diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7ddc748..de24973 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -13,7 +13,7 @@ builds: - amd64 - arm64 ldflags: - - -s -w + - -s -w -X main.version={{.Version}} archives: - formats: [tar.gz] diff --git a/CONTEXT.yaml b/CONTEXT.yaml index 01877ea..c1381bd 100644 --- a/CONTEXT.yaml +++ b/CONTEXT.yaml @@ -90,7 +90,7 @@ risks: mitigation: Pin the exact version in go.mod; read release notes before running go get - content: ktext init scanner heuristics are brittle for non-standard repo layouts severity: low - mitigation: All scanner output is reviewed interactively by the user before writing — bad guesses are correctable + mitigation: Scanner output is written immediately by default; use -interactive to review before writing — bad guesses are correctable dependencies: - name: gopkg.in/yaml.v3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35a815b..436bf26 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ go test ./... # test go vet ./... # vet ``` -Go 1.22+ required. No other tooling needed. +Go 1.25+ required. No other tooling needed. ## Project layout @@ -51,7 +51,7 @@ Export formats live in `internal/export/`. To add one: ### Scanner heuristics -`internal/scan/scan.go` discovers project context from the filesystem. Scanner output is always reviewed interactively by the user before being written, so false positives are acceptable. False negatives (missing real information) are worse. When in doubt, surface the data and let the user decide. +`internal/scan/scan.go` discovers project context from the filesystem. Scanner output is written immediately by default; use `-interactive` to review before writing. False positives are acceptable. False negatives (missing real information) are worse. When in doubt, surface the data and let the user decide. ## Tests diff --git a/README.md b/README.md index a39616a..dbbbc28 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,11 @@ Or download a binary from the [releases page](https://github.com/arithmetike/kte ### `ktext init` -Scans the repo and generates a `CONTEXT.yaml`. Reads your README, package manifests, Makefile, ADRs, and directory structure to make initial guesses, then walks you through each field interactively. Hit Enter to accept a value or type a replacement. Nothing is written until you finish the review. +Scans the repo and generates a `CONTEXT.yaml`. Reads your README, package manifests, Makefile, ADRs, and directory structure to fill in what it can, writes the file immediately, and reports what was discovered and what still needs manual editing. Use `-interactive` to review and edit each value before writing. ```bash -ktext init +ktext init # scan and write immediately +ktext init -interactive # review each value before writing ``` ### `ktext validate` diff --git a/cmd/ktext/args_test.go b/cmd/ktext/args_test.go new file mode 100644 index 0000000..de312f6 --- /dev/null +++ b/cmd/ktext/args_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestSplitArgsFlagsBeforePositional(t *testing.T) { + flags, pos := splitArgs( + []string{"-threshold", "90", "-json", "CONTEXT.yaml"}, + map[string]bool{"json": true}, + ) + wantFlags := []string{"-threshold", "90", "-json"} + wantPos := []string{"CONTEXT.yaml"} + if !reflect.DeepEqual(flags, wantFlags) { + t.Errorf("flags = %v, want %v", flags, wantFlags) + } + if !reflect.DeepEqual(pos, wantPos) { + t.Errorf("positional = %v, want %v", pos, wantPos) + } +} + +func TestSplitArgsFlagsAfterPositional(t *testing.T) { + flags, pos := splitArgs( + []string{"xml", "-write"}, + map[string]bool{"write": true}, + ) + wantFlags := []string{"-write"} + wantPos := []string{"xml"} + if !reflect.DeepEqual(flags, wantFlags) { + t.Errorf("flags = %v, want %v", flags, wantFlags) + } + if !reflect.DeepEqual(pos, wantPos) { + t.Errorf("positional = %v, want %v", pos, wantPos) + } +} + +func TestSplitArgsFlagEqualsValue(t *testing.T) { + flags, pos := splitArgs( + []string{"-file=custom.yaml", "json"}, + map[string]bool{}, + ) + wantFlags := []string{"-file=custom.yaml"} + wantPos := []string{"json"} + if !reflect.DeepEqual(flags, wantFlags) { + t.Errorf("flags = %v, want %v", flags, wantFlags) + } + if !reflect.DeepEqual(pos, wantPos) { + t.Errorf("positional = %v, want %v", pos, wantPos) + } +} + +func TestSplitArgsEmpty(t *testing.T) { + flags, pos := splitArgs(nil, map[string]bool{}) + if flags != nil { + t.Errorf("flags = %v, want nil", flags) + } + if pos != nil { + t.Errorf("positional = %v, want nil", pos) + } +} + +func TestSplitArgsOnlyPositional(t *testing.T) { + flags, pos := splitArgs( + []string{"xml"}, + map[string]bool{}, + ) + if flags != nil { + t.Errorf("flags = %v, want nil", flags) + } + wantPos := []string{"xml"} + if !reflect.DeepEqual(pos, wantPos) { + t.Errorf("positional = %v, want %v", pos, wantPos) + } +} + +func TestSplitArgsMixedOrder(t *testing.T) { + flags, pos := splitArgs( + []string{"-verbose", "xml", "-file", "custom.yaml"}, + map[string]bool{"verbose": true}, + ) + wantFlags := []string{"-verbose", "-file", "custom.yaml"} + wantPos := []string{"xml"} + if !reflect.DeepEqual(flags, wantFlags) { + t.Errorf("flags = %v, want %v", flags, wantFlags) + } + if !reflect.DeepEqual(pos, wantPos) { + t.Errorf("positional = %v, want %v", pos, wantPos) + } +} diff --git a/cmd/ktext/main.go b/cmd/ktext/main.go index 57ead25..0cee8d8 100644 --- a/cmd/ktext/main.go +++ b/cmd/ktext/main.go @@ -5,7 +5,8 @@ import ( "os" ) -const version = "0.1.0" +// version is set at build time via ldflags. Falls back to "dev" for local builds. +var version = "dev" func main() { if len(os.Args) < 2 { diff --git a/internal/export/json_test.go b/internal/export/json_test.go new file mode 100644 index 0000000..9145f6d --- /dev/null +++ b/internal/export/json_test.go @@ -0,0 +1,141 @@ +package export_test + +import ( + "encoding/json" + "testing" + + "github.com/arithmetike/ktext/internal/export" + "github.com/arithmetike/ktext/internal/schema" +) + +func TestJSONExportUsesLowercaseKeys(t *testing.T) { + doc := &schema.Context{ + Ktext: "1.0.0", + Identity: schema.Identity{ + Name: "demo", + URL: "https://example.test/demo", + Type: "tool", + Purpose: "a demo project", + Status: "active", + }, + Constraints: []schema.Constraint{ + {Content: "never do X", Scope: "all", Why: "safety"}, + }, + Decisions: []schema.Decision{ + {Title: "use Go", Rationale: "fast builds", Date: "2026-04", Status: "accepted"}, + }, + Conventions: []schema.Convention{ + {Rule: "use gofmt", Why: "consistency"}, + }, + Risks: []schema.Risk{ + {Content: "schema renames break files", Severity: "high", Mitigation: "version field"}, + }, + Dependencies: []schema.Dependency{ + {Name: "yaml.v3", URL: "https://pkg.go.dev/gopkg.in/yaml.v3", Why: "YAML parsing"}, + }, + Working: &schema.Working{ + Commands: []schema.Command{ + {Command: "go build ./...", Description: "build"}, + }, + Structure: []schema.StructureEntry{ + {Path: "cmd/", Description: "CLI entry"}, + }, + Notes: []string{"run tests first"}, + }, + Ownership: &schema.Ownership{ + Team: "platform", + Escalation: "#oncall", + Maintainers: []schema.Maintainer{ + {Name: "alice", Role: "lead"}, + }, + }, + } + + out, err := export.Render("json", doc) + if err != nil { + t.Fatalf("render error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal([]byte(out), &raw); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + // Top-level keys must be lowercase schema names. + for _, key := range []string{"ktext", "identity", "constraints", "decisions", "conventions", "risks", "dependencies", "working", "ownership"} { + if _, ok := raw[key]; !ok { + t.Errorf("missing top-level key %q", key) + } + } + + // Nested identity keys must be lowercase. + identity, ok := raw["identity"].(map[string]any) + if !ok { + t.Fatal("identity is not an object") + } + for _, key := range []string{"name", "url", "type", "purpose", "status"} { + if _, ok := identity[key]; !ok { + t.Errorf("identity: missing key %q", key) + } + } + + // Go field names must NOT appear. + for _, bad := range []string{"Name", "URL", "Type", "Purpose", "Status", "Content", "Severity", "Mitigation", "Title", "Rationale", "Rule", "Command", "Description", "Path", "Team", "Escalation", "Maintainers"} { + if _, ok := identity[bad]; ok { + t.Errorf("identity: Go field name %q should not appear", bad) + } + } + + // Check nested struct keys in constraints. + constraints, ok := raw["constraints"].([]any) + if !ok || len(constraints) == 0 { + t.Fatal("constraints is not a non-empty array") + } + c0, ok := constraints[0].(map[string]any) + if !ok { + t.Fatal("constraints[0] is not an object") + } + for _, key := range []string{"content", "scope", "why"} { + if _, ok := c0[key]; !ok { + t.Errorf("constraints[0]: missing key %q", key) + } + } + + // Check working.commands nested keys. + working, ok := raw["working"].(map[string]any) + if !ok { + t.Fatal("working is not an object") + } + cmds, ok := working["commands"].([]any) + if !ok || len(cmds) == 0 { + t.Fatal("working.commands is not a non-empty array") + } + cmd0, ok := cmds[0].(map[string]any) + if !ok { + t.Fatal("working.commands[0] is not an object") + } + for _, key := range []string{"command", "description"} { + if _, ok := cmd0[key]; !ok { + t.Errorf("working.commands[0]: missing key %q", key) + } + } + + // Check ownership.maintainers nested keys. + ownership, ok := raw["ownership"].(map[string]any) + if !ok { + t.Fatal("ownership is not an object") + } + maintainers, ok := ownership["maintainers"].([]any) + if !ok || len(maintainers) == 0 { + t.Fatal("ownership.maintainers is not a non-empty array") + } + m0, ok := maintainers[0].(map[string]any) + if !ok { + t.Fatal("ownership.maintainers[0] is not an object") + } + for _, key := range []string{"name", "role"} { + if _, ok := m0[key]; !ok { + t.Errorf("ownership.maintainers[0]: missing key %q", key) + } + } +} diff --git a/internal/scan/scan.go b/internal/scan/scan.go index eff909f..9538633 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -513,7 +513,10 @@ func scanDependencies(repoPath string, log *[]string) []schema.Dependency { orgPrefix = m[1] + "/" } if orgPrefix != "" { - all := pkg.Dependencies + all := make(map[string]string) + for k, v := range pkg.Dependencies { + all[k] = v + } for k, v := range pkg.DevDeps { all[k] = v } diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go new file mode 100644 index 0000000..0869708 --- /dev/null +++ b/internal/scan/scan_test.go @@ -0,0 +1,99 @@ +package scan_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/arithmetike/ktext/internal/scan" +) + +func TestScanDepsDevDependenciesOnly(t *testing.T) { + dir := t.TempDir() + + pkg := map[string]any{ + "name": "@myorg/service", + "devDependencies": map[string]string{ + "@myorg/shared": "^1.0.0", + "typescript": "^5.0.0", + }, + } + b, err := json.Marshal(pkg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), b, 0o644); err != nil { + t.Fatal(err) + } + + // Must not panic. + result := scan.Repo(dir) + + // Should discover the org-scoped sibling from devDependencies. + found := false + for _, d := range result.Dependencies { + if d.Name == "shared" { + found = true + break + } + } + if !found { + t.Errorf("expected dependency 'shared' from devDependencies, got %v", result.Dependencies) + } +} + +func TestScanDepsBothDependenciesAndDevDependencies(t *testing.T) { + dir := t.TempDir() + + pkg := map[string]any{ + "name": "@myorg/app", + "dependencies": map[string]string{ + "@myorg/core": "^2.0.0", + }, + "devDependencies": map[string]string{ + "@myorg/testing": "^1.0.0", + }, + } + b, err := json.Marshal(pkg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), b, 0o644); err != nil { + t.Fatal(err) + } + + result := scan.Repo(dir) + + names := map[string]bool{} + for _, d := range result.Dependencies { + names[d.Name] = true + } + if !names["core"] { + t.Error("expected dependency 'core' from dependencies") + } + if !names["testing"] { + t.Error("expected dependency 'testing' from devDependencies") + } +} + +func TestScanDepsNoDependencies(t *testing.T) { + dir := t.TempDir() + + pkg := map[string]any{ + "name": "@myorg/empty", + } + b, err := json.Marshal(pkg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), b, 0o644); err != nil { + t.Fatal(err) + } + + // Must not panic. + result := scan.Repo(dir) + if len(result.Dependencies) != 0 { + t.Errorf("expected no dependencies, got %v", result.Dependencies) + } +} diff --git a/internal/schema/context.go b/internal/schema/context.go index ba2bc30..0c506fa 100644 --- a/internal/schema/context.go +++ b/internal/schema/context.go @@ -21,68 +21,68 @@ type Context struct { // Identity describes what the project is. type Identity struct { - Name string `yaml:"name"` - URL string `yaml:"url"` - Type string `yaml:"type,omitempty"` - Purpose string `yaml:"purpose,omitempty"` - Status string `yaml:"status,omitempty"` + Name string `yaml:"name" json:"name"` + URL string `yaml:"url" json:"url"` + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Purpose string `yaml:"purpose,omitempty" json:"purpose,omitempty"` + Status string `yaml:"status,omitempty" json:"status,omitempty"` } // Constraint is a rule engineers or AI must not violate. type Constraint struct { - Content string `yaml:"content"` - Scope string `yaml:"scope,omitempty"` - Why string `yaml:"why,omitempty"` + Content string `yaml:"content" json:"content"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` + Why string `yaml:"why,omitempty" json:"why,omitempty"` } // Decision records an architectural choice and its rationale. type Decision struct { - Title string `yaml:"title"` - Rationale string `yaml:"rationale"` - Date string `yaml:"date,omitempty"` - Status string `yaml:"status,omitempty"` - Reference string `yaml:"reference,omitempty"` + Title string `yaml:"title" json:"title"` + Rationale string `yaml:"rationale" json:"rationale"` + Date string `yaml:"date,omitempty" json:"date,omitempty"` + Status string `yaml:"status,omitempty" json:"status,omitempty"` + Reference string `yaml:"reference,omitempty" json:"reference,omitempty"` } // Convention is a coding or process rule for the project. type Convention struct { - Rule string `yaml:"rule"` - Scope string `yaml:"scope,omitempty"` - Why string `yaml:"why,omitempty"` - Reference string `yaml:"reference,omitempty"` + Rule string `yaml:"rule" json:"rule"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` + Why string `yaml:"why,omitempty" json:"why,omitempty"` + Reference string `yaml:"reference,omitempty" json:"reference,omitempty"` } // Risk is a known vulnerability, tech-debt item, or operational risk. type Risk struct { - Content string `yaml:"content"` - Severity string `yaml:"severity"` // "high" | "medium" | "low" - Mitigation string `yaml:"mitigation,omitempty"` + Content string `yaml:"content" json:"content"` + Severity string `yaml:"severity" json:"severity"` // "high" | "medium" | "low" + Mitigation string `yaml:"mitigation,omitempty" json:"mitigation,omitempty"` } // Dependency is an outgoing connection from this project to another system. type Dependency struct { - Name string `yaml:"name"` - URL string `yaml:"url"` - Why string `yaml:"why,omitempty"` + Name string `yaml:"name" json:"name"` + URL string `yaml:"url" json:"url"` + Why string `yaml:"why,omitempty" json:"why,omitempty"` } // Working holds information a developer or LLM needs to operate in the codebase. type Working struct { - Commands []Command `yaml:"commands,omitempty"` - Structure []StructureEntry `yaml:"structure,omitempty"` - Notes []string `yaml:"notes,omitempty"` + Commands []Command `yaml:"commands,omitempty" json:"commands,omitempty"` + Structure []StructureEntry `yaml:"structure,omitempty" json:"structure,omitempty"` + Notes []string `yaml:"notes,omitempty" json:"notes,omitempty"` } // Command is a runnable shell command with a description. type Command struct { - Command string `yaml:"command"` - Description string `yaml:"description"` + Command string `yaml:"command" json:"command"` + Description string `yaml:"description" json:"description"` } // StructureEntry describes a path within the project. type StructureEntry struct { - Path string `yaml:"path"` - Description string `yaml:"description"` + Path string `yaml:"path" json:"path"` + Description string `yaml:"description" json:"description"` } // Links is an open map of name → URL. @@ -90,13 +90,13 @@ type Links map[string]string // Ownership records who maintains the project. type Ownership struct { - Team string `yaml:"team,omitempty"` - Escalation string `yaml:"escalation,omitempty"` - Maintainers []Maintainer `yaml:"maintainers,omitempty"` + Team string `yaml:"team,omitempty" json:"team,omitempty"` + Escalation string `yaml:"escalation,omitempty" json:"escalation,omitempty"` + Maintainers []Maintainer `yaml:"maintainers,omitempty" json:"maintainers,omitempty"` } // Maintainer is a named person responsible for the project. type Maintainer struct { - Name string `yaml:"name"` - Role string `yaml:"role,omitempty"` + Name string `yaml:"name" json:"name"` + Role string `yaml:"role,omitempty" json:"role,omitempty"` }