Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/ide/RFC.md §3.2 (Workspace Tools). Im...' (#9) from agent/read---spec-code-core-ide-rfc-md--3-2--w into dev
Some checks failed
Security Scan / security (push) Successful in 11s
Test / test (push) Has been cancelled

This commit is contained in:
Virgil 2026-03-31 18:53:20 +00:00
commit 7518c53bfa
8 changed files with 800 additions and 54 deletions

View file

@ -1,4 +1,4 @@
//go:build !ios
//go:build ignore
package main
@ -7,4 +7,4 @@ import "github.com/wailsapp/wails/v3/pkg/application"
// modifyOptionsForIOS is a no-op on non-iOS platforms
func modifyOptionsForIOS(opts *application.Options) {
// No modifications needed for non-iOS platforms
}
}

66
go.mod
View file

@ -16,12 +16,44 @@ require (
)
require (
dario.cat/mergo v1.0.2 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/go-git/go-git/v5 v5.17.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
@ -31,20 +63,15 @@ require (
require (
dappco.re/go/core/io v0.2.0 // indirect
dappco.re/go/core/log v0.1.0
dario.cat/mergo v1.0.2 // indirect
forge.lthn.ai/core/go-ai v0.1.11 // indirect
forge.lthn.ai/core/go-io v0.1.7 // indirect
forge.lthn.ai/core/go-rag v0.1.11 // indirect
forge.lthn.ai/core/go-webview v0.1.5 // indirect
github.com/99designs/gqlgen v0.17.88 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
@ -53,14 +80,9 @@ require (
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/authz v1.0.6 // indirect
@ -77,13 +99,9 @@ require (
github.com/gin-contrib/static v1.1.5 // indirect
github.com/gin-contrib/timeout v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/go-git/go-git/v5 v5.17.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
@ -100,8 +118,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
@ -109,36 +125,23 @@ require (
github.com/gorilla/sessions v1.4.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modelcontextprotocol/go-sdk v1.4.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ollama/ollama v0.18.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/qdrant/go-client v1.17.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/sosodev/duration v1.4.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@ -151,9 +154,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@ -176,6 +177,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gopkg.in/yaml.v3 v3.0.1
)

26
gui_enabled.go Normal file
View file

@ -0,0 +1,26 @@
package main
import (
"os"
"runtime"
"forge.lthn.ai/core/config"
)
// guiEnabled checks whether the GUI should start.
// Returns false if config says gui.enabled: false, or if no display is available.
func guiEnabled(cfg *config.Config) bool {
if cfg != nil {
var guiCfg struct {
Enabled *bool `mapstructure:"enabled"`
}
if err := cfg.Get("gui", &guiCfg); err == nil && guiCfg.Enabled != nil {
return *guiCfg.Enabled
}
}
// Fall back to display detection.
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return true
}
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}

21
main.go
View file

@ -1,3 +1,5 @@
//go:build !linux
package main
import (
@ -85,6 +87,7 @@ func main() {
// ── Providers API ─────────────────────────────────────────
// Exposes GET /api/v1/providers for the Angular frontend
engine.Register(NewProvidersAPI(reg, rm))
engine.Register(NewWorkspaceAPI(cwd))
// ── Core framework ─────────────────────────────────────────
c, err := core.New(
@ -298,21 +301,3 @@ func main() {
log.Fatal(err)
}
}
// guiEnabled checks whether the GUI should start.
// Returns false if config says gui.enabled: false, or if no display is available.
func guiEnabled(cfg *config.Config) bool {
if cfg != nil {
var guiCfg struct {
Enabled *bool `mapstructure:"enabled"`
}
if err := cfg.Get("gui", &guiCfg); err == nil && guiCfg.Enabled != nil {
return *guiCfg.Enabled
}
}
// Fall back to display detection
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return true
}
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}

136
main_linux.go Normal file
View file

@ -0,0 +1,136 @@
//go:build linux
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"forge.lthn.ai/core/config"
process "forge.lthn.ai/core/go-process"
processapi "forge.lthn.ai/core/go-process/pkg/api"
"forge.lthn.ai/core/go-ws"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/mcp/pkg/mcp"
"forge.lthn.ai/core/mcp/pkg/mcp/agentic"
"forge.lthn.ai/core/mcp/pkg/mcp/brain"
"forge.lthn.ai/core/mcp/pkg/mcp/ide"
)
func main() {
mcpOnly := false
for _, arg := range os.Args[1:] {
if arg == "--mcp" {
mcpOnly = true
}
}
cfg, _ := config.New()
cwd, err := os.Getwd()
if err != nil {
log.Fatalf("failed to get working directory: %v", err)
}
hub := ws.NewHub()
bridgeCfg := ide.DefaultConfig()
bridgeCfg.WorkspaceRoot = cwd
if url := os.Getenv("CORE_API_URL"); url != "" {
bridgeCfg.LaravelWSURL = url
}
if token := os.Getenv("CORE_API_TOKEN"); token != "" {
bridgeCfg.Token = token
}
bridge := ide.NewBridge(hub, bridgeCfg)
reg := provider.NewRegistry()
reg.Add(processapi.NewProvider(process.DefaultRegistry(), hub))
reg.Add(brain.NewProvider(bridge, hub))
apiAddr := ":9880"
if addr := os.Getenv("CORE_API_ADDR"); addr != "" {
apiAddr = addr
}
engine, _ := api.New(
api.WithAddr(apiAddr),
api.WithCORS("*"),
api.WithWSHandler(http.Handler(hub.Handler())),
api.WithSwagger("Core IDE", "Service Provider API", "0.1.0"),
)
reg.MountAll(engine)
rm := NewRuntimeManager(engine)
engine.Register(NewProvidersAPI(reg, rm))
engine.Register(NewWorkspaceAPI(cwd))
c, err := core.New(
core.WithName("ws", func(c *core.Core) (any, error) {
return hub, nil
}),
core.WithName("mcp", func(c *core.Core) (any, error) {
return mcp.New(
mcp.WithWorkspaceRoot(cwd),
mcp.WithWSHub(hub),
mcp.WithSubsystem(brain.NewDirect()),
mcp.WithSubsystem(agentic.NewPrep()),
)
}),
)
if err != nil {
log.Fatalf("failed to create core: %v", err)
}
mcpSvc, err := core.ServiceFor[*mcp.Service](c, "mcp")
if err != nil {
log.Fatalf("failed to get MCP service: %v", err)
}
if guiEnabled(cfg) {
log.Printf("GUI mode is unavailable in this Linux build; running headless instead")
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := c.ServiceStartup(ctx, nil); err != nil {
log.Fatalf("core startup failed: %v", err)
}
bridge.Start(ctx)
go hub.Run(ctx)
if err := rm.StartAll(ctx); err != nil {
log.Printf("runtime provider error: %v", err)
}
go func() {
log.Printf("API server listening on %s", apiAddr)
if err := engine.Serve(ctx); err != nil {
log.Printf("API server error: %v", err)
}
}()
if mcpOnly {
if err := mcpSvc.ServeStdio(ctx); err != nil {
log.Printf("MCP stdio error: %v", err)
}
} else {
go func() {
if err := mcpSvc.Run(ctx); err != nil {
log.Printf("MCP error: %v", err)
}
}()
<-ctx.Done()
}
rm.StopAll()
shutdownCtx := context.Background()
_ = mcpSvc.Shutdown(shutdownCtx)
_ = c.ServiceShutdown(shutdownCtx)
}

View file

@ -82,5 +82,5 @@ func TestProvidersAPI_List_Good_WithRuntimeProviders(t *testing.T) {
require.Len(t, resp.Providers, 1)
assert.Equal(t, "test-provider", resp.Providers[0].Name)
assert.Equal(t, "test", resp.Providers[0].BasePath)
assert.Equal(t, "active", resp.Providers[0].Status)
assert.Equal(t, "running", resp.Providers[0].Status)
}

501
workspace.go Normal file
View file

@ -0,0 +1,501 @@
// SPDX-Licence-Identifier: EUPL-1.2
package main
import (
"bufio"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
// WorkspaceAPI exposes project context derived from .core/ and git status.
type WorkspaceAPI struct {
root string
}
func NewWorkspaceAPI(root string) *WorkspaceAPI {
return &WorkspaceAPI{root: root}
}
func (w *WorkspaceAPI) Name() string { return "workspace-api" }
func (w *WorkspaceAPI) BasePath() string { return "/api/v1/workspace" }
func (w *WorkspaceAPI) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/status", w.status)
rg.GET("/conventions", w.conventions)
rg.GET("/impact", w.impact)
}
type workspaceStatusResponse struct {
Root string `json:"root"`
Git gitStatusSummary `json:"git"`
CoreFiles []workspaceFile `json:"coreFiles"`
Counts workspaceFileCount `json:"counts"`
UpdatedAt string `json:"updatedAt"`
}
type workspaceConventionsResponse struct {
Root string `json:"root"`
Sources []string `json:"sources"`
Build buildConfigSummary `json:"build"`
Conventions []string `json:"conventions"`
Notes []string `json:"notes"`
}
type workspaceImpactResponse struct {
Root string `json:"root"`
Git gitStatusSummary `json:"git"`
ImpactedAreas []string `json:"impactedAreas"`
SuggestedChecks []string `json:"suggestedChecks"`
Notes []string `json:"notes"`
}
type workspaceFileCount struct {
Total int `json:"total"`
Text int `json:"text"`
}
type workspaceFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
Modified string `json:"modified"`
Preview string `json:"preview,omitempty"`
Content string `json:"content,omitempty"`
IsText bool `json:"isText"`
}
type gitStatusSummary struct {
Branch string `json:"branch,omitempty"`
RawHeader string `json:"rawHeader,omitempty"`
Clean bool `json:"clean"`
Changes []gitChange `json:"changes,omitempty"`
ChangeCounts gitChangeCounts `json:"changeCounts"`
}
type gitChangeCounts struct {
Staged int `json:"staged"`
Unstaged int `json:"unstaged"`
Untracked int `json:"untracked"`
}
type gitChange struct {
Code string `json:"code"`
Path string `json:"path"`
}
type buildConfigSummary struct {
Version string `json:"version,omitempty"`
ProjectName string `json:"projectName,omitempty"`
ProjectDescription string `json:"projectDescription,omitempty"`
Binary string `json:"binary,omitempty"`
BuildType string `json:"buildType,omitempty"`
CGO bool `json:"cgo"`
Flags []string `json:"flags,omitempty"`
Ldflags []string `json:"ldflags,omitempty"`
Targets []buildTarget `json:"targets,omitempty"`
RawFiles []string `json:"rawFiles,omitempty"`
}
type buildTarget struct {
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
}
func (w *WorkspaceAPI) status(c *gin.Context) {
snapshot, err := collectWorkspaceSnapshot(w.root)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, workspaceStatusResponse{
Root: snapshot.Root,
Git: snapshot.Git,
CoreFiles: snapshot.CoreFiles,
Counts: snapshot.Counts,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
})
}
func (w *WorkspaceAPI) conventions(c *gin.Context) {
snapshot, err := collectWorkspaceSnapshot(w.root)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
conventions := []string{
"Use UK English in documentation and user-facing strings.",
"Use conventional commits: type(scope): description.",
"Go code lives in package main for this module.",
"Prefer core build for production builds and core go test for Go tests.",
}
notes := []string{
"Design input was derived from CLAUDE.md, docs/development.md, and .core/build.yaml.",
}
if !snapshot.Git.Clean {
notes = append(notes, "The worktree is dirty, so any new work should account for local changes.")
}
c.JSON(200, workspaceConventionsResponse{
Root: snapshot.Root,
Sources: snapshot.SourceFiles,
Build: snapshot.Build,
Conventions: conventions,
Notes: notes,
})
}
func (w *WorkspaceAPI) impact(c *gin.Context) {
snapshot, err := collectWorkspaceSnapshot(w.root)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
impactedAreas := classifyImpact(snapshot.Git.Changes, snapshot.SourceFiles)
suggestedChecks := []string{"go build ./...", "go vet ./...", "go test ./... -count=1 -timeout 120s", "go test -cover ./..."}
if hasPathPrefix(snapshot.SourceFiles, ".core/") {
suggestedChecks = append([]string{"core build"}, suggestedChecks...)
}
if hasAnyImpact(snapshot.Git.Changes, "frontend/") {
suggestedChecks = append(suggestedChecks, "cd frontend && npm test")
}
notes := []string{
"Impact categories are inferred from changed paths and .core configuration files.",
}
if snapshot.Git.Clean {
notes = append(notes, "The worktree is clean, so there is no active change set to assess.")
}
c.JSON(200, workspaceImpactResponse{
Root: snapshot.Root,
Git: snapshot.Git,
ImpactedAreas: impactedAreas,
SuggestedChecks: uniqueStrings(suggestedChecks),
Notes: notes,
})
}
type workspaceSnapshot struct {
Root string
Git gitStatusSummary
CoreFiles []workspaceFile
Counts workspaceFileCount
Build buildConfigSummary
SourceFiles []string
}
func collectWorkspaceSnapshot(root string) (*workspaceSnapshot, error) {
if root == "" {
root = "."
}
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve workspace root: %w", err)
}
coreFiles, counts, sourceFiles, err := readCoreFiles(absRoot)
if err != nil {
return nil, err
}
gitStatus, err := readGitStatus(absRoot)
if err != nil {
return nil, err
}
buildSummary := parseBuildConfig(coreFiles)
return &workspaceSnapshot{
Root: absRoot,
Git: gitStatus,
CoreFiles: coreFiles,
Counts: counts,
Build: buildSummary,
SourceFiles: sourceFiles,
}, nil
}
func readCoreFiles(root string) ([]workspaceFile, workspaceFileCount, []string, error) {
coreDir := filepath.Join(root, ".core")
entries := []workspaceFile{}
sourceFiles := []string{}
counts := workspaceFileCount{}
info, err := os.Stat(coreDir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return entries, counts, sourceFiles, nil
}
return nil, counts, sourceFiles, fmt.Errorf("inspect .core directory: %w", err)
}
if !info.IsDir() {
return nil, counts, sourceFiles, fmt.Errorf("%s is not a directory", coreDir)
}
err = filepath.WalkDir(coreDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
stat, err := d.Info()
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
text := isLikelyText(data)
if text {
counts.Text++
}
counts.Total++
entries = append(entries, workspaceFile{
Path: filepath.ToSlash(rel),
Size: stat.Size(),
Modified: stat.ModTime().UTC().Format(time.RFC3339),
Preview: filePreview(data, 8, 480),
Content: fileContent(data, 64*1024),
IsText: text,
})
sourceFiles = append(sourceFiles, filepath.ToSlash(rel))
return nil
})
if err != nil {
return nil, counts, sourceFiles, fmt.Errorf("read .core files: %w", err)
}
return entries, counts, sourceFiles, nil
}
func filePreview(data []byte, maxLines int, maxBytes int) string {
if len(data) == 0 {
return ""
}
if len(data) > maxBytes {
data = data[:maxBytes]
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
lines := make([]string, 0, maxLines)
for scanner.Scan() {
lines = append(lines, scanner.Text())
if len(lines) >= maxLines {
break
}
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func fileContent(data []byte, maxBytes int) string {
if len(data) == 0 {
return ""
}
if len(data) > maxBytes {
data = data[:maxBytes]
}
return strings.TrimSpace(string(data))
}
func isLikelyText(data []byte) bool {
for _, b := range data {
if b == 0 {
return false
}
}
return true
}
func readGitStatus(root string) (gitStatusSummary, error) {
cmd := exec.Command("git", "-C", root, "status", "--short", "--branch", "--untracked-files=all")
out, err := cmd.Output()
if err != nil {
return gitStatusSummary{}, fmt.Errorf("git status: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
summary := gitStatusSummary{Clean: true}
if len(lines) == 1 && lines[0] == "" {
return summary, nil
}
for i, line := range lines {
if line == "" {
continue
}
if i == 0 && strings.HasPrefix(line, "## ") {
summary.RawHeader = strings.TrimSpace(strings.TrimPrefix(line, "## "))
summary.Branch = parseGitBranch(summary.RawHeader)
continue
}
if len(line) < 3 {
continue
}
change := gitChange{Code: line[:2], Path: strings.TrimSpace(line[3:])}
summary.Changes = append(summary.Changes, change)
summary.Clean = false
switch change.Code {
case "??":
summary.ChangeCounts.Untracked++
default:
if change.Code[0] != ' ' {
summary.ChangeCounts.Staged++
}
if change.Code[1] != ' ' {
summary.ChangeCounts.Unstaged++
}
}
}
return summary, nil
}
func parseGitBranch(header string) string {
if header == "" {
return ""
}
branch := header
if idx := strings.Index(branch, "..."); idx >= 0 {
branch = branch[:idx]
}
branch = strings.TrimSpace(branch)
branch = strings.TrimPrefix(branch, "(detached from ")
branch = strings.TrimSuffix(branch, ")")
return branch
}
func parseBuildConfig(files []workspaceFile) buildConfigSummary {
summary := buildConfigSummary{}
for _, file := range files {
if filepath.Base(file.Path) != "build.yaml" {
continue
}
summary.RawFiles = append(summary.RawFiles, file.Path)
content := file.Content
if content == "" {
content = file.Preview
}
var raw struct {
Version any `yaml:"version"`
Project struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Binary string `yaml:"binary"`
} `yaml:"project"`
Build struct {
Type string `yaml:"type"`
CGO bool `yaml:"cgo"`
Flags []string `yaml:"flags"`
Ldflags []string `yaml:"ldflags"`
} `yaml:"build"`
Targets []buildTarget `yaml:"targets"`
}
if err := yaml.Unmarshal([]byte(content), &raw); err != nil {
continue
}
summary.Version = fmt.Sprint(raw.Version)
summary.ProjectName = raw.Project.Name
summary.ProjectDescription = raw.Project.Description
summary.Binary = raw.Project.Binary
summary.BuildType = raw.Build.Type
summary.CGO = raw.Build.CGO
summary.Flags = append(summary.Flags, raw.Build.Flags...)
summary.Ldflags = append(summary.Ldflags, raw.Build.Ldflags...)
summary.Targets = append(summary.Targets, raw.Targets...)
break
}
return summary
}
func classifyImpact(changes []gitChange, sourceFiles []string) []string {
areas := []string{}
for _, change := range changes {
path := change.Path
switch {
case strings.HasPrefix(path, ".core/"):
areas = append(areas, "build configuration")
case path == "go.mod" || path == "go.sum":
areas = append(areas, "dependency graph")
case strings.HasSuffix(path, ".go"):
areas = append(areas, "Go backend")
case strings.HasPrefix(path, "frontend/"):
areas = append(areas, "Angular frontend")
case strings.HasPrefix(path, "docs/") || strings.HasSuffix(path, ".md"):
areas = append(areas, "documentation")
case strings.HasPrefix(path, "build/"):
areas = append(areas, "packaging and platform build files")
default:
areas = append(areas, "general project context")
}
}
if hasPathPrefix(sourceFiles, ".core/") {
areas = append(areas, "workspace metadata")
}
if len(changes) == 0 {
areas = append(areas, "no active changes")
}
return uniqueStrings(areas)
}
func hasPathPrefix(paths []string, prefix string) bool {
for _, path := range paths {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func hasAnyImpact(changes []gitChange, prefix string) bool {
for _, change := range changes {
if strings.HasPrefix(change.Path, prefix) {
return true
}
}
return false
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}

98
workspace_test.go Normal file
View file

@ -0,0 +1,98 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadCoreFiles_Good(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, ".core"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, ".core", "build.yaml"), []byte("version: 1\nproject:\n name: demo\n"), 0o644))
files, counts, sourceFiles, err := readCoreFiles(root)
require.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, 1, counts.Total)
assert.Equal(t, 1, counts.Text)
assert.Equal(t, []string{".core/build.yaml"}, sourceFiles)
assert.Equal(t, ".core/build.yaml", files[0].Path)
assert.True(t, files[0].IsText)
}
func TestParseBuildConfig_Good(t *testing.T) {
content, err := os.ReadFile(filepath.Join(".core", "build.yaml"))
require.NoError(t, err)
files := []workspaceFile{{Path: ".core/build.yaml", Content: string(content)}}
summary := parseBuildConfig(files)
assert.Equal(t, "1", summary.Version)
assert.Equal(t, "core-ide", summary.ProjectName)
assert.Equal(t, "Core IDE - Development Environment", summary.ProjectDescription)
assert.Equal(t, "core-ide", summary.Binary)
assert.Equal(t, "wails", summary.BuildType)
assert.True(t, summary.CGO)
assert.Equal(t, []string{"-trimpath"}, summary.Flags)
assert.Equal(t, []string{"-s", "-w"}, summary.Ldflags)
require.Len(t, summary.Targets, 3)
assert.Equal(t, "darwin", summary.Targets[0].OS)
assert.Equal(t, "arm64", summary.Targets[0].Arch)
assert.Equal(t, "linux", summary.Targets[1].OS)
assert.Equal(t, "amd64", summary.Targets[1].Arch)
assert.Equal(t, "windows", summary.Targets[2].OS)
assert.Equal(t, "amd64", summary.Targets[2].Arch)
}
func TestReadGitStatus_Good(t *testing.T) {
root := t.TempDir()
runGit(t, root, "init")
runGit(t, root, "config", "user.email", "test@example.com")
runGit(t, root, "config", "user.name", "Test User")
require.NoError(t, os.WriteFile(filepath.Join(root, "README.md"), []byte("hello\n"), 0o644))
runGit(t, root, "add", "README.md")
runGit(t, root, "commit", "-m", "initial")
require.NoError(t, os.WriteFile(filepath.Join(root, "README.md"), []byte("hello world\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "new.txt"), []byte("new\n"), 0o644))
status, err := readGitStatus(root)
require.NoError(t, err)
assert.False(t, status.Clean)
assert.NotEmpty(t, status.Branch)
assert.GreaterOrEqual(t, len(status.Changes), 2)
assert.GreaterOrEqual(t, status.ChangeCounts.Unstaged, 1)
assert.GreaterOrEqual(t, status.ChangeCounts.Untracked, 1)
}
func TestClassifyImpact_Good(t *testing.T) {
areas := classifyImpact([]gitChange{
{Path: ".core/build.yaml"},
{Path: "main.go"},
{Path: "frontend/src/app/app.ts"},
{Path: "docs/index.md"},
}, []string{".core/build.yaml"})
assert.Contains(t, areas, "build configuration")
assert.Contains(t, areas, "Go backend")
assert.Contains(t, areas, "Angular frontend")
assert.Contains(t, areas, "documentation")
assert.Contains(t, areas, "workspace metadata")
}
func TestWorkspaceAPI_BasePath(t *testing.T) {
api := NewWorkspaceAPI(".")
assert.Equal(t, "workspace-api", api.Name())
assert.Equal(t, "/api/v1/workspace", api.BasePath())
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
}