Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ builds:
- amd64
- arm64
ldflags:
- -s -w
- -s -w -X main.version={{.Version}}

archives:
- formats: [tar.gz]
Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
90 changes: 90 additions & 0 deletions cmd/ktext/args_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 2 additions & 1 deletion cmd/ktext/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
141 changes: 141 additions & 0 deletions internal/export/json_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
5 changes: 4 additions & 1 deletion internal/scan/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading