diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..8380a31 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,37 @@ +name: PR Title Check + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + pull_request_target: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + pull-requests: read + +jobs: + validate-title: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + requireScope: false + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + Subject must not start with uppercase letter. + Example: "feat: add new feature" not "feat: Add new feature" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..6c76cc6 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,25 @@ +name: Release Please + +on: + push: + branches: + - main + - v3.0.1-alpha-updates + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ secrets.GITHUB_TOKEN }} + # Target branch automatically set to triggering branch + target-branch: ${{ github.ref_name }} + # Manifest-driven config for multi-branch support + manifest-file: .release-please-manifest.json + config-file: .release-please-config.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ae9b5fa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 951fa9c..94068e9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ bin/__pycache__/ # Claude Code agent harness ephemeral worktrees .claude/worktrees/ +bin/rebar diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..39202ce --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,75 @@ +# GoReleaser configuration for rebar CLI +# Builds multi-platform binaries with version injection via ldflags + +version: 2 + +before: + hooks: + - go mod tidy + - go test ./... + +builds: + - id: rebar + main: ./cli + binary: rebar + ldflags: + - -s -w + - -X github.com/willackerly/rebar/cli/cmd.Version={{.Version}} + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + +archives: + - id: rebar + format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + format_overrides: + - goos: windows + format: zip + files: + - LICENSE* + - README* + - CLAUDE.md + - bin/install + - templates/**/* + - scripts/**/* + - agents/**/* + +checksum: + name_template: 'checksums.txt' + +snapshot: + name_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - Merge pull request + - Merge branch + +release: + github: + owner: ttschampel + name: rebar + draft: false + prerelease: auto + name_template: "{{.ProjectName}} {{.Version}}" diff --git a/.rebar-version b/.rebar-version index 46b105a..3901362 100644 --- a/.rebar-version +++ b/.rebar-version @@ -1 +1 @@ -v2.0.0 +v3.0.1-alpha diff --git a/.rebarrc b/.rebarrc index c2a7d63..5e7cfe3 100644 --- a/.rebarrc +++ b/.rebarrc @@ -3,3 +3,7 @@ # Enforcement tier (1=Partial, 2=Adopted, 3=Enforced) tier = 3 + +# Contract namespace (host/org/repo). All CONTRACT: references in this repo +# are prefixed with this value. Inferred from `git remote get-url origin`. +contract_namespace = github.com/willackerly/rebar diff --git a/.release-please-config.json b/.release-please-config.json new file mode 100644 index 0000000..c01c118 --- /dev/null +++ b/.release-please-config.json @@ -0,0 +1,22 @@ +{ + "packages": { + ".": { + "release-type": "go", + "package-name": "rebar", + "changelog-path": "CHANGELOG.md", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "draft": false, + "prerelease": false, + "include-component-in-tag": false, + "extra-files": [ + { + "type": "generic", + "path": ".rebar-version", + "glob": false + } + ] + } + }, + "bootstrap-sha": "775d382" +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..96aec23 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "3.0.1-alpha" +} diff --git a/architecture/CONTRACT-REGISTRY.md b/architecture/CONTRACT-REGISTRY.md index 5b47f8b..1dd52a0 100644 --- a/architecture/CONTRACT-REGISTRY.md +++ b/architecture/CONTRACT-REGISTRY.md @@ -1,16 +1,16 @@ # Contract Registry - + ## Services | ID | Version | Status | Impl Files | Purpose | |----|---------|--------|------------|---------| -| S1-STEWARD | 1.0 | active | 2 | — | -| S2-ASK-CLI | 1.0 | active | 3 | — | -| S3-MCP-SERVER | 1.0 | active | 1 | — | +| github.com/willackerly/rebar:S1-STEWARD | 1.0 | active | 3 | — | +| github.com/willackerly/rebar:S2-ASK-CLI | 1.0 | active | 4 | — | +| github.com/willackerly/rebar:S3-MCP-SERVER | 1.0 | active | 1 | — | ## Contract Files diff --git a/architecture/CONTRACT-S1-STEWARD.1.0.md b/architecture/CONTRACT-S1-STEWARD.1.0.md index fca9da5..052bbdc 100644 --- a/architecture/CONTRACT-S1-STEWARD.1.0.md +++ b/architecture/CONTRACT-S1-STEWARD.1.0.md @@ -1,4 +1,4 @@ -# CONTRACT-S1-STEWARD.1.0 +# CONTRACT-github.com/willackerly/rebar:S1-STEWARD.1.0 **Version:** 1.0 **Status:** active @@ -109,7 +109,7 @@ ask steward check C1 # → commands/check.sh C1 ## Dependencies -- Depends on: `CONTRACT:S2-ASK-CLI.1.0` (ASK CLI invokes the steward via `ask steward`) +- Depends on: `CONTRACT:github.com/willackerly/rebar:S2-ASK-CLI.1.0` (ASK CLI invokes the steward via `ask steward`) - Depends on: bash 3.2+, `jq`, GNU/BSD `grep`, `find`, `awk`, `sed` - Configuration: `.rebarrc` (`tier = 1|2|3`) - External: none @@ -123,7 +123,7 @@ ask steward check C1 # → commands/check.sh C1 ## Future evolution - **Provisional:** the JSON schema in `architecture/.state/` will get a - formal versioned contract (likely `CONTRACT:I1-STEWARD-STATE.1.0`) + formal versioned contract (likely `CONTRACT:github.com/willackerly/rebar:I1-STEWARD-STATE.1.0`) once a 2nd consumer beyond `rebar audit` emerges. - **Major-bump trigger:** if the spec-gate sections change (e.g., Why/Who/Scenarios become required for spec-gate completeness). diff --git a/architecture/CONTRACT-S2-ASK-CLI.1.0.md b/architecture/CONTRACT-S2-ASK-CLI.1.0.md index 593838c..4690971 100644 --- a/architecture/CONTRACT-S2-ASK-CLI.1.0.md +++ b/architecture/CONTRACT-S2-ASK-CLI.1.0.md @@ -1,4 +1,4 @@ -# CONTRACT-S2-ASK-CLI.1.0 +# CONTRACT-github.com/willackerly/rebar:S2-ASK-CLI.1.0 **Version:** 1.0 **Status:** active @@ -118,13 +118,13 @@ Exit codes: 0 success, 1 ask-level failure, 2 invocation error. ## Cross-references - **Doc:** `bin/README.md` (full reference), `DESIGN.md` §10 (design rationale) -- **Sister contract:** `CONTRACT:S3-MCP-SERVER.1.0` (the MCP bridge that wraps ASK) -- **Sister contract:** `CONTRACT:S1-STEWARD.1.0` (Steward exposes itself via `ask steward`) +- **Sister contract:** `CONTRACT:github.com/willackerly/rebar:S3-MCP-SERVER.1.0` (the MCP bridge that wraps ASK) +- **Sister contract:** `CONTRACT:github.com/willackerly/rebar:S1-STEWARD.1.0` (Steward exposes itself via `ask steward`) ## Future evolution - **Provisional:** the wire protocol between `bin/ask` and `bin/ask-server` - isn't yet a versioned contract; it should be `CONTRACT:I2-ASK-WIRE.1.0` + isn't yet a versioned contract; it should be `CONTRACT:github.com/willackerly/rebar:I2-ASK-WIRE.1.0` once a 2nd implementation appears (e.g., a Go reimplementation). - **Major-bump trigger:** changing the quoted-vs-unquoted convention, or changing how `ask :` resolves repos. diff --git a/architecture/CONTRACT-S3-MCP-SERVER.1.0.md b/architecture/CONTRACT-S3-MCP-SERVER.1.0.md index 16ca83c..0953fe2 100644 --- a/architecture/CONTRACT-S3-MCP-SERVER.1.0.md +++ b/architecture/CONTRACT-S3-MCP-SERVER.1.0.md @@ -1,4 +1,4 @@ -# CONTRACT-S3-MCP-SERVER.1.0 +# CONTRACT-github.com/willackerly/rebar:S3-MCP-SERVER.1.0 **Version:** 1.0 **Status:** active @@ -119,7 +119,7 @@ JSON-RPC surface: ## Dependencies -- Depends on: `CONTRACT:S2-ASK-CLI.1.0` (subprocess executor for every tools/call) +- Depends on: `CONTRACT:github.com/willackerly/rebar:S2-ASK-CLI.1.0` (subprocess executor for every tools/call) - Depends on: Python 3.10+ - Depends on: standard library only (no third-party deps — keeps install zero-friction) - Configuration: `.mcp.json` in adopted repos (auto-written by `rebar init`) @@ -128,7 +128,7 @@ JSON-RPC surface: ## Cross-references - **Doc:** `docs/MCP-SETUP.md` (adopter guide), `docs/MCP-IMPLEMENTATION.md` (protocol-level details) -- **Sister contract:** `CONTRACT:S2-ASK-CLI.1.0` +- **Sister contract:** `CONTRACT:github.com/willackerly/rebar:S2-ASK-CLI.1.0` - **Wave 2.5 commits:** `d9e68fc` (activation), `0db9073` (notifications fix), `bc936cf` (paragraph extraction), `2f52983` (role preambles), `5800647` (depth-2 discovery) diff --git a/architecture/CONTRACT-TEMPLATE.md b/architecture/CONTRACT-TEMPLATE.md index 77c4447..fb4d41a 100644 --- a/architecture/CONTRACT-TEMPLATE.md +++ b/architecture/CONTRACT-TEMPLATE.md @@ -1,12 +1,19 @@ -# CONTRACT-{ID}-{NAME}.{MAJOR}.{MINOR} +# CONTRACT-{NAMESPACE}:{ID}-{NAME}.{MAJOR}.{MINOR} + Remove these HTML comments when done. + + {NAMESPACE} is your repo's namespace in Go-module form + (host/org/repo, e.g. github.com/willackerly/rebar). The filename + keeps the legacy unnamespaced form + (CONTRACT-{ID}-{NAME}.{MAJOR}.{MINOR}.md); only the title and + in-source CONTRACT: references carry the namespace. Run + `rebar contract migrate-namespace` to apply it across a repo. --> **Version:** {MAJOR}.{MINOR} @@ -98,7 +105,7 @@ type BlobStore interface { -- Depends on: `CONTRACT:I2-KEY-EXCHANGE.1.0` for encryption keys +- Depends on: `CONTRACT:{NAMESPACE}:I2-KEY-EXCHANGE.1.0` for encryption keys - Configuration: `BLOBSTORE_PATH` environment variable - External: none (self-contained) @@ -136,7 +143,7 @@ type BlobStore interface { - `internal/blobstore/file.go` — file-backed implementation diff --git a/architecture/README.md b/architecture/README.md index 8a78150..babaca7 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -40,9 +40,20 @@ architecture/ ## Naming Convention ``` -CONTRACT-{ID}-{NAME}.{MAJOR}.{MINOR}.md +Filename: CONTRACT-{ID}-{NAME}.{MAJOR}.{MINOR}.md (filenames stay unnamespaced) +Title: # CONTRACT-{NAMESPACE}:{ID}-{NAME}.{MAJOR}.{MINOR} +Reference: CONTRACT:{NAMESPACE}:{ID}-{NAME}.{MAJOR}.{MINOR} ``` +Contracts live under a repo namespace (Go-module form, e.g. +`github.com/willackerly/rebar`). The filename stays unnamespaced because +the file path already says "which repo"; the title and in-source +`CONTRACT:` references carry the namespace so cross-repo greps are +unambiguous. Run `rebar contract migrate-namespace --write` once per +repo to apply the namespace; the value is inferred from +`git remote get-url origin` and recorded in `.rebarrc` as +`contract_namespace = `. + | Prefix | Meaning | Example | |--------|---------|---------| | `S` | Service | `S1-AUTH`, `S4-STORAGE` | @@ -99,26 +110,29 @@ When bumping major: ## Code-to-Contract Linking -Every source file declares its contract in a header comment: +Every source file declares its contract in a header comment. Local +contracts use this repo's namespace; foreign contracts (consumed from +another repo) carry the owner's namespace, so a `CONTRACT:` ref with an +unfamiliar prefix is an immediate signal of an upstream dependency. ```go -// CONTRACT:C1-BLOBSTORE.2.1 +// CONTRACT:github.com/willackerly/rebar:C1-BLOBSTORE.2.1 package blobstore ``` ```typescript -/** @contract CONTRACT:C3-CRYPTO-BRIDGE.1.0 */ +/** @contract CONTRACT:github.com/willackerly/rebar:C3-CRYPTO-BRIDGE.1.0 */ ``` For helpers that don't directly implement a contract: ```go -// Architecture: CONTRACT:S2-API-GATEWAY.1.0 +// Architecture: CONTRACT:github.com/willackerly/rebar:S2-API-GATEWAY.1.0 package httputil ``` This creates doubly-linked traceability — searchable in either direction -with `grep`. +with `grep`, and provenance is encoded in every reference. ## Cross-Repo Contract Federation diff --git a/bin/rebar b/bin/rebar deleted file mode 100755 index 07eafa4..0000000 Binary files a/bin/rebar and /dev/null differ diff --git a/cli/cmd/adopt.go b/cli/cmd/adopt.go index 6cdefa0..e56025a 100644 --- a/cli/cmd/adopt.go +++ b/cli/cmd/adopt.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/willackerly/rebar/cli/internal/llm" + "github.com/willackerly/rebar/cli/internal/repo" ) var _ = time.Now // ensure time is used @@ -66,7 +67,7 @@ func runAdopt(cmd *cobra.Command, args []string) error { } fmt.Println() - // Phase 2: Scaffold (always — equivalent to rebar init + fixes) + // Phase 2: Scaffold (always — creates docs, agents, hooks) fmt.Println(" Phase 2: Scaffolding") fixed := applyFixes(root) @@ -105,10 +106,26 @@ func runAdopt(cmd *cobra.Command, args []string) error { } } + // agents/subagent-guidelines.md + if ensureSubagentGuidelines(root) { + fixed++ + } + + // .git/hooks/pre-commit + if ensurePreCommitHook(root) { + fixed++ + } + if fixed == 0 { fmt.Println(" (all scaffolding already in place)") } + // Initialize integrity tracking (.rebar/ directory, manifest, salt) + fmt.Println("\n Initializing integrity tracking") + if err := runInit(cmd, []string{}); err != nil { + return fmt.Errorf("initializing .rebar/: %w", err) + } + // Phase 3: Contract proposals (tier 2 only, requires LLM) if adoptTier >= 2 { fmt.Println("\n Phase 3: Contract Proposals (via Claude API)") @@ -176,19 +193,9 @@ Only propose contracts for code that actually exists — don't invent components func gatherCodebaseSummary(root string) string { var sb strings.Builder - // Find source files - exts := []string{"*.go", "*.ts", "*.tsx", "*.py", "*.rs", "*.js", "*.jsx"} - var sourceFiles []string - for _, ext := range exts { - cmd := exec.Command("find", root, "-name", ext, "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.claude/*", "-not", "-path", "*/.git/*", "-not", "-path", "*/vendor/*") - out, _ := cmd.Output() - for _, f := range strings.Split(strings.TrimSpace(string(out)), "\n") { - if f != "" { - rel, _ := filepath.Rel(root, f) - sourceFiles = append(sourceFiles, rel) - } - } - } + // Find tracked source files + sourceFiles, _ := repo.TrackedFiles(root, + "*.go", "*.ts", "*.tsx", "*.py", "*.rs", "*.js", "*.jsx") if len(sourceFiles) == 0 { return "" @@ -209,11 +216,11 @@ func gatherCodebaseSummary(root string) string { // Show directory structure sb.WriteString("\nDirectory structure:\n") - cmd := exec.Command("find", root, "-maxdepth", "2", "-type", "d", + dirCmd := exec.Command("find", root, "-maxdepth", "2", "-type", "d", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.git/*", "-not", "-path", "*/.claude/*", "-not", "-path", "*/vendor/*") - out, _ := cmd.Output() - for _, d := range strings.Split(strings.TrimSpace(string(out)), "\n") { + dirOut, _ := dirCmd.Output() + for _, d := range strings.Split(strings.TrimSpace(string(dirOut)), "\n") { if d != "" { rel, _ := filepath.Rel(root, d) if rel != "" && rel != "." { @@ -305,3 +312,4 @@ func writeMinimalClaude(root, name string) { os.WriteFile(filepath.Join(root, "CLAUDE.md"), []byte(content), 0644) } + diff --git a/cli/cmd/audit.go b/cli/cmd/audit.go index d20c79c..6a19992 100644 --- a/cli/cmd/audit.go +++ b/cli/cmd/audit.go @@ -3,13 +3,14 @@ package cmd import ( "fmt" "os" - "os/exec" "path/filepath" "sort" + "strconv" "strings" "time" "github.com/spf13/cobra" + "github.com/willackerly/rebar/cli/internal/repo" ) var ( @@ -17,6 +18,30 @@ var ( auditFix bool // auto-fix quick wins ) +// readTier reads the tier from .rebarrc. Returns 3 (full enforcement) if +// unset or missing. +func readTier(root string) int { + rcPath := filepath.Join(root, ".rebarrc") + data, err := os.ReadFile(rcPath) + if err != nil { + return 3 + } + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || !strings.Contains(trimmed, "=") { + continue + } + parts := strings.SplitN(trimmed, "=", 2) + key := strings.TrimSpace(parts[0]) + if strings.EqualFold(key, "tier") { + if tier, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && tier >= 1 && tier <= 3 { + return tier + } + } + } + return 3 +} + var auditCmd = &cobra.Command{ Use: "audit [path]", Short: "Assess REBAR compliance for a project", @@ -208,16 +233,33 @@ func auditRepo(root string) (float64, []auditResult) { // --- Section Auditors --- func auditStructure(root string) auditResult { - checks := []checkResult{ - fileExists(root, "README.md", "Project README"), - fileContains(root, "README.md", "rebar v", "Rebar badge in README"), - fileExists(root, "QUICKCONTEXT.md", "QUICKCONTEXT.md"), - fileContains(root, "QUICKCONTEXT.md", "last-synced", "QUICKCONTEXT has last-synced date"), + tier := readTier(root) + var checks []checkResult + + // Tier 1: minimal + checks = append(checks, + fileExists(root, ".rebarrc", ".rebarrc"), fileExists(root, "TODO.md", "TODO.md"), - fileExists(root, "AGENTS.md", "AGENTS.md"), - fileExists(root, ".rebarrc", ".rebarrc tier declaration"), - fileExists(root, ".rebar-version", ".rebar-version file"), + ) + + // Tier 2: team/shared + if tier >= 2 { + checks = append(checks, + fileExists(root, "QUICKCONTEXT.md", "QUICKCONTEXT.md"), + fileContains(root, "QUICKCONTEXT.md", "last-synced", "QUICKCONTEXT last-synced date"), + fileExists(root, "AGENTS.md", "AGENTS.md"), + ) + } + + // Tier 3: full discipline + if tier >= 3 { + checks = append(checks, + fileExists(root, "README.md", "README.md"), + fileContains(root, "README.md", "rebar v", "Rebar badge in README"), + fileExists(root, ".rebar-version", ".rebar-version"), + ) } + return auditResult{Section: "Structural Presence", Score: scoreChecks(checks), Weight: 15, Checks: checks} } @@ -252,32 +294,37 @@ func auditContracts(root string) auditResult { } func auditEnforcement(root string) auditResult { + // All tiers: pre-commit hook (scripts run from framework, not project) checks := []checkResult{ - dirExists(root, "scripts", "scripts/ directory"), hookInstalled(root), - fileExists(root, "scripts/refresh-context.sh", "refresh-context.sh"), - } - - // Check for enforcement scripts - for _, script := range []string{"check-contract-refs.sh", "check-todos.sh"} { - checks = append(checks, fileExists(root, filepath.Join("scripts", script), script)) } return auditResult{Section: "Enforcement & Scripts", Score: scoreChecks(checks), Weight: 15, Checks: checks} } func auditAgents(root string) auditResult { - checks := []checkResult{ - dirExists(root, "agents", "agents/ directory"), - fileExists(root, "agents/subagent-guidelines.md", "subagent-guidelines.md"), + tier := readTier(root) + var checks []checkResult + + // Tier 1: no agent requirements - skip section entirely + if tier < 2 { + return auditResult{Section: "Agent Coordination", Score: 0, Weight: 0, Checks: nil} } - // Count agent roles - roles := countAgentRoles(root) - checks = append(checks, checkResult{ - Name: fmt.Sprintf("Agent roles (%d found)", roles), - Pass: roles > 0, - }) + // Tier 2: subagent-guidelines + checks = append(checks, + dirExists(root, "agents", "agents/"), + fileExists(root, "agents/subagent-guidelines.md", "subagent-guidelines.md"), + ) + + // Tier 3: role-based agents expected + if tier >= 3 { + roles := countAgentRoles(root) + checks = append(checks, checkResult{ + Name: fmt.Sprintf("Agent roles (%d found)", roles), + Pass: roles > 0, + }) + } return auditResult{Section: "Agent Coordination", Score: scoreChecks(checks), Weight: 10, Checks: checks} } @@ -397,12 +444,9 @@ func countGlob(pattern string) int { } func countContractHeaders(root string) int { - cmd := exec.Command("grep", "-rl", "CONTRACT:", "--include=*.go", "--include=*.ts", "--include=*.tsx", "--include=*.py", "--include=*.rs", "--include=*.js", root) - out, _ := cmd.Output() - if len(out) == 0 { - return 0 - } - return len(strings.Split(strings.TrimSpace(string(out)), "\n")) + files, _ := repo.TrackedFilesGrep(root, `CONTRACT:`, + "*.go", "*.ts", "*.tsx", "*.py", "*.rs", "*.js") + return len(files) } func countAgentRoles(root string) int { @@ -447,20 +491,10 @@ func extractDateFromFile(root, rel string) string { } func countSkippedTests(root string) int { - cmd := exec.Command("grep", "-rl", "-E", `\.skip\(|\.skip\b|xit\(|xdescribe\(|xtest\(`, "--include=*.test.*", "--include=*_test.*", "--include=*.spec.*", root) - out, _ := cmd.Output() - if len(out) == 0 { - return 0 - } - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - // Filter out node_modules and .claude - count := 0 - for _, l := range lines { - if !strings.Contains(l, "node_modules") && !strings.Contains(l, ".claude/worktrees") { - count++ - } - } - return count + files, _ := repo.TrackedFilesGrep(root, + `\.skip\(|\.skip\b|xit\(|xdescribe\(|xtest\(`, + "*_test.*", "*.test.*", "*.spec.*") + return len(files) } func scoreChecks(checks []checkResult) int { @@ -487,6 +521,11 @@ func printAuditReport(root string, score float64, results []auditResult) { fmt.Println() for _, r := range results { + // Skip sections with weight 0 (tier-gated, not applicable) + if r.Weight == 0 { + continue + } + status := "PASS" if r.Score < 5 { status = "FAIL" @@ -549,21 +588,6 @@ func applyFixes(root string) int { } } - // refresh-context.sh - refreshPath := filepath.Join(root, "scripts", "refresh-context.sh") - if _, err := os.Stat(refreshPath); os.IsNotExist(err) { - rebarRoot := findRebarRoot() - if rebarRoot != "" { - src := filepath.Join(rebarRoot, "scripts", "refresh-context.sh") - if data, err := os.ReadFile(src); err == nil { - os.MkdirAll(filepath.Join(root, "scripts"), 0755) - os.WriteFile(refreshPath, data, 0755) - fmt.Println(" ✓ Copied refresh-context.sh") - fixed++ - } - } - } - // architecture/ directory archDir := filepath.Join(root, "architecture") if _, err := os.Stat(archDir); os.IsNotExist(err) { diff --git a/cli/cmd/check.go b/cli/cmd/check.go index 6dd751f..14ab435 100644 --- a/cli/cmd/check.go +++ b/cli/cmd/check.go @@ -27,7 +27,7 @@ func runCheck(cmd *cobra.Command, args []string) error { scriptArgs = append(scriptArgs, "--strict") } - exitCode, err := scripts.RunPassthrough(cfg.ScriptsDir, "ci-check.sh", scriptArgs...) + exitCode, err := scripts.RunPassthrough(cfg.ScriptsDir, cfg.RepoRoot, "ci-check.sh", scriptEnv(), scriptArgs...) if err != nil { return fmt.Errorf("running checks: %w", err) } diff --git a/cli/cmd/commit.go b/cli/cmd/commit.go index bf9b3dd..4209c11 100644 --- a/cli/cmd/commit.go +++ b/cli/cmd/commit.go @@ -44,7 +44,7 @@ func runCommit(cmd *cobra.Command, args []string) error { fmt.Println("Running pre-commit checks...") preCommitPath := filepath.Join(cfg.ScriptsDir, "pre-commit.sh") if _, err := os.Stat(preCommitPath); err == nil { - exitCode, err := scripts.RunPassthrough(cfg.ScriptsDir, "pre-commit.sh") + exitCode, err := scripts.RunPassthrough(cfg.ScriptsDir, cfg.RepoRoot, "pre-commit.sh", scriptEnv()) if err != nil { return fmt.Errorf("running pre-commit: %w", err) } diff --git a/cli/cmd/context.go b/cli/cmd/context.go index f25171a..82ed51a 100644 --- a/cli/cmd/context.go +++ b/cli/cmd/context.go @@ -204,6 +204,12 @@ func findBranchContracts() []string { // comment so check-contract-refs.sh doesn't false-positive on it. ref = strings.TrimPrefix(ref, "CONTRACT:") ref = strings.Fields(ref)[0] + // Strip optional namespace prefix (host/org/repo:). The ID + // itself never contains ':', so the substring after the + // last ':' is the bare ID with version. + if colon := strings.LastIndex(ref, ":"); colon >= 0 { + ref = ref[colon+1:] + } // Convert to glob pattern pattern := fmt.Sprintf("architecture/CONTRACT-%s*.md", strings.Split(ref, ".")[0]) contracts[pattern] = true diff --git a/cli/cmd/contract.go b/cli/cmd/contract.go index 53167ba..ef6bdfe 100644 --- a/cli/cmd/contract.go +++ b/cli/cmd/contract.go @@ -25,7 +25,7 @@ var contractVerifyCmd = &cobra.Command{ Short: "Verify contract references and headers", RunE: func(cmd *cobra.Command, args []string) error { fmt.Println("Checking contract references...") - exitCode, err := scripts.RunPassthrough(cfg.ScriptsDir, "check-contract-refs.sh") + exitCode, err := scripts.RunPassthrough(cfg.ScriptsDir, cfg.RepoRoot, "check-contract-refs.sh", scriptEnv()) if err != nil { return err } @@ -34,7 +34,7 @@ var contractVerifyCmd = &cobra.Command{ } fmt.Println("\nChecking contract headers...") - exitCode, err = scripts.RunPassthrough(cfg.ScriptsDir, "check-contract-headers.sh") + exitCode, err = scripts.RunPassthrough(cfg.ScriptsDir, cfg.RepoRoot, "check-contract-headers.sh", scriptEnv()) if err != nil { return err } diff --git a/cli/cmd/contract_migrate.go b/cli/cmd/contract_migrate.go new file mode 100644 index 0000000..8f46390 --- /dev/null +++ b/cli/cmd/contract_migrate.go @@ -0,0 +1,407 @@ +package cmd + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/willackerly/rebar/cli/internal/repo" + "github.com/willackerly/rebar/cli/internal/scripts" +) + +var ( + migrateNamespaceFlag string + migrateWrite bool + migrateJSON bool +) + +var contractMigrateNamespaceCmd = &cobra.Command{ + Use: "migrate-namespace", + Short: "Rewrite contract references to use the repo namespace (Go-module form)", + Long: `Rewrites every contract reference in this repo from the legacy +form (CONTRACT:..) to the namespaced form +(CONTRACT://:..). + +The namespace is inferred from 'git remote get-url origin' unless +overridden by --namespace or the contract_namespace key in .rebarrc. + +Scope of rewrites: + - architecture/CONTRACT-*.md title line + body references + - source files (.go .ts .tsx .js .jsx .py .rs) in src/ internal/ cmd/ + client/ packages/ lib/ app/ CONTRACT: header references + - scripts/*.sh CONTRACT: comment headers + +Filenames are NOT renamed — the repo's filesystem path implicitly +namespaces the contract. + +Templates and methodology docs (DESIGN.md, conventions.md, QUICKSTART.md, +QUICKCONTEXT.md, templates/project-bootstrap/) are skipped because they +contain illustrative examples that aren't real contracts. + +By default this is a dry run. Use --write to apply. + +Exit codes: + 0 no drift (already migrated) OR --write succeeded + 1 drift found in dry-run mode + 2 namespace could not be resolved +`, + RunE: runMigrateNamespace, +} + +func init() { + contractMigrateNamespaceCmd.Flags().StringVar(&migrateNamespaceFlag, "namespace", "", "override namespace (e.g. github.com/owner/repo)") + contractMigrateNamespaceCmd.Flags().BoolVar(&migrateWrite, "write", false, "apply changes (default: dry-run)") + contractMigrateNamespaceCmd.Flags().BoolVar(&migrateJSON, "json", false, "machine-readable JSON output") + contractCmd.AddCommand(contractMigrateNamespaceCmd) +} + +// Matches a legacy reference: CONTRACT: followed by ID.MAJOR.MINOR with +// NO colon immediately after CONTRACT:NS (i.e. namespace not already +// present). The ID must start with an uppercase letter. +// +// Used in source files, contract bodies, and shell comment headers. +var legacyRefRE = regexp.MustCompile(`CONTRACT:([A-Z][A-Za-z0-9_-]*)\.(\d+)\.(\d+)\b`) + +// Matches a legacy title line in a contract markdown file: +// +// # CONTRACT-S1-STEWARD.1.0 +// +// Capture group 1 = ID + version (e.g. S1-STEWARD.1.0). +var legacyTitleRE = regexp.MustCompile(`(?m)^# CONTRACT-([A-Z][A-Za-z0-9_-]*\.\d+\.\d+)\b`) + +// Detects a reference that is ALREADY namespaced. Used for idempotence: +// if a file is fully migrated, the legacy regex matches nothing and the +// file is skipped. +var namespacedRefRE = regexp.MustCompile(`CONTRACT:[a-zA-Z0-9][a-zA-Z0-9_./-]+:[A-Z][A-Za-z0-9_-]*\.\d+\.\d+\b`) + +type migrationChange struct { + Path string `json:"path"` + Kind string `json:"kind"` // "contract-md", "source", "script" + Count int `json:"count"` +} + +type migrationReport struct { + Namespace string `json:"namespace"` + NamespaceSource string `json:"namespace_source"` // "flag" | ".rebarrc" | "git-remote" + DryRun bool `json:"dry_run"` + Changes []migrationChange `json:"changes"` + TotalFiles int `json:"total_files"` + TotalReplacements int `json:"total_replacements"` +} + +func runMigrateNamespace(cmd *cobra.Command, args []string) error { + ns, source, err := resolveMigrationNamespace() + if err != nil { + fmt.Fprintf(os.Stderr, "migrate-namespace: %v\n", err) + os.Exit(2) + } + + // Guard: refuse if .rebarrc already declares a different namespace + // (the user-provided override is the way to deliberately change it). + existing := cfg.ContractNamespace + if existing != "" && existing != ns && migrateNamespaceFlag == "" { + fmt.Fprintf(os.Stderr, "migrate-namespace: .rebarrc declares contract_namespace=%q but inferred %q.\n", existing, ns) + fmt.Fprintln(os.Stderr, "Pass --namespace= to force a change, or align .rebarrc with the remote.") + os.Exit(2) + } + + report := migrationReport{ + Namespace: ns, + NamespaceSource: source, + DryRun: !migrateWrite, + } + + if err := scanContractDir(cfg.RepoRoot, ns, &report); err != nil { + return err + } + if err := scanSourceDirs(cfg.RepoRoot, ns, &report); err != nil { + return err + } + if err := scanScriptsDir(cfg.RepoRoot, ns, &report); err != nil { + return err + } + + if migrateWrite { + if err := ensureRebarRCNamespace(filepath.Join(cfg.RepoRoot, ".rebarrc"), ns); err != nil { + return fmt.Errorf("updating .rebarrc: %w", err) + } + // Regenerate registry so the ID column reflects the new namespace. + if _, err := os.Stat(filepath.Join(cfg.ScriptsDir, "compute-registry.sh")); err == nil { + _, _ = scripts.RunPassthrough(cfg.ScriptsDir, cfg.RepoRoot, "compute-registry.sh", scriptEnv()) + } + } + + if migrateJSON { + return emitMigrationJSON(&report) + } + emitMigrationText(&report) + + if !migrateWrite && report.TotalReplacements > 0 { + os.Exit(1) + } + return nil +} + +func resolveMigrationNamespace() (string, string, error) { + if migrateNamespaceFlag != "" { + return migrateNamespaceFlag, "flag", nil + } + if cfg.ContractNamespace != "" { + return cfg.ContractNamespace, ".rebarrc", nil + } + ns, err := repo.InferNamespace(cfg.RepoRoot) + if err != nil { + return "", "", fmt.Errorf("could not infer namespace from git remote: %w\nPass --namespace=// or set contract_namespace in .rebarrc", err) + } + return ns, "git-remote", nil +} + +// scanContractDir walks architecture/CONTRACT-*.md and rewrites titles +// and bodies. Skips templates and the auto-generated registry. +func scanContractDir(repoRoot, ns string, report *migrationReport) error { + archDir := filepath.Join(repoRoot, "architecture") + entries, err := os.ReadDir(archDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("reading architecture dir: %w", err) + } + + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasPrefix(name, "CONTRACT-") || !strings.HasSuffix(name, ".md") { + continue + } + // Skip non-contract files in this directory. + switch name { + case "CONTRACT-TEMPLATE.md", "CONTRACT-SEAM-TEMPLATE.md", + "CONTRACT-REGISTRY.md", "CONTRACT-REGISTRY.template.md", "CONTRACT-GAPS.md": + continue + } + + path := filepath.Join(archDir, name) + if err := rewriteFile(path, ns, "contract-md", report); err != nil { + return err + } + } + return nil +} + +// scanSourceDirs finds tracked source files via git ls-files and rewrites +// legacy CONTRACT: references. Uses git to scope to the consumer repo, +// avoiding leaks into framework installs or untracked directories. +func scanSourceDirs(repoRoot, ns string, report *migrationReport) error { + files, err := repo.TrackedFiles(repoRoot, + "*.go", "*.ts", "*.tsx", "*.js", "*.jsx", "*.py", "*.rs") + if err != nil { + return nil + } + + skipSuffix := []string{ + "_test.go", ".test.ts", ".test.tsx", ".test.js", + ".spec.ts", ".spec.tsx", ".spec.js", + "_generated", ".gen.", ".pb.go", ".pb.ts", + } + + for _, rel := range files { + name := filepath.Base(rel) + skip := false + for _, s := range skipSuffix { + if strings.Contains(name, s) { + skip = true + break + } + } + if skip { + continue + } + if err := rewriteFile(filepath.Join(repoRoot, rel), ns, "source", report); err != nil { + return err + } + } + return nil +} + +// scanScriptsDir walks scripts/ for shell scripts with CONTRACT: comment +// headers (e.g. scripts/steward.sh L3). Restricted to top-level .sh. +func scanScriptsDir(repoRoot, ns string, report *migrationReport) error { + scriptsDir := filepath.Join(repoRoot, "scripts") + entries, err := os.ReadDir(scriptsDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("reading scripts dir: %w", err) + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sh") { + continue + } + path := filepath.Join(scriptsDir, e.Name()) + if err := rewriteFile(path, ns, "script", report); err != nil { + return err + } + } + return nil +} + +// rewriteFile reads a file, applies legacy → namespaced rewrites +// idempotently, optionally writes the result. Records change counts in +// the report. +func rewriteFile(path, ns, kind string, report *migrationReport) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + content := string(data) + + // Heuristic guard: skip files that are clearly templates (still + // contain unfilled placeholders). The migrator can't reliably + // distinguish a real contract from a template if the body has + // {ID} or {NAMESPACE} placeholders. + if strings.Contains(content, "{NAMESPACE}") || strings.Contains(content, "{ID}-{NAME}") { + return nil + } + + original := content + replacements := 0 + + // 1. Title line in contract markdown files. + content = legacyTitleRE.ReplaceAllStringFunc(content, func(m string) string { + // Avoid double-prefixing if the title is somehow already + // namespaced (idempotence). + if strings.Contains(m, "CONTRACT-"+ns+":") { + return m + } + idVer := legacyTitleRE.FindStringSubmatch(m)[1] + replacements++ + return "# CONTRACT-" + ns + ":" + idVer + }) + + // 2. Inline CONTRACT: references in any file. + content = legacyRefRE.ReplaceAllStringFunc(content, func(m string) string { + sub := legacyRefRE.FindStringSubmatch(m) + // sub[1] = ID; sub[2] = major; sub[3] = minor. + replacements++ + return "CONTRACT:" + ns + ":" + sub[1] + "." + sub[2] + "." + sub[3] + }) + + if replacements == 0 || content == original { + return nil + } + + report.Changes = append(report.Changes, migrationChange{ + Path: relPath(path, cfg.RepoRoot), + Kind: kind, + Count: replacements, + }) + report.TotalFiles++ + report.TotalReplacements += replacements + + if migrateWrite { + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + } + return nil +} + +func relPath(abs, root string) string { + if rel, err := filepath.Rel(root, abs); err == nil { + return rel + } + return abs +} + +// ensureRebarRCNamespace appends contract_namespace= to .rebarrc if +// the key isn't already present. Idempotent. +func ensureRebarRCNamespace(path, ns string) error { + var lines []string + hasKey := false + + if f, err := os.Open(path); err == nil { + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { + parts := strings.SplitN(trimmed, "=", 2) + if strings.EqualFold(strings.TrimSpace(parts[0]), "contract_namespace") { + // Replace existing value to keep .rebarrc canonical. + line = "contract_namespace = " + ns + hasKey = true + } + } + lines = append(lines, line) + } + f.Close() + } else if !os.IsNotExist(err) { + return err + } + + if !hasKey { + lines = append(lines, "") + lines = append(lines, "# Contract namespace (host/org/repo). All CONTRACT: references in this repo") + lines = append(lines, "# are prefixed with this value. Inferred from `git remote get-url origin`.") + lines = append(lines, "contract_namespace = "+ns) + } + + out := strings.Join(lines, "\n") + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return os.WriteFile(path, []byte(out), 0644) +} + +func emitMigrationText(r *migrationReport) { + mode := "DRY RUN — pass --write to apply" + if !r.DryRun { + mode = "applied" + } + fmt.Printf("migrate-namespace: %s\n", mode) + fmt.Printf(" namespace: %s (source: %s)\n", r.Namespace, r.NamespaceSource) + if r.TotalReplacements == 0 { + fmt.Println(" no drift — every CONTRACT: reference is already namespaced (or there are none)") + return + } + + // Group changes by kind for a readable summary. + byKind := map[string][]migrationChange{} + for _, c := range r.Changes { + byKind[c.Kind] = append(byKind[c.Kind], c) + } + kindOrder := []string{"contract-md", "source", "script"} + for _, k := range kindOrder { + group, ok := byKind[k] + if !ok { + continue + } + sort.Slice(group, func(i, j int) bool { return group[i].Path < group[j].Path }) + fmt.Printf("\n %s (%d files):\n", k, len(group)) + for _, c := range group { + fmt.Printf(" %-60s %d replacement(s)\n", c.Path, c.Count) + } + } + fmt.Printf("\n total: %d files, %d replacement(s)\n", r.TotalFiles, r.TotalReplacements) + if r.DryRun { + fmt.Println("\nRe-run with --write to apply.") + } else { + fmt.Println("\n.rebarrc updated, CONTRACT-REGISTRY.md regenerated.") + } +} + +func emitMigrationJSON(r *migrationReport) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(r) +} diff --git a/cli/cmd/init.go b/cli/cmd/init.go index 0a147c5..2fba914 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/spf13/cobra" "github.com/willackerly/rebar/cli/internal/config" + "github.com/willackerly/rebar/cli/internal/hooks" "github.com/willackerly/rebar/cli/internal/integrity" ) @@ -248,23 +249,6 @@ _None currently._ } } - // scripts/refresh-context.sh - scriptsDir := filepath.Join(root, "scripts") - refreshPath := filepath.Join(scriptsDir, "refresh-context.sh") - if _, err := os.Stat(refreshPath); os.IsNotExist(err) { - os.MkdirAll(scriptsDir, 0755) - // Find rebar's copy to use as source - rebarRoot := findRebarRoot() - if rebarRoot != "" { - src := filepath.Join(rebarRoot, "scripts", "refresh-context.sh") - if data, err := os.ReadFile(src); err == nil { - os.WriteFile(refreshPath, data, 0755) - fmt.Println(" Created scripts/refresh-context.sh") - created++ - } - } - } - // architecture/ directory archDir := filepath.Join(root, "architecture") if _, err := os.Stat(archDir); os.IsNotExist(err) { @@ -299,6 +283,16 @@ _None currently._ created++ } + // agents/subagent-guidelines.md + if ensureSubagentGuidelines(root) { + created++ + } + + // .git/hooks/pre-commit + if ensurePreCommitHook(root) { + created++ + } + return created } @@ -407,28 +401,96 @@ func ensureAgentsScaffolding(root string) bool { return true } -// findRebarRoot locates the rebar framework repo by checking common locations. +// ensureSubagentGuidelines copies agents/subagent-guidelines.md from the +// framework if missing. Skipped if already present (users may customize). +func ensureSubagentGuidelines(root string) bool { + guidelinesPath := filepath.Join(root, "agents", "subagent-guidelines.md") + if _, err := os.Stat(guidelinesPath); err == nil { + return false + } + + os.MkdirAll(filepath.Join(root, "agents"), 0755) + rebarRoot := findRebarRoot() + if rebarRoot == "" { + return false + } + + src := filepath.Join(rebarRoot, "agents", "subagent-guidelines.md") + data, err := os.ReadFile(src) + if err != nil { + return false + } + + if err := os.WriteFile(guidelinesPath, data, 0644); err != nil { + return false + } + fmt.Println(" Created agents/subagent-guidelines.md") + return true +} + +// ensurePreCommitHook installs .git/hooks/pre-commit, delegating to +// framework's scripts/pre-commit.sh. Merges with existing hooks. +func ensurePreCommitHook(root string) bool { + rebarRoot := findRebarRoot() + if rebarRoot == "" { + fmt.Println(" Skipped pre-commit hook — rebar framework not found") + return false + } + + if err := hooks.InstallPreCommit(root, rebarRoot, "symlink"); err != nil { + fmt.Printf(" ⚠ Could not install pre-commit hook: %v\n", err) + return false + } + fmt.Println(" Installed .git/hooks/pre-commit") + return true +} + +// findRebarRoot locates the rebar framework install directory. +// Precedence: REBAR_DIR env → walk up from executable → ~/.rebar/current/ → ~/.rebar/ func findRebarRoot() string { - // Check if we're inside the rebar repo - if _, err := os.Stat("DESIGN.md"); err == nil { - if _, err := os.Stat("architecture/CONTRACT-TEMPLATE.md"); err == nil { - cwd, _ := os.Getwd() - return cwd + // 1. REBAR_DIR env (explicit override) + if env := os.Getenv("REBAR_DIR"); env != "" { + if _, err := os.Stat(filepath.Join(env, "bin", "rebar")); err == nil { + return env } } - // Check common locations + // 2. Walk up from this executable (bin/rebar → framework root) + if exe, err := os.Executable(); err == nil { + // Resolve symlinks + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + // bin/rebar → bin/ → framework root + frameworkDir := filepath.Dir(filepath.Dir(exe)) + if _, err := os.Stat(filepath.Join(frameworkDir, "scripts", "pre-commit.sh")); err == nil { + return frameworkDir + } + } + + // 3. ~/.rebar/current/ symlink (versioned install) home, _ := os.UserHomeDir() - candidates := []string{ - filepath.Join(home, "dev", "rebar"), - filepath.Join(home, "src", "rebar"), - filepath.Join(home, "code", "rebar"), - } - for _, c := range candidates { - if _, err := os.Stat(filepath.Join(c, "DESIGN.md")); err == nil { - return c + currentLink := filepath.Join(home, ".rebar", "current") + if target, err := os.Readlink(currentLink); err == nil { + abs := filepath.Join(home, ".rebar", target) + if _, err := os.Stat(filepath.Join(abs, "scripts", "pre-commit.sh")); err == nil { + return abs } } + + // 4. ~/.rebar/ (legacy single install) + legacy := filepath.Join(home, ".rebar") + if _, err := os.Stat(filepath.Join(legacy, "scripts", "pre-commit.sh")); err == nil { + return legacy + } + + // 5. CWD if it's the rebar source repo (for development) + if _, err := os.Stat("DESIGN.md"); err == nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + } + return "" } diff --git a/cli/cmd/new.go b/cli/cmd/new.go index 8685ef8..e617581 100644 --- a/cli/cmd/new.go +++ b/cli/cmd/new.go @@ -132,17 +132,6 @@ build/ } fmt.Println(" ✓ architecture/") - // scripts/ - scriptsDir := filepath.Join(root, "scripts") - os.MkdirAll(scriptsDir, 0755) - if rebarRoot != "" { - src := filepath.Join(rebarRoot, "scripts", "refresh-context.sh") - if data, err := os.ReadFile(src); err == nil { - os.WriteFile(filepath.Join(scriptsDir, "refresh-context.sh"), data, 0755) - } - } - fmt.Println(" ✓ scripts/") - // Phase 3: LLM-generated content (if description provided) if newDescription != "" { fmt.Println("\n Phase 3: AI-Generated Content (via Claude API)") @@ -208,6 +197,18 @@ Be practical and specific. Don't over-engineer — this is a fresh project.`, na } } + // agents/subagent-guidelines.md + ensureSubagentGuidelines(root) + + // .git/hooks/pre-commit + ensurePreCommitHook(root) + + // Initialize integrity tracking (.rebar/ directory, manifest, salt) + fmt.Println("\n Initializing integrity tracking") + if err := runInit(cmd, []string{}); err != nil { + return fmt.Errorf("initializing .rebar/: %w", err) + } + // Initial commit fmt.Println("\n Phase 4: Initial Commit") exec.Command("git", "-C", root, "add", "-A").Run() diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 870e5dd..600158a 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -8,6 +8,10 @@ import ( "github.com/willackerly/rebar/cli/internal/config" ) +// Version is set via ldflags during build (e.g., -X github.com/willackerly/rebar/cli/cmd.Version=v3.1.0) +// Defaults to "dev" for local builds without version injection +var Version = "dev" + var ( verbose bool jsonOut bool @@ -17,7 +21,7 @@ var ( var rootCmd = &cobra.Command{ Use: "rebar", - Version: "v3.0.1-alpha", + Version: Version, Short: "REBAR — contract-driven development framework for AI-powered teams", Long: `REBAR — contract-driven development framework for AI-powered teams. @@ -76,6 +80,16 @@ func Execute() error { return rootCmd.Execute() } +func scriptEnv() map[string]string { + if cfg == nil { + return nil + } + return map[string]string{ + "REBAR_CONTRACT_NAMESPACE": cfg.ContractNamespace, + "REBAR_TIER": fmt.Sprintf("%d", cfg.Tier), + } +} + func init() { rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "machine-readable JSON output") @@ -120,11 +134,13 @@ func init() { checkCmd.GroupID = "quality" diffCmd.GroupID = "quality" contractCmd.GroupID = "quality" + upgradeCmd.GroupID = "quality" rootCmd.AddCommand(verifyCmd) rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(checkCmd) rootCmd.AddCommand(diffCmd) rootCmd.AddCommand(contractCmd) + rootCmd.AddCommand(upgradeCmd) // Signing & Keys signCmd.GroupID = "keys" diff --git a/cli/cmd/spec.go b/cli/cmd/spec.go new file mode 100644 index 0000000..819a525 --- /dev/null +++ b/cli/cmd/spec.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/willackerly/rebar/cli/internal/spec" +) + +var ( + specOutDir string + specFormat string + specForce bool + specDryRun bool + specContractDir string +) + +var specCmd = &cobra.Command{ + Use: "spec", + Short: "Export, import, and sync framework-agnostic specifications", + Long: `Convert REBAR contracts to/from standard formats (Gherkin, Mermaid, OpenAPI). + +Enables interoperability with other agentic frameworks and tools. + +Examples: + rebar spec export # export all contracts to specs/ + rebar spec export --out build/specs # custom output directory + rebar spec import specs/gherkin/*.feature # import Gherkin scenarios + rebar spec sync # bidirectional sync with change detection`, +} + +var specExportCmd = &cobra.Command{ + Use: "export [contract-path...]", + Short: "Export contracts to standard formats", + Long: `Export REBAR contracts to framework-agnostic specifications. + +Output directory structure: + specs/ + gherkin/ Behavior scenarios (.feature) + mermaid/ Architecture diagrams (.mmd) + openapi/ API contracts (.yaml) + schemas/ Data schemas (.json) + adrs/ Decision records (.md) + .spec-manifest.json Sync tracking + +Supported formats: + - Gherkin: Behavior scenarios (from Scenarios section) + - Mermaid: Architecture diagrams (from mermaid code blocks) + - OpenAPI: API contracts (from API section) + - JSON Schema: Data models (from Data section) + - ADR: Architecture decisions (from Decisions section) + +Examples: + rebar spec export + rebar spec export architecture/CONTRACT-*.md + rebar spec export --format gherkin + rebar spec export --out build/specs`, + RunE: runSpecExport, +} + +var specImportCmd = &cobra.Command{ + Use: "import ", + Short: "Import standard specs as REBAR contracts", + Long: `Generate or update REBAR contracts from external specifications. + +Auto-detects format from file extension: + .feature -> Gherkin scenarios + .mmd -> Mermaid diagrams + .yaml -> OpenAPI (if paths/components present) + .json -> JSON Schema (if $schema present) + .md -> ADR (if decision record format) + +Examples: + rebar spec import specs/gherkin/*.feature + rebar spec import specs/openapi/api.yaml + rebar spec import specs/**/* # import all`, + Args: cobra.MinimumNArgs(1), + RunE: runSpecImport, +} + +var specSyncCmd = &cobra.Command{ + Use: "sync", + Short: "Bidirectional sync between contracts and specs", + Long: `Synchronize REBAR contracts with exported specifications. + +Detects changes on both sides using checksums in .spec-manifest.json: + - Contract changed → re-export to specs/ + - Spec changed → prompt to update contract + - Both changed → conflict warning, manual resolution + +Examples: + rebar spec sync # sync all + rebar spec sync --dry-run # show what would change + rebar spec sync --force # overwrite without prompting`, + RunE: runSpecSync, +} + +func init() { + // Parent command flags + specCmd.PersistentFlags().StringVar(&specOutDir, "out", "specs", "output directory for exported specs") + specCmd.PersistentFlags().StringVar(&specContractDir, "contracts", "architecture", "directory containing contracts") + + // Export flags + specExportCmd.Flags().StringVar(&specFormat, "format", "", "export only this format (gherkin, mermaid, openapi, schema, adr)") + specExportCmd.Flags().BoolVar(&specForce, "force", false, "overwrite existing spec files") + specExportCmd.Flags().BoolVar(&specDryRun, "dry-run", false, "show what would be exported without writing") + + // Import flags + specImportCmd.Flags().BoolVar(&specForce, "force", false, "overwrite existing contracts without prompting") + specImportCmd.Flags().BoolVar(&specDryRun, "dry-run", false, "show what would be imported without writing") + + // Sync flags + specSyncCmd.Flags().BoolVar(&specForce, "force", false, "resolve conflicts by overwriting") + specSyncCmd.Flags().BoolVar(&specDryRun, "dry-run", false, "show changes without applying") + + specCmd.AddCommand(specExportCmd) + specCmd.AddCommand(specImportCmd) + specCmd.AddCommand(specSyncCmd) + rootCmd.AddCommand(specCmd) +} + +func runSpecExport(cmd *cobra.Command, args []string) error { + if repoRoot == "" { + return fmt.Errorf("not in a REBAR repository") + } + + opts := spec.ExportOptions{ + RepoRoot: repoRoot, + ContractDir: specContractDir, + OutDir: specOutDir, + Format: specFormat, + Force: specForce, + DryRun: specDryRun, + Patterns: args, + } + + return spec.Export(opts) +} + +func runSpecImport(cmd *cobra.Command, args []string) error { + if repoRoot == "" { + return fmt.Errorf("not in a REBAR repository") + } + + opts := spec.ImportOptions{ + RepoRoot: repoRoot, + ContractDir: specContractDir, + SpecPaths: args, + Force: specForce, + DryRun: specDryRun, + } + + return spec.Import(opts) +} + +func runSpecSync(cmd *cobra.Command, args []string) error { + if repoRoot == "" { + return fmt.Errorf("not in a REBAR repository") + } + + opts := spec.SyncOptions{ + RepoRoot: repoRoot, + ContractDir: specContractDir, + OutDir: specOutDir, + Force: specForce, + DryRun: specDryRun, + } + + return spec.Sync(opts) +} diff --git a/cli/cmd/upgrade.go b/cli/cmd/upgrade.go new file mode 100644 index 0000000..86776d4 --- /dev/null +++ b/cli/cmd/upgrade.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var ( + upgradeDryRun bool + upgradeForce bool +) + +var upgradeCmd = &cobra.Command{ + Use: "upgrade ", + Short: "Upgrade project to a different rebar version", + Long: `Migrates this project from its current rebar version (in .rebar-version) +to a target version. Runs version-specific migration scripts and updates +.rebar-version. + +Goose-style migration: each version transition has an optional migration +script at $REBAR_DIR/migrations/-to-.sh that handles schema +changes, config rewrites, or file migrations. + +By default this is a dry run. Use --force to apply. + +Examples: + rebar upgrade v3.1.0 Show migration plan + rebar upgrade v3.1.0 --force Execute migration +`, + Args: cobra.ExactArgs(1), + RunE: runUpgrade, +} + +func init() { + upgradeCmd.Flags().BoolVar(&upgradeDryRun, "dry-run", false, "show migration plan without applying (default behavior)") + upgradeCmd.Flags().BoolVar(&upgradeForce, "force", false, "apply migration") +} + +func runUpgrade(cmd *cobra.Command, args []string) error { + targetVersion := args[0] + root := cfg.RepoRoot + + // Read current version + versionPath := filepath.Join(root, ".rebar-version") + currentBytes, err := os.ReadFile(versionPath) + if err != nil { + return fmt.Errorf("reading .rebar-version: %w", err) + } + currentVersion := strings.TrimSpace(string(currentBytes)) + + if currentVersion == targetVersion { + fmt.Printf("Already at %s\n", targetVersion) + return nil + } + + // Resolve target framework dir + home, _ := os.UserHomeDir() + targetDir := filepath.Join(home, ".rebar", "versions", targetVersion) + if _, err := os.Stat(targetDir); os.IsNotExist(err) { + return fmt.Errorf("rebar %s not installed\nRun: curl ... | bash -s -- %s", targetVersion, targetVersion) + } + + // Look for migration script + migrationName := fmt.Sprintf("%s-to-%s.sh", currentVersion, targetVersion) + migrationScript := filepath.Join(targetDir, "migrations", migrationName) + + hasMigration := false + if _, err := os.Stat(migrationScript); err == nil { + hasMigration = true + } + + // Dry-run or force check + isDryRun := !upgradeForce + + fmt.Printf("Upgrade: %s → %s\n", currentVersion, targetVersion) + if hasMigration { + fmt.Printf(" Migration script: %s\n", migrationName) + } else { + fmt.Println(" No migration script (version bump only)") + } + + if isDryRun { + fmt.Println("\nDRY RUN — pass --force to apply") + if hasMigration { + fmt.Println("\nMigration script preview:") + data, _ := os.ReadFile(migrationScript) + fmt.Println(string(data)) + } + return nil + } + + // Execute migration + if hasMigration { + fmt.Println("\nRunning migration...") + c := exec.Command("bash", migrationScript, root) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("migration failed: %w", err) + } + fmt.Println("✓ Migration complete") + } + + // Update .rebar-version + if err := os.WriteFile(versionPath, []byte(targetVersion+"\n"), 0644); err != nil { + return fmt.Errorf("updating .rebar-version: %w", err) + } + fmt.Printf("✓ Updated .rebar-version to %s\n", targetVersion) + + // Verify post-upgrade health + fmt.Println("\nRunning post-upgrade audit...") + c := exec.Command(os.Args[0], "audit") + c.Dir = root + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Run() // ignore error — audit prints its own output + + return nil +} diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 279a529..32a8dc3 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -10,25 +10,38 @@ import ( ) type Config struct { - RepoRoot string - RebarDir string // .rebar/ - Tier int // 1, 2, or 3 - Version string // from .rebar-version - ScriptsDir string // scripts/ - AgentsDir string // agents/ - BinDir string // bin/ + RepoRoot string + RebarDir string // .rebar/ in project + RebarFrameworkDir string // framework install (versioned, e.g. ~/.rebar/versions/v3.1.0) + Tier int // 1, 2, or 3 + Version string // from .rebar-version + ScriptsDir string // framework scripts/ dir + AgentsDir string // agents/ in project + BinDir string // bin/ in framework + ContractNamespace string // e.g. github.com/willackerly/rebar; empty = legacy/unmigrated } // Load reads configuration from .rebarrc and .rebar-version, respecting // REBAR_TIER env override. Mirrors the logic in scripts/_rebar-config.sh. func Load(repoRoot string) (*Config, error) { + // Read version first to resolve framework dir + versionBytes, _ := os.ReadFile(filepath.Join(repoRoot, ".rebar-version")) + version := strings.TrimSpace(string(versionBytes)) + + frameworkDir, err := findRebarDir(version) + if err != nil { + return nil, fmt.Errorf("finding rebar framework: %w", err) + } + c := &Config{ - RepoRoot: repoRoot, - RebarDir: filepath.Join(repoRoot, ".rebar"), - ScriptsDir: filepath.Join(repoRoot, "scripts"), - AgentsDir: filepath.Join(repoRoot, "agents"), - BinDir: filepath.Join(repoRoot, "bin"), - Tier: 3, // default: full enforcement + RepoRoot: repoRoot, + RebarDir: filepath.Join(repoRoot, ".rebar"), + RebarFrameworkDir: frameworkDir, + Version: version, + ScriptsDir: filepath.Join(frameworkDir, "scripts"), + AgentsDir: filepath.Join(repoRoot, "agents"), + BinDir: filepath.Join(frameworkDir, "bin"), + Tier: 3, // default: full enforcement } // REBAR_TIER env takes precedence @@ -45,10 +58,12 @@ func Load(repoRoot string) (*Config, error) { } } - // Read version - versionBytes, err := os.ReadFile(filepath.Join(repoRoot, ".rebar-version")) - if err == nil { - c.Version = strings.TrimSpace(string(versionBytes)) + // Contract namespace (Go-module form, e.g. github.com/owner/repo). + // Sourced from .rebarrc; REBAR_CONTRACT_NAMESPACE env var overrides. + if env := strings.TrimSpace(os.Getenv("REBAR_CONTRACT_NAMESPACE")); env != "" { + c.ContractNamespace = env + } else { + c.ContractNamespace = readRebarRCString(filepath.Join(repoRoot, ".rebarrc"), "contract_namespace") } return c, nil @@ -109,6 +124,76 @@ func readRebarRC(path string) (int, error) { return 0, fmt.Errorf("tier not found in .rebarrc") } +// readRebarRCString reads a string-valued key from .rebarrc. Returns +// empty string if the file is missing, the key is absent, or the value +// is blank. Matches keys case-insensitively. +func readRebarRCString(path, key string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + k := strings.TrimSpace(parts[0]) + v := strings.TrimSpace(parts[1]) + if strings.EqualFold(k, key) { + return v + } + } + return "" +} + +// findRebarDir locates the rebar framework installation directory. +// Precedence: REBAR_DIR env → ~/.rebar/versions// → ~/.rebar/current/ +func findRebarDir(version string) (string, error) { + // 1. REBAR_DIR env (explicit override for testing/CI) + if env := os.Getenv("REBAR_DIR"); env != "" { + if _, err := os.Stat(env); err == nil { + return env, nil + } + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting home dir: %w", err) + } + + // 2. Versioned install (~/.rebar/versions//) + if version != "" { + versioned := filepath.Join(home, ".rebar", "versions", version) + if _, err := os.Stat(versioned); err == nil { + return versioned, nil + } + } + + // 3. Current symlink (~/.rebar/current/) + current := filepath.Join(home, ".rebar", "current") + if target, err := os.Readlink(current); err == nil { + abs := filepath.Join(home, ".rebar", target) + if _, err := os.Stat(abs); err == nil { + return abs, nil + } + } + + // 4. Legacy single install (~/.rebar/) + legacy := filepath.Join(home, ".rebar") + if _, err := os.Stat(filepath.Join(legacy, "bin", "rebar")); err == nil { + return legacy, nil + } + + return "", fmt.Errorf("rebar framework not found — run setup-rebar.sh or set REBAR_DIR") +} + // EnsureRebarDir creates the .rebar/ directory structure. func EnsureRebarDir(repoRoot string) error { dirs := []string{ diff --git a/cli/internal/hooks/hooks.go b/cli/internal/hooks/hooks.go new file mode 100644 index 0000000..7d9f41b --- /dev/null +++ b/cli/internal/hooks/hooks.go @@ -0,0 +1,164 @@ +// Package hooks manages git hook installation and merging for rebar. +// +// Architecture: CONTRACT:S2-ASK-CLI.1.0 +package hooks + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// InstallPreCommit installs .git/hooks/pre-commit, merging with existing +// hook if present. +// +// strategy: "symlink" (default) creates symlink to framework's pre-commit.sh +// "merge" prepends rebar shim to existing hook with markers +// "force" overwrites existing hook +func InstallPreCommit(repoRoot, frameworkDir, strategy string) error { + hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit") + frameworkScript := filepath.Join(frameworkDir, "scripts", "pre-commit.sh") + + if _, err := os.Stat(frameworkScript); os.IsNotExist(err) { + return fmt.Errorf("framework pre-commit.sh not found at %s", frameworkScript) + } + + hookType, err := DetectExistingHook(repoRoot) + if err != nil { + return err + } + + switch hookType { + case "none": + relPath, _ := filepath.Rel(filepath.Dir(hookPath), frameworkScript) + if err := os.Symlink(relPath, hookPath); err != nil { + return fmt.Errorf("creating symlink: %w", err) + } + return nil + + case "rebar-symlink": + return nil + + case "custom": + if strategy == "force" { + os.Remove(hookPath) + relPath, _ := filepath.Rel(filepath.Dir(hookPath), frameworkScript) + return os.Symlink(relPath, hookPath) + } + return prependRebarShim(hookPath, frameworkScript) + + case "husky", "pre-commit-py": + return injectRebarShim(hookPath, frameworkScript) + + case "other-symlink": + return fmt.Errorf("existing symlink points elsewhere; re-run with --force to replace") + } + return nil +} + +// DetectExistingHook inspects .git/hooks/pre-commit and returns its type. +// Returns: "none", "rebar-symlink", "other-symlink", "husky", "pre-commit-py", "custom" +func DetectExistingHook(repoRoot string) (string, error) { + hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit") + info, err := os.Lstat(hookPath) + if os.IsNotExist(err) { + return "none", nil + } + if err != nil { + return "", fmt.Errorf("stat hook: %w", err) + } + + if info.Mode()&os.ModeSymlink != 0 { + target, _ := os.Readlink(hookPath) + if strings.Contains(target, "scripts/pre-commit.sh") { + return "rebar-symlink", nil + } + return "other-symlink", nil + } + + data, err := os.ReadFile(hookPath) + if err != nil { + return "", err + } + content := string(data) + + if strings.Contains(content, "# === REBAR ===") { + return "rebar-symlink", nil + } + if strings.Contains(content, "husky") || strings.Contains(content, ".husky/") { + return "husky", nil + } + if strings.Contains(content, "pre-commit run") || strings.Contains(content, "pre_commit") { + return "pre-commit-py", nil + } + return "custom", nil +} + +// prependRebarShim adds rebar enforcement delegation to an existing hook. +func prependRebarShim(hookPath, frameworkScript string) error { + data, err := os.ReadFile(hookPath) + if err != nil { + return err + } + existing := string(data) + + if strings.Contains(existing, "# === REBAR ===") { + return nil + } + + var shebang string + if strings.HasPrefix(existing, "#!") { + idx := strings.Index(existing, "\n") + if idx > 0 { + shebang = existing[:idx+1] + existing = existing[idx+1:] + } + } + if shebang == "" { + shebang = "#!/bin/bash\n" + } + + merged := shebang + merged += "# === REBAR === (auto-managed)\n" + merged += fmt.Sprintf("%q || exit 1\n", frameworkScript) + merged += "# === END REBAR ===\n\n" + merged += existing + + return os.WriteFile(hookPath, []byte(merged), 0755) +} + +// injectRebarShim inserts rebar delegation into framework-managed hooks +// (husky, pre-commit-py). Injects after shebang, before framework invocation. +func injectRebarShim(hookPath, frameworkScript string) error { + data, err := os.ReadFile(hookPath) + if err != nil { + return err + } + content := string(data) + + if strings.Contains(content, "# === REBAR ===") { + return nil + } + + lines := strings.Split(content, "\n") + var out []string + injected := false + + for i, line := range lines { + out = append(out, line) + if i == 0 && strings.HasPrefix(line, "#!") { + out = append(out, "# === REBAR === (auto-managed)") + out = append(out, fmt.Sprintf("%q || exit 1", frameworkScript)) + out = append(out, "# === END REBAR ===") + out = append(out, "") + injected = true + } + } + + if !injected { + return fmt.Errorf("could not find injection point in %s", hookPath) + } + + return os.WriteFile(hookPath, []byte(strings.Join(out, "\n")), 0755) +} diff --git a/cli/internal/repo/namespace.go b/cli/internal/repo/namespace.go new file mode 100644 index 0000000..0a6a4d3 --- /dev/null +++ b/cli/internal/repo/namespace.go @@ -0,0 +1,102 @@ +// Package repo derives a repo's canonical contract namespace from its +// git remote. +// +// Architecture: CONTRACT:github.com/willackerly/rebar:S2-ASK-CLI.1.0 +package repo + +import ( + "errors" + "fmt" + "os/exec" + "regexp" + "strings" +) + +// ErrNoRemote indicates the repo has no `origin` remote configured. +// Callers should surface this with guidance to pass --namespace or set +// contract_namespace in .rebarrc. +var ErrNoRemote = errors.New("no git remote origin configured") + +// InferNamespace runs `git remote get-url origin` in repoRoot and +// returns the Go-module form (host/org/repo). Returns ErrNoRemote if +// origin is not set. +func InferNamespace(repoRoot string) (string, error) { + cmd := exec.Command("git", "-C", repoRoot, "remote", "get-url", "origin") + out, err := cmd.Output() + if err != nil { + // `git remote get-url` exits 2 when the remote is missing. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", ErrNoRemote + } + return "", fmt.Errorf("running git remote get-url: %w", err) + } + return ParseRemoteURL(strings.TrimSpace(string(out))) +} + +var ( + // SSH short form: git@host:org/repo.git or user@host:path/repo + sshShortRE = regexp.MustCompile(`^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(.+)$`) + // URL form with scheme: ssh://, https://, http://, git:// + urlSchemeRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://`) +) + +// ParseRemoteURL normalizes a git remote URL to host/org/repo. The host +// is lowercased; the path portion is preserved case-sensitively because +// path case is host-defined. Trailing `.git` is stripped. +// +// Supported inputs: +// +// git@github.com:willackerly/rebar.git → github.com/willackerly/rebar +// https://github.com/willackerly/rebar.git → github.com/willackerly/rebar +// https://github.com/willackerly/rebar → github.com/willackerly/rebar +// ssh://git@gitlab.com/team/foo.git → gitlab.com/team/foo +// git@gitea.example.com:nested/group/repo.git → gitea.example.com/nested/group/repo +func ParseRemoteURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errors.New("remote URL is empty") + } + + var host, path string + + switch { + case urlSchemeRE.MatchString(raw): + // Strip scheme. + idx := strings.Index(raw, "://") + rest := raw[idx+3:] + // Strip optional userinfo (user@ or user:pass@). + if at := strings.Index(rest, "@"); at >= 0 && at < strings.Index(rest+"/", "/") { + rest = rest[at+1:] + } + // Split host from path. + slash := strings.Index(rest, "/") + if slash < 0 { + return "", fmt.Errorf("remote URL %q has no path", raw) + } + host = rest[:slash] + path = rest[slash+1:] + case sshShortRE.MatchString(raw): + m := sshShortRE.FindStringSubmatch(raw) + host = m[1] + path = m[2] + default: + return "", fmt.Errorf("unrecognized remote URL format: %q", raw) + } + + // Strip port from host (e.g. localhost:2222). + if colon := strings.Index(host, ":"); colon >= 0 { + host = host[:colon] + } + host = strings.ToLower(host) + + // Strip trailing slash and .git suffix from path. + path = strings.TrimSuffix(path, "/") + path = strings.TrimSuffix(path, ".git") + + if host == "" || path == "" { + return "", fmt.Errorf("remote URL %q missing host or path after normalization", raw) + } + + return host + "/" + path, nil +} diff --git a/cli/internal/repo/namespace_test.go b/cli/internal/repo/namespace_test.go new file mode 100644 index 0000000..cd38c9e --- /dev/null +++ b/cli/internal/repo/namespace_test.go @@ -0,0 +1,46 @@ +package repo + +import "testing" + +func TestParseRemoteURL(t *testing.T) { + cases := []struct { + name string + input string + want string + wantErr bool + }{ + {"ssh short", "git@github.com:willackerly/rebar.git", "github.com/willackerly/rebar", false}, + {"ssh short no .git", "git@github.com:willackerly/rebar", "github.com/willackerly/rebar", false}, + {"https with .git", "https://github.com/willackerly/rebar.git", "github.com/willackerly/rebar", false}, + {"https no .git", "https://github.com/willackerly/rebar", "github.com/willackerly/rebar", false}, + {"ssh:// scheme", "ssh://git@gitlab.com/team/foo.git", "gitlab.com/team/foo", false}, + {"gitlab nested groups", "git@gitea.example.com:nested/group/repo.git", "gitea.example.com/nested/group/repo", false}, + {"uppercase host lowered", "https://GitHub.com/Foo/Bar.git", "github.com/Foo/Bar", false}, + {"trailing slash", "https://github.com/foo/bar/", "github.com/foo/bar", false}, + {"http scheme", "http://example.com/foo/bar.git", "example.com/foo/bar", false}, + {"https with port", "https://example.com:8443/foo/bar.git", "example.com/foo/bar", false}, + {"git scheme", "git://github.com/foo/bar.git", "github.com/foo/bar", false}, + + {"empty", "", "", true}, + {"no path", "https://github.com", "", true}, + {"garbage", "not-a-url", "", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseRemoteURL(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got %q", tc.input, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.input, err) + } + if got != tc.want { + t.Errorf("ParseRemoteURL(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} diff --git a/cli/internal/repo/tracked.go b/cli/internal/repo/tracked.go new file mode 100644 index 0000000..226f13f --- /dev/null +++ b/cli/internal/repo/tracked.go @@ -0,0 +1,54 @@ +package repo + +import ( + "fmt" + "os/exec" + "strings" +) + +// TrackedFiles returns git-tracked files in repoRoot matching the given +// glob patterns (e.g. "*.go", "*.ts"). Returns relative paths. +func TrackedFiles(repoRoot string, patterns ...string) ([]string, error) { + args := append([]string{"-C", repoRoot, "ls-files", "--"}, patterns...) + cmd := exec.Command("git", args...) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git ls-files: %w", err) + } + raw := strings.TrimSpace(string(out)) + if raw == "" { + return nil, nil + } + return strings.Split(raw, "\n"), nil +} + +// TrackedFilesGrep returns git-tracked files matching patterns whose +// contents match the given grep regex. Equivalent to: +// +// git ls-files | xargs grep -lE +func TrackedFilesGrep(repoRoot, regex string, patterns ...string) ([]string, error) { + shell := fmt.Sprintf( + "git -C %q ls-files -- %s | xargs grep -lE %q 2>/dev/null", + repoRoot, shellGlobs(patterns), regex) + cmd := exec.Command("bash", "-c", shell) + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return nil, nil + } + return nil, fmt.Errorf("git ls-files | grep: %w", err) + } + raw := strings.TrimSpace(string(out)) + if raw == "" { + return nil, nil + } + return strings.Split(raw, "\n"), nil +} + +func shellGlobs(patterns []string) string { + quoted := make([]string, len(patterns)) + for i, p := range patterns { + quoted[i] = "'" + p + "'" + } + return strings.Join(quoted, " ") +} diff --git a/cli/internal/scripts/runner.go b/cli/internal/scripts/runner.go index 0345410..1821e64 100644 --- a/cli/internal/scripts/runner.go +++ b/cli/internal/scripts/runner.go @@ -17,20 +17,32 @@ type Result struct { Duration time.Duration } +func buildEnv(extra map[string]string) []string { + env := os.Environ() + for k, v := range extra { + if v != "" { + env = append(env, k+"="+v) + } + } + return env +} + // Run executes a bash script and captures its output. -func Run(scriptsDir, scriptName string, args ...string) (*Result, error) { +// repoRoot is the consumer repo directory used as the working directory. +// env provides additional environment variables passed to the script. +func Run(scriptsDir, repoRoot, scriptName string, env map[string]string, args ...string) (*Result, error) { scriptPath := scriptsDir + "/" + scriptName if _, err := os.Stat(scriptPath); err != nil { return nil, fmt.Errorf("script not found: %s", scriptPath) } cmd := exec.Command("bash", append([]string{scriptPath}, args...)...) - cmd.Dir = scriptsDir + "/.." // run from repo root + cmd.Dir = repoRoot var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr - cmd.Env = os.Environ() + cmd.Env = buildEnv(env) start := time.Now() err := cmd.Run() @@ -55,18 +67,20 @@ func Run(scriptsDir, scriptName string, args ...string) (*Result, error) { } // RunPassthrough executes a script with stdout/stderr connected to the terminal. -func RunPassthrough(scriptsDir, scriptName string, args ...string) (int, error) { +// repoRoot is the consumer repo directory used as the working directory. +// env provides additional environment variables passed to the script. +func RunPassthrough(scriptsDir, repoRoot, scriptName string, env map[string]string, args ...string) (int, error) { scriptPath := scriptsDir + "/" + scriptName if _, err := os.Stat(scriptPath); err != nil { return -1, fmt.Errorf("script not found: %s", scriptPath) } cmd := exec.Command("bash", append([]string{scriptPath}, args...)...) - cmd.Dir = scriptsDir + "/.." + cmd.Dir = repoRoot cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin - cmd.Env = os.Environ() + cmd.Env = buildEnv(env) err := cmd.Run() if err != nil { diff --git a/cli/internal/spec/TODO.md b/cli/internal/spec/TODO.md new file mode 100644 index 0000000..296da9b --- /dev/null +++ b/cli/internal/spec/TODO.md @@ -0,0 +1,93 @@ +# Spec Package TODO + +## High Priority - Additional Format Plugins + +### 1. Protobuf Plugin +**Status:** Not started +**Priority:** High (ubiquitous for gRPC/microservices) + +**Implementation:** +- Extension: `.proto` +- SubDir: `protobuf/` +- Extract: Parse `.proto` service definitions + message types from contracts +- Generate: Create contract from `.proto` service/message definitions +- Detect: File extension + `syntax = "proto3"` or `service` keyword + +**Use cases:** +- gRPC service contracts +- Message serialization schemas +- Cross-language type definitions + +### 2. TypeSpec Plugin +**Status:** Not started +**Priority:** High (growing adoption, multi-format generation) + +**Implementation:** +- Extension: `.tsp` +- SubDir: `typespec/` +- Extract: Parse TypeSpec service definitions from contracts +- Generate: Create contract from `.tsp` models/operations +- Detect: File extension + TypeSpec syntax (`model`, `op`, `namespace`) + +**Use cases:** +- Protocol-agnostic service definitions +- Generate OpenAPI, Protobuf, JSON Schema from single source +- Microsoft ecosystem integration + +**Notes:** +- TypeSpec can emit OpenAPI/Protobuf, so consider workflow: + - Option A: Store `.tsp` as canonical, generate OpenAPI/Proto on export + - Option B: Treat `.tsp` as another format alongside OpenAPI/Proto +- May need TypeSpec compiler integration for multi-format export + +## Medium Priority + +### 3. AsyncAPI Plugin +**Status:** Not started +**Priority:** Medium (event-driven architectures) + +- Extension: `.yaml` (detect via `asyncapi:` version field) +- SubDir: `asyncapi/` +- Use cases: Kafka, AMQP, WebSocket event contracts + +### 4. GraphQL Plugin +**Status:** Not started +**Priority:** Medium (API-heavy projects) + +- Extension: `.graphql` / `.gql` +- SubDir: `graphql/` +- Use cases: GraphQL schema definitions, queries, mutations + +### 5. Smithy Plugin +**Status:** Not started +**Priority:** Low (AWS-focused, niche) + +- Extension: `.smithy` +- SubDir: `smithy/` +- Use cases: AWS SDK generation, protocol-agnostic services + +## Architectural Improvements + +### Refactor export/import to use plugin system +**Status:** Partially complete (plugin interface defined) +**Next steps:** +- Refactor `export.go` to use `PluginRegistry` +- Refactor `import.go` to use `PluginRegistry` +- Remove hardcoded format logic, delegate to plugins +- Update tests for plugin architecture + +### Multi-format compilation +**Status:** Not started +**Goal:** Support formats that compile to other formats (TypeSpec → OpenAPI/Proto) + +**Design considerations:** +- Should contracts store compiled or source format? +- How to track derived specs vs source specs in manifest? +- Compilation step in export workflow + +## Testing + +### Improve test isolation +**Status:** In progress (intermittent failures in full suite) +**Issue:** TestExtractGherkinScenarios fails intermittently when run with full suite +**Fix:** Investigate shared state or file system pollution between tests diff --git a/cli/internal/spec/export.go b/cli/internal/spec/export.go new file mode 100644 index 0000000..629e5e0 --- /dev/null +++ b/cli/internal/spec/export.go @@ -0,0 +1,227 @@ +package spec + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Export exports contracts to standard specification formats +func Export(opts ExportOptions) error { + // Find contracts to export + contractPaths, err := FindContracts(filepath.Join(opts.RepoRoot, opts.ContractDir), opts.Patterns) + if err != nil { + return fmt.Errorf("finding contracts: %w", err) + } + + if len(contractPaths) == 0 { + fmt.Println("No contracts found to export") + return nil + } + + // Load manifest + manifest, err := LoadManifest(filepath.Join(opts.RepoRoot, opts.OutDir)) + if err != nil { + return fmt.Errorf("loading manifest: %w", err) + } + + // Track results + exported := 0 + skipped := 0 + errors := 0 + + fmt.Printf("Exporting %d contracts to %s/\n\n", len(contractPaths), opts.OutDir) + + for _, contractPath := range contractPaths { + rel, _ := filepath.Rel(opts.RepoRoot, contractPath) + fmt.Printf(" %s\n", rel) + + // Parse contract + contract, err := ParseContract(contractPath) + if err != nil { + fmt.Printf(" ✗ parse error: %v\n", err) + errors++ + continue + } + + if contract.ID == "" { + fmt.Printf(" ⚠ skipped: no contract ID found\n") + skipped++ + continue + } + + // Export to each format + mapping := SpecMapping{ + Contract: rel, + Exports: []ExportedSpec{}, + } + + // Gherkin + if opts.Format == "" || opts.Format == string(FormatGherkin) { + if exported, spec := exportGherkin(contract, opts); exported != nil { + mapping.Exports = append(mapping.Exports, *exported) + fmt.Printf(" ✓ %s\n", spec) + } + } + + // Mermaid + if opts.Format == "" || opts.Format == string(FormatMermaid) { + if exported, spec := exportMermaid(contract, opts); exported != nil { + mapping.Exports = append(mapping.Exports, *exported) + fmt.Printf(" ✓ %s\n", spec) + } + } + + // OpenAPI + if opts.Format == "" || opts.Format == string(FormatOpenAPI) { + if exported, spec := exportOpenAPI(contract, opts); exported != nil { + mapping.Exports = append(mapping.Exports, *exported) + fmt.Printf(" ✓ %s\n", spec) + } + } + + // JSON Schema + if opts.Format == "" || opts.Format == string(FormatSchema) { + if exported, spec := exportJSONSchema(contract, opts); exported != nil { + mapping.Exports = append(mapping.Exports, *exported) + fmt.Printf(" ✓ %s\n", spec) + } + } + + // Update checksums + if len(mapping.Exports) > 0 { + mapping.ContractChecksum, _ = ComputeChecksum(contractPath) + for i := range mapping.Exports { + absPath := filepath.Join(opts.RepoRoot, mapping.Exports[i].Path) + mapping.Exports[i].Checksum, _ = ComputeChecksum(absPath) + } + manifest.UpdateMapping(mapping) + exported++ + } else { + fmt.Printf(" ⚠ no exportable content found\n") + skipped++ + } + } + + // Save manifest + if !opts.DryRun { + if err := SaveManifest(filepath.Join(opts.RepoRoot, opts.OutDir), manifest); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + } + + fmt.Printf("\n✓ Exported %d contracts (%d skipped, %d errors)\n", exported, skipped, errors) + if opts.DryRun { + fmt.Println("(dry-run: no files written)") + } + + return nil +} + +func exportGherkin(contract *Contract, opts ExportOptions) (*ExportedSpec, string) { + content := contract.ExtractGherkinScenarios() + if content == "" { + return nil, "" + } + + filename := fmt.Sprintf("%s.feature", sanitizeFilename(contract.ID)) + relPath := filepath.Join(opts.OutDir, "gherkin", filename) + absPath := filepath.Join(opts.RepoRoot, relPath) + + if !opts.DryRun { + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return nil, "" + } + if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { + return nil, "" + } + } + + return &ExportedSpec{Type: string(FormatGherkin), Path: relPath}, relPath +} + +func exportMermaid(contract *Contract, opts ExportOptions) (*ExportedSpec, string) { + diagrams := contract.ExtractMermaidDiagrams() + if len(diagrams) == 0 { + return nil, "" + } + + // Combine multiple diagrams with comments + var combined strings.Builder + for i, diagram := range diagrams { + if i > 0 { + combined.WriteString("\n\n%% Diagram ") + combined.WriteString(fmt.Sprintf("%d", i+1)) + combined.WriteString("\n\n") + } + combined.WriteString(diagram) + } + + filename := fmt.Sprintf("%s.mmd", sanitizeFilename(contract.ID)) + relPath := filepath.Join(opts.OutDir, "mermaid", filename) + absPath := filepath.Join(opts.RepoRoot, relPath) + + if !opts.DryRun { + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return nil, "" + } + if err := os.WriteFile(absPath, []byte(combined.String()), 0644); err != nil { + return nil, "" + } + } + + return &ExportedSpec{Type: string(FormatMermaid), Path: relPath}, relPath +} + +func exportOpenAPI(contract *Contract, opts ExportOptions) (*ExportedSpec, string) { + content := contract.ExtractOpenAPISpec() + if content == "" { + return nil, "" + } + + filename := fmt.Sprintf("%s.yaml", sanitizeFilename(contract.ID)) + relPath := filepath.Join(opts.OutDir, "openapi", filename) + absPath := filepath.Join(opts.RepoRoot, relPath) + + if !opts.DryRun { + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return nil, "" + } + if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { + return nil, "" + } + } + + return &ExportedSpec{Type: string(FormatOpenAPI), Path: relPath}, relPath +} + +func exportJSONSchema(contract *Contract, opts ExportOptions) (*ExportedSpec, string) { + content := contract.ExtractJSONSchema() + if content == "" { + return nil, "" + } + + filename := fmt.Sprintf("%s.json", sanitizeFilename(contract.ID)) + relPath := filepath.Join(opts.OutDir, "schemas", filename) + absPath := filepath.Join(opts.RepoRoot, relPath) + + if !opts.DryRun { + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return nil, "" + } + if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { + return nil, "" + } + } + + return &ExportedSpec{Type: string(FormatSchema), Path: relPath}, relPath +} + +func sanitizeFilename(s string) string { + // Replace path separators and special chars + s = strings.ReplaceAll(s, "/", "-") + s = strings.ReplaceAll(s, "\\", "-") + s = strings.ReplaceAll(s, ":", "-") + return s +} diff --git a/cli/internal/spec/export_test.go b/cli/internal/spec/export_test.go new file mode 100644 index 0000000..61167b3 --- /dev/null +++ b/cli/internal/spec/export_test.go @@ -0,0 +1,181 @@ +package spec + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExport(t *testing.T) { + // Setup temp directories + tmpDir := t.TempDir() + contractDir := filepath.Join(tmpDir, "architecture") + outDir := filepath.Join(tmpDir, "specs") + + if err := os.MkdirAll(contractDir, 0755); err != nil { + t.Fatal(err) + } + + // Copy test contract to temp + testContract := filepath.Join("testdata", "CONTRACT-AUTH.1.0.md") + contractData, err := os.ReadFile(testContract) + if err != nil { + t.Fatal(err) + } + + targetContract := filepath.Join(contractDir, "CONTRACT-AUTH.1.0.md") + if err := os.WriteFile(targetContract, contractData, 0644); err != nil { + t.Fatal(err) + } + + // Export + opts := ExportOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + OutDir: "specs", + Force: true, + } + + if err := Export(opts); err != nil { + t.Fatalf("Export() error = %v", err) + } + + // Verify exports (filenames use contract ID, not full name) + gherkinPath := filepath.Join(outDir, "gherkin", "AUTH.1.0.feature") + if _, err := os.Stat(gherkinPath); os.IsNotExist(err) { + t.Errorf("Gherkin export not found: %s", gherkinPath) + } + + mermaidPath := filepath.Join(outDir, "mermaid", "AUTH.1.0.mmd") + if _, err := os.Stat(mermaidPath); os.IsNotExist(err) { + t.Errorf("Mermaid export not found: %s", mermaidPath) + } + + openapiPath := filepath.Join(outDir, "openapi", "AUTH.1.0.yaml") + if _, err := os.Stat(openapiPath); os.IsNotExist(err) { + t.Errorf("OpenAPI export not found: %s", openapiPath) + } + + schemaPath := filepath.Join(outDir, "schemas", "AUTH.1.0.json") + if _, err := os.Stat(schemaPath); os.IsNotExist(err) { + t.Errorf("Schema export not found: %s", schemaPath) + } + + // Verify manifest + manifestPath := filepath.Join(outDir, ManifestFile) + if _, err := os.Stat(manifestPath); os.IsNotExist(err) { + t.Errorf("Manifest not found: %s", manifestPath) + } + + manifest, err := LoadManifest(outDir) + if err != nil { + t.Fatalf("LoadManifest() error = %v", err) + } + + if len(manifest.Mappings) == 0 { + t.Error("Manifest has no mappings") + } + + mapping := manifest.Mappings[0] + if mapping.ContractChecksum == "" { + t.Error("Contract checksum is empty") + } + + if len(mapping.Exports) == 0 { + t.Error("No exports in mapping") + } + + for _, exp := range mapping.Exports { + if exp.Checksum == "" { + t.Errorf("Export %s has empty checksum", exp.Path) + } + } +} + +func TestExportSingleFormat(t *testing.T) { + tmpDir := t.TempDir() + contractDir := filepath.Join(tmpDir, "architecture") + outDir := filepath.Join(tmpDir, "specs") + + if err := os.MkdirAll(contractDir, 0755); err != nil { + t.Fatal(err) + } + + // Copy test contract + testContract := filepath.Join("testdata", "CONTRACT-AUTH.1.0.md") + contractData, err := os.ReadFile(testContract) + if err != nil { + t.Fatal(err) + } + + targetContract := filepath.Join(contractDir, "CONTRACT-AUTH.1.0.md") + if err := os.WriteFile(targetContract, contractData, 0644); err != nil { + t.Fatal(err) + } + + // Export only Gherkin + opts := ExportOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + OutDir: "specs", + Format: "gherkin", + Force: true, + } + + if err := Export(opts); err != nil { + t.Fatalf("Export() error = %v", err) + } + + // Verify only Gherkin exported + gherkinPath := filepath.Join(outDir, "gherkin", "AUTH.1.0.feature") + if _, err := os.Stat(gherkinPath); os.IsNotExist(err) { + t.Errorf("Gherkin export not found") + } + + mermaidPath := filepath.Join(outDir, "mermaid", "AUTH.1.0.mmd") + if _, err := os.Stat(mermaidPath); err == nil { + t.Error("Mermaid should not be exported with format=gherkin") + } +} + +func TestExportDryRun(t *testing.T) { + tmpDir := t.TempDir() + contractDir := filepath.Join(tmpDir, "architecture") + outDir := filepath.Join(tmpDir, "specs") + + if err := os.MkdirAll(contractDir, 0755); err != nil { + t.Fatal(err) + } + + // Copy test contract + testContract := filepath.Join("testdata", "CONTRACT-AUTH.1.0.md") + contractData, err := os.ReadFile(testContract) + if err != nil { + t.Fatal(err) + } + + targetContract := filepath.Join(contractDir, "CONTRACT-AUTH.1.0.md") + if err := os.WriteFile(targetContract, contractData, 0644); err != nil { + t.Fatal(err) + } + + // Export with dry-run + opts := ExportOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + OutDir: "specs", + DryRun: true, + } + + if err := Export(opts); err != nil { + t.Fatalf("Export() error = %v", err) + } + + // Verify no files created + if _, err := os.Stat(outDir); err == nil { + entries, _ := os.ReadDir(outDir) + if len(entries) > 0 { + t.Error("Files were created in dry-run mode") + } + } +} diff --git a/cli/internal/spec/import.go b/cli/internal/spec/import.go new file mode 100644 index 0000000..1bc3e45 --- /dev/null +++ b/cli/internal/spec/import.go @@ -0,0 +1,186 @@ +package spec + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Import imports standard specs as REBAR contracts +func Import(opts ImportOptions) error { + if len(opts.SpecPaths) == 0 { + return fmt.Errorf("no spec files specified") + } + + fmt.Printf("Importing %d spec files to %s/\n\n", len(opts.SpecPaths), opts.ContractDir) + + created := 0 + updated := 0 + skipped := 0 + errors := 0 + + for _, specPath := range opts.SpecPaths { + abs, err := filepath.Abs(specPath) + if err != nil { + fmt.Printf(" ✗ %s: invalid path\n", specPath) + errors++ + continue + } + + rel, _ := filepath.Rel(opts.RepoRoot, abs) + fmt.Printf(" %s\n", rel) + + // Detect format from extension + format := detectFormat(abs) + if format == "" { + fmt.Printf(" ⚠ skipped: unknown format\n") + skipped++ + continue + } + + // Generate contract + contract, err := generateContract(abs, format, opts) + if err != nil { + fmt.Printf(" ✗ %v\n", err) + errors++ + continue + } + + // Write contract + contractPath := filepath.Join(opts.RepoRoot, opts.ContractDir, contract.filename) + exists := false + if _, err := os.Stat(contractPath); err == nil { + exists = true + if !opts.Force && !opts.DryRun { + fmt.Printf(" ⚠ skipped: contract exists (use --force to overwrite)\n") + skipped++ + continue + } + } + + if !opts.DryRun { + if err := os.MkdirAll(filepath.Dir(contractPath), 0755); err != nil { + fmt.Printf(" ✗ %v\n", err) + errors++ + continue + } + if err := os.WriteFile(contractPath, []byte(contract.content), 0644); err != nil { + fmt.Printf(" ✗ %v\n", err) + errors++ + continue + } + } + + if exists { + fmt.Printf(" ✓ updated %s\n", contract.filename) + updated++ + } else { + fmt.Printf(" ✓ created %s\n", contract.filename) + created++ + } + } + + fmt.Printf("\n✓ Imported %d specs (%d created, %d updated, %d skipped, %d errors)\n", created+updated, created, updated, skipped, errors) + if opts.DryRun { + fmt.Println("(dry-run: no files written)") + } + + return nil +} + +type generatedContract struct { + filename string + content string +} + +func detectFormat(path string) SpecFormat { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".feature": + return FormatGherkin + case ".mmd": + return FormatMermaid + case ".yaml", ".yml": + // Check if it's OpenAPI by reading content + data, err := os.ReadFile(path) + if err == nil && (strings.Contains(string(data), "openapi:") || strings.Contains(string(data), "paths:")) { + return FormatOpenAPI + } + case ".json": + // Check if it's JSON Schema + data, err := os.ReadFile(path) + if err == nil && strings.Contains(string(data), "$schema") { + return FormatSchema + } + } + return "" +} + +func generateContract(specPath string, format SpecFormat, opts ImportOptions) (*generatedContract, error) { + data, err := os.ReadFile(specPath) + if err != nil { + return nil, err + } + + content := string(data) + basename := filepath.Base(specPath) + nameWithoutExt := strings.TrimSuffix(basename, filepath.Ext(basename)) + + // Generate contract ID from filename + contractID := strings.ToUpper(strings.ReplaceAll(nameWithoutExt, "-", "_")) + contractID = strings.ReplaceAll(contractID, ".", "_") + + var markdown strings.Builder + + // Header + markdown.WriteString(fmt.Sprintf("# CONTRACT-%s.1.0\n\n", contractID)) + markdown.WriteString(fmt.Sprintf("> Auto-generated from %s on %s\n\n", basename, time.Now().Format("2006-01-02"))) + + // Format-specific sections + switch format { + case FormatGherkin: + markdown.WriteString("## Purpose\n\n") + markdown.WriteString(fmt.Sprintf("Behavior specification imported from %s.\n\n", basename)) + markdown.WriteString("## Scenarios\n\n") + markdown.WriteString("```gherkin\n") + markdown.WriteString(content) + markdown.WriteString("\n```\n\n") + + case FormatMermaid: + markdown.WriteString("## Purpose\n\n") + markdown.WriteString(fmt.Sprintf("Architecture diagram imported from %s.\n\n", basename)) + markdown.WriteString("## Architecture\n\n") + markdown.WriteString("```mermaid\n") + markdown.WriteString(content) + markdown.WriteString("\n```\n\n") + + case FormatOpenAPI: + markdown.WriteString("## Purpose\n\n") + markdown.WriteString(fmt.Sprintf("API specification imported from %s.\n\n", basename)) + markdown.WriteString("## API\n\n") + markdown.WriteString("```yaml\n") + markdown.WriteString(content) + markdown.WriteString("\n```\n\n") + + case FormatSchema: + markdown.WriteString("## Purpose\n\n") + markdown.WriteString(fmt.Sprintf("Data schema imported from %s.\n\n", basename)) + markdown.WriteString("## Data Model\n\n") + markdown.WriteString("```json\n") + markdown.WriteString(content) + markdown.WriteString("\n```\n\n") + } + + // Footer + markdown.WriteString("## Implementation\n\n") + markdown.WriteString("_TODO: Document implementing files and components._\n\n") + markdown.WriteString("## Testing\n\n") + markdown.WriteString("_TODO: Document test strategy._\n") + + return &generatedContract{ + filename: fmt.Sprintf("CONTRACT-%s.1.0.md", contractID), + content: markdown.String(), + }, nil +} diff --git a/cli/internal/spec/import_test.go b/cli/internal/spec/import_test.go new file mode 100644 index 0000000..389d135 --- /dev/null +++ b/cli/internal/spec/import_test.go @@ -0,0 +1,199 @@ +package spec + +import ( + "os" + "path/filepath" + "testing" +) + +func TestImport(t *testing.T) { + tmpDir := t.TempDir() + contractDir := filepath.Join(tmpDir, "architecture") + specDir := filepath.Join(tmpDir, "specs") + + if err := os.MkdirAll(contractDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(specDir, 0755); err != nil { + t.Fatal(err) + } + + // Copy test spec + testSpec := filepath.Join("testdata", "sample.feature") + specData, err := os.ReadFile(testSpec) + if err != nil { + t.Fatal(err) + } + + targetSpec := filepath.Join(specDir, "sample.feature") + if err := os.WriteFile(targetSpec, specData, 0644); err != nil { + t.Fatal(err) + } + + // Import + opts := ImportOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + SpecPaths: []string{targetSpec}, + Force: true, + } + + if err := Import(opts); err != nil { + t.Fatalf("Import() error = %v", err) + } + + // Verify contract created + contractPath := filepath.Join(contractDir, "CONTRACT-SAMPLE.1.0.md") + if _, err := os.Stat(contractPath); os.IsNotExist(err) { + t.Errorf("Contract not created: %s", contractPath) + } + + // Verify contract content + contractData, err := os.ReadFile(contractPath) + if err != nil { + t.Fatalf("Reading contract: %v", err) + } + + content := string(contractData) + if !contains(content, "CONTRACT-SAMPLE.1.0") { + t.Error("Contract doesn't contain expected ID") + } + + if !contains(content, "Feature: User Registration") { + t.Error("Contract doesn't contain Gherkin content") + } + + if !contains(content, "```gherkin") { + t.Error("Contract doesn't have Gherkin code block") + } +} + +func TestImportForceOverwrite(t *testing.T) { + tmpDir := t.TempDir() + contractDir := filepath.Join(tmpDir, "architecture") + specDir := filepath.Join(tmpDir, "specs") + + if err := os.MkdirAll(contractDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(specDir, 0755); err != nil { + t.Fatal(err) + } + + // Create existing contract + existingContract := filepath.Join(contractDir, "CONTRACT-SAMPLE.1.0.md") + if err := os.WriteFile(existingContract, []byte("# Existing\n"), 0644); err != nil { + t.Fatal(err) + } + + // Copy test spec + testSpec := filepath.Join("testdata", "sample.feature") + specData, err := os.ReadFile(testSpec) + if err != nil { + t.Fatal(err) + } + + targetSpec := filepath.Join(specDir, "sample.feature") + if err := os.WriteFile(targetSpec, specData, 0644); err != nil { + t.Fatal(err) + } + + // Import with force (should overwrite) + opts := ImportOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + SpecPaths: []string{targetSpec}, + Force: true, + } + + if err := Import(opts); err != nil { + t.Fatalf("Import() error = %v", err) + } + + // Verify contract overwritten + contractData, err := os.ReadFile(existingContract) + if err != nil { + t.Fatal(err) + } + + if contains(string(contractData), "# Existing") { + t.Error("Contract was not overwritten") + } + + if !contains(string(contractData), "Feature: User Registration") { + t.Error("Contract doesn't contain new content") + } +} + +func TestImportDryRun(t *testing.T) { + tmpDir := t.TempDir() + contractDir := filepath.Join(tmpDir, "architecture") + specDir := filepath.Join(tmpDir, "specs") + + if err := os.MkdirAll(contractDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(specDir, 0755); err != nil { + t.Fatal(err) + } + + // Copy test spec + testSpec := filepath.Join("testdata", "sample.feature") + specData, err := os.ReadFile(testSpec) + if err != nil { + t.Fatal(err) + } + + targetSpec := filepath.Join(specDir, "sample.feature") + if err := os.WriteFile(targetSpec, specData, 0644); err != nil { + t.Fatal(err) + } + + // Import with dry-run + opts := ImportOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + SpecPaths: []string{targetSpec}, + DryRun: true, + } + + if err := Import(opts); err != nil { + t.Fatalf("Import() error = %v", err) + } + + // Verify no contract created + contractPath := filepath.Join(contractDir, "CONTRACT-SAMPLE.1.0.md") + if _, err := os.Stat(contractPath); err == nil { + t.Error("Contract was created in dry-run mode") + } +} + +func TestDetectFormat(t *testing.T) { + tests := []struct { + filename string + want SpecFormat + }{ + {"test.feature", FormatGherkin}, + {"test.mmd", FormatMermaid}, + {"test.yaml", ""}, // Need content check for OpenAPI + {"test.json", ""}, // Need content check for Schema + {"test.txt", ""}, + {"test", ""}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + // Create temp file + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, tt.filename) + if err := os.WriteFile(path, []byte{}, 0644); err != nil { + t.Fatal(err) + } + + got := detectFormat(path) + if got != tt.want { + t.Errorf("detectFormat(%q) = %v, want %v", tt.filename, got, tt.want) + } + }) + } +} diff --git a/cli/internal/spec/manifest.go b/cli/internal/spec/manifest.go new file mode 100644 index 0000000..e5214a5 --- /dev/null +++ b/cli/internal/spec/manifest.go @@ -0,0 +1,142 @@ +package spec + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +const ManifestFile = ".spec-manifest.json" + +// LoadManifest reads the spec manifest from the output directory +func LoadManifest(outDir string) (*Manifest, error) { + path := filepath.Join(outDir, ManifestFile) + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + // No manifest yet, return empty + return &Manifest{ + Version: "1.0", + LastSync: time.Now(), + Mappings: []SpecMapping{}, + }, nil + } + if err != nil { + return nil, fmt.Errorf("reading manifest: %w", err) + } + + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing manifest: %w", err) + } + + return &m, nil +} + +// SaveManifest writes the spec manifest to the output directory +func SaveManifest(outDir string, m *Manifest) error { + m.LastSync = time.Now() + + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("marshaling manifest: %w", err) + } + + if err := os.MkdirAll(outDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + path := filepath.Join(outDir, ManifestFile) + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + + return nil +} + +// FindMapping finds the mapping for a given contract path +func (m *Manifest) FindMapping(contractPath string) *SpecMapping { + for i := range m.Mappings { + if m.Mappings[i].Contract == contractPath { + return &m.Mappings[i] + } + } + return nil +} + +// UpdateMapping updates or adds a mapping in the manifest +func (m *Manifest) UpdateMapping(mapping SpecMapping) { + for i := range m.Mappings { + if m.Mappings[i].Contract == mapping.Contract { + m.Mappings[i] = mapping + return + } + } + m.Mappings = append(m.Mappings, mapping) +} + +// RemoveMapping removes a mapping from the manifest +func (m *Manifest) RemoveMapping(contractPath string) { + for i := range m.Mappings { + if m.Mappings[i].Contract == contractPath { + m.Mappings = append(m.Mappings[:i], m.Mappings[i+1:]...) + return + } + } +} + +// ComputeChecksum calculates SHA256 checksum of a file +func ComputeChecksum(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +// HasContractChanged checks if contract checksum differs from manifest +func HasContractChanged(contractPath string, mapping *SpecMapping) (bool, error) { + if mapping == nil { + return true, nil // No mapping = new contract + } + + current, err := ComputeChecksum(contractPath) + if err != nil { + return false, err + } + + return current != mapping.ContractChecksum, nil +} + +// HasSpecChanged checks if any exported spec checksum differs from manifest +func HasSpecChanged(repoRoot string, mapping *SpecMapping) (bool, []string, error) { + changed := []string{} + + for _, exp := range mapping.Exports { + absPath := filepath.Join(repoRoot, exp.Path) + current, err := ComputeChecksum(absPath) + if os.IsNotExist(err) { + changed = append(changed, exp.Path) + continue + } + if err != nil { + return false, nil, err + } + + if current != exp.Checksum { + changed = append(changed, exp.Path) + } + } + + return len(changed) > 0, changed, nil +} diff --git a/cli/internal/spec/manifest_test.go b/cli/internal/spec/manifest_test.go new file mode 100644 index 0000000..68fa392 --- /dev/null +++ b/cli/internal/spec/manifest_test.go @@ -0,0 +1,311 @@ +package spec + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadManifest(t *testing.T) { + tmpDir := t.TempDir() + + // Load non-existent manifest (should create empty) + manifest, err := LoadManifest(tmpDir) + if err != nil { + t.Fatalf("LoadManifest() error = %v", err) + } + + if manifest.Version != "1.0" { + t.Errorf("Version = %q, want %q", manifest.Version, "1.0") + } + + if len(manifest.Mappings) != 0 { + t.Errorf("New manifest has %d mappings, want 0", len(manifest.Mappings)) + } +} + +func TestSaveManifest(t *testing.T) { + tmpDir := t.TempDir() + + manifest := &Manifest{ + Version: "1.0", + Mappings: []SpecMapping{ + { + Contract: "architecture/CONTRACT-TEST.1.0.md", + ContractChecksum: "abc123", + Exports: []ExportedSpec{ + {Type: "gherkin", Path: "specs/gherkin/test.feature", Checksum: "def456"}, + }, + }, + }, + } + + if err := SaveManifest(tmpDir, manifest); err != nil { + t.Fatalf("SaveManifest() error = %v", err) + } + + // Verify file created + manifestPath := filepath.Join(tmpDir, ManifestFile) + if _, err := os.Stat(manifestPath); os.IsNotExist(err) { + t.Error("Manifest file not created") + } + + // Load and verify + loaded, err := LoadManifest(tmpDir) + if err != nil { + t.Fatalf("LoadManifest() error = %v", err) + } + + if len(loaded.Mappings) != 1 { + t.Fatalf("Loaded manifest has %d mappings, want 1", len(loaded.Mappings)) + } + + mapping := loaded.Mappings[0] + if mapping.Contract != "architecture/CONTRACT-TEST.1.0.md" { + t.Errorf("Contract = %q, want %q", mapping.Contract, "architecture/CONTRACT-TEST.1.0.md") + } + + if mapping.ContractChecksum != "abc123" { + t.Errorf("ContractChecksum = %q, want %q", mapping.ContractChecksum, "abc123") + } + + if len(mapping.Exports) != 1 { + t.Fatalf("Exports count = %d, want 1", len(mapping.Exports)) + } +} + +func TestFindMapping(t *testing.T) { + manifest := &Manifest{ + Version: "1.0", + Mappings: []SpecMapping{ + {Contract: "architecture/CONTRACT-A.md"}, + {Contract: "architecture/CONTRACT-B.md"}, + }, + } + + tests := []struct { + path string + want bool + }{ + {"architecture/CONTRACT-A.md", true}, + {"architecture/CONTRACT-B.md", true}, + {"architecture/CONTRACT-C.md", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + mapping := manifest.FindMapping(tt.path) + found := mapping != nil + if found != tt.want { + t.Errorf("FindMapping(%q) found = %v, want %v", tt.path, found, tt.want) + } + }) + } +} + +func TestUpdateMapping(t *testing.T) { + manifest := &Manifest{ + Version: "1.0", + Mappings: []SpecMapping{ + {Contract: "architecture/CONTRACT-A.md", ContractChecksum: "old"}, + }, + } + + // Update existing + updated := SpecMapping{ + Contract: "architecture/CONTRACT-A.md", + ContractChecksum: "new", + } + + manifest.UpdateMapping(updated) + + if len(manifest.Mappings) != 1 { + t.Errorf("Mappings count = %d, want 1", len(manifest.Mappings)) + } + + if manifest.Mappings[0].ContractChecksum != "new" { + t.Error("Mapping not updated") + } + + // Add new + newMapping := SpecMapping{ + Contract: "architecture/CONTRACT-B.md", + ContractChecksum: "xyz", + } + + manifest.UpdateMapping(newMapping) + + if len(manifest.Mappings) != 2 { + t.Errorf("Mappings count = %d, want 2", len(manifest.Mappings)) + } +} + +func TestRemoveMapping(t *testing.T) { + manifest := &Manifest{ + Version: "1.0", + Mappings: []SpecMapping{ + {Contract: "architecture/CONTRACT-A.md"}, + {Contract: "architecture/CONTRACT-B.md"}, + }, + } + + manifest.RemoveMapping("architecture/CONTRACT-A.md") + + if len(manifest.Mappings) != 1 { + t.Errorf("Mappings count = %d, want 1", len(manifest.Mappings)) + } + + if manifest.Mappings[0].Contract == "architecture/CONTRACT-A.md" { + t.Error("Wrong mapping removed") + } + + // Remove non-existent (should not error) + manifest.RemoveMapping("architecture/CONTRACT-Z.md") + + if len(manifest.Mappings) != 1 { + t.Error("Mappings count changed after removing non-existent") + } +} + +func TestComputeChecksum(t *testing.T) { + tmpDir := t.TempDir() + + // Create test file + testFile := filepath.Join(tmpDir, "test.txt") + content := "Hello, World!" + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + // Compute checksum + checksum1, err := ComputeChecksum(testFile) + if err != nil { + t.Fatalf("ComputeChecksum() error = %v", err) + } + + if checksum1 == "" { + t.Error("Checksum is empty") + } + + // Same content should produce same checksum + checksum2, err := ComputeChecksum(testFile) + if err != nil { + t.Fatal(err) + } + + if checksum1 != checksum2 { + t.Error("Checksums don't match for same content") + } + + // Different content should produce different checksum + if err := os.WriteFile(testFile, []byte("Different"), 0644); err != nil { + t.Fatal(err) + } + + checksum3, err := ComputeChecksum(testFile) + if err != nil { + t.Fatal(err) + } + + if checksum1 == checksum3 { + t.Error("Checksums match for different content") + } +} + +func TestHasContractChanged(t *testing.T) { + tmpDir := t.TempDir() + + // Create test file + contractPath := filepath.Join(tmpDir, "contract.md") + if err := os.WriteFile(contractPath, []byte("Original"), 0644); err != nil { + t.Fatal(err) + } + + originalChecksum, _ := ComputeChecksum(contractPath) + + mapping := &SpecMapping{ + Contract: "contract.md", + ContractChecksum: originalChecksum, + } + + // No change + changed, err := HasContractChanged(contractPath, mapping) + if err != nil { + t.Fatal(err) + } + if changed { + t.Error("Contract reported as changed when unchanged") + } + + // Modify file + time.Sleep(10 * time.Millisecond) + if err := os.WriteFile(contractPath, []byte("Modified"), 0644); err != nil { + t.Fatal(err) + } + + changed, err = HasContractChanged(contractPath, mapping) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Error("Contract not reported as changed after modification") + } + + // No mapping (new contract) + changed, err = HasContractChanged(contractPath, nil) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Error("New contract (nil mapping) not reported as changed") + } +} + +func TestHasSpecChanged(t *testing.T) { + tmpDir := t.TempDir() + + // Create test spec + specPath := filepath.Join(tmpDir, "test.feature") + if err := os.WriteFile(specPath, []byte("Original"), 0644); err != nil { + t.Fatal(err) + } + + originalChecksum, _ := ComputeChecksum(specPath) + + mapping := &SpecMapping{ + Exports: []ExportedSpec{ + {Type: "gherkin", Path: "test.feature", Checksum: originalChecksum}, + }, + } + + // No change + changed, changedPaths, err := HasSpecChanged(tmpDir, mapping) + if err != nil { + t.Fatal(err) + } + if changed { + t.Error("Spec reported as changed when unchanged") + } + if len(changedPaths) > 0 { + t.Error("ChangedPaths not empty for unchanged spec") + } + + // Modify spec + time.Sleep(10 * time.Millisecond) + if err := os.WriteFile(specPath, []byte("Modified"), 0644); err != nil { + t.Fatal(err) + } + + changed, changedPaths, err = HasSpecChanged(tmpDir, mapping) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Error("Spec not reported as changed after modification") + } + if len(changedPaths) != 1 { + t.Errorf("ChangedPaths count = %d, want 1", len(changedPaths)) + } +} diff --git a/cli/internal/spec/parser.go b/cli/internal/spec/parser.go new file mode 100644 index 0000000..82b0485 --- /dev/null +++ b/cli/internal/spec/parser.go @@ -0,0 +1,335 @@ +package spec + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +var ( + contractIDPattern = regexp.MustCompile(`^#\s+CONTRACT[-:]([A-Z0-9-]+\.[0-9.]+)`) + sectionPattern = regexp.MustCompile(`^##\s+(.+)`) + codeBlockPattern = regexp.MustCompile("^```(\\w+)?") +) + +// ParseContract reads and parses a contract markdown file +func ParseContract(path string) (*Contract, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + content := string(data) + lines := strings.Split(content, "\n") + + c := &Contract{ + Path: path, + Content: content, + Sections: make(map[string]string), + CodeBlocks: []CodeBlock{}, + } + + // Extract contract ID and name from first heading + for _, line := range lines { + if match := contractIDPattern.FindStringSubmatch(line); match != nil { + c.ID = match[1] + parts := strings.SplitN(strings.TrimPrefix(line, "#"), "-", 2) + if len(parts) > 1 { + c.Name = strings.TrimSpace(parts[1]) + } + break + } + } + + // Parse sections and code blocks + var currentSection string + var sectionContent strings.Builder + var inCodeBlock bool + var codeBlockLang string + var codeBlockContent strings.Builder + var codeBlockLine int + + for i, line := range lines { + // Section header + if match := sectionPattern.FindStringSubmatch(line); match != nil && !inCodeBlock { + // Save previous section + if currentSection != "" { + c.Sections[currentSection] = strings.TrimSpace(sectionContent.String()) + sectionContent.Reset() + } + currentSection = strings.TrimSpace(match[1]) + continue + } + + // Code block start/end + if match := codeBlockPattern.FindStringSubmatch(line); match != nil { + if !inCodeBlock { + // Start code block + inCodeBlock = true + codeBlockLang = "" + if len(match) > 1 { + codeBlockLang = match[1] + } + codeBlockLine = i + 1 + codeBlockContent.Reset() + } else { + // End code block + inCodeBlock = false + c.CodeBlocks = append(c.CodeBlocks, CodeBlock{ + Language: codeBlockLang, + Content: strings.TrimSpace(codeBlockContent.String()), + Line: codeBlockLine, + }) + } + continue + } + + // Accumulate content + if inCodeBlock { + codeBlockContent.WriteString(line) + codeBlockContent.WriteString("\n") + } else if currentSection != "" { + sectionContent.WriteString(line) + sectionContent.WriteString("\n") + } + } + + // Save final section + if currentSection != "" { + c.Sections[currentSection] = strings.TrimSpace(sectionContent.String()) + } + + return c, nil +} + +// FindContracts scans for contract files matching patterns +func FindContracts(contractDir string, patterns []string) ([]string, error) { + if len(patterns) == 0 { + patterns = []string{"CONTRACT-*.md"} + } + + var contracts []string + seen := make(map[string]bool) + + for _, pattern := range patterns { + // If pattern is absolute or contains path separators, use it directly + searchPattern := pattern + if !filepath.IsAbs(pattern) && !strings.Contains(pattern, string(filepath.Separator)) { + searchPattern = filepath.Join(contractDir, pattern) + } + + matches, err := filepath.Glob(searchPattern) + if err != nil { + return nil, fmt.Errorf("glob %s: %w", searchPattern, err) + } + + for _, match := range matches { + abs, err := filepath.Abs(match) + if err != nil { + continue + } + if !seen[abs] { + contracts = append(contracts, abs) + seen[abs] = true + } + } + } + + return contracts, nil +} + +// ExtractMermaidDiagrams extracts all mermaid code blocks from contract +func (c *Contract) ExtractMermaidDiagrams() []string { + var diagrams []string + for _, block := range c.CodeBlocks { + if block.Language == "mermaid" { + diagrams = append(diagrams, block.Content) + } + } + return diagrams +} + +// ExtractGherkinScenarios extracts Gherkin from "Scenarios" or "Behavior" sections +func (c *Contract) ExtractGherkinScenarios() string { + // First pass: look for exact or high-priority matches + for name, content := range c.Sections { + lower := strings.ToLower(name) + if lower == "scenarios" || lower == "behavior" || lower == "test scenarios" { + // Check if it's already Gherkin-formatted + if strings.Contains(content, "Feature:") || strings.Contains(content, "Scenario:") { + return content + } + // Convert bullet points to Gherkin + return convertToGherkin(c.Name, content) + } + } + + // Second pass: partial matches (but not "Testing") + for name, content := range c.Sections { + lower := strings.ToLower(name) + if (strings.Contains(lower, "scenario") || strings.Contains(lower, "behavior")) && lower != "testing" { + // Check if it's already Gherkin-formatted + if strings.Contains(content, "Feature:") || strings.Contains(content, "Scenario:") { + return content + } + // Convert bullet points to Gherkin + return convertToGherkin(c.Name, content) + } + } + + // Check code blocks for gherkin + for _, block := range c.CodeBlocks { + if block.Language == "gherkin" || block.Language == "feature" { + return block.Content + } + } + + return "" +} + +// ExtractOpenAPISpec extracts OpenAPI from "API" section or openapi/yaml code blocks +func (c *Contract) ExtractOpenAPISpec() string { + // Check for API section + for name, content := range c.Sections { + lower := strings.ToLower(name) + if strings.Contains(lower, "api") || strings.Contains(lower, "endpoint") { + // Look for code block within section content + if strings.Contains(content, "```") { + return extractCodeFromMarkdown(content, "yaml", "yml", "openapi") + } + } + } + + // Check code blocks + for _, block := range c.CodeBlocks { + lang := strings.ToLower(block.Language) + if lang == "yaml" || lang == "openapi" || lang == "yml" { + if strings.Contains(block.Content, "paths:") || strings.Contains(block.Content, "openapi:") { + return block.Content + } + } + } + + return "" +} + +// ExtractJSONSchema extracts JSON schemas from "Data" or "Schema" sections +func (c *Contract) ExtractJSONSchema() string { + for name, content := range c.Sections { + lower := strings.ToLower(name) + if strings.Contains(lower, "data") || strings.Contains(lower, "schema") || strings.Contains(lower, "model") { + extracted := extractCodeFromMarkdown(content, "json", "jsonschema") + if extracted != "" && strings.Contains(extracted, "$schema") { + return extracted + } + } + } + + for _, block := range c.CodeBlocks { + lang := strings.ToLower(block.Language) + if (lang == "json" || lang == "jsonschema") && strings.Contains(block.Content, "$schema") { + return block.Content + } + } + + return "" +} + +// convertToGherkin converts bullet-point scenarios to Gherkin format +func convertToGherkin(featureName, content string) string { + var gherkin strings.Builder + gherkin.WriteString(fmt.Sprintf("Feature: %s\n\n", featureName)) + + scanner := bufio.NewScanner(strings.NewReader(content)) + inScenario := false + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + lower := strings.ToLower(line) + + // Check if it's already a Gherkin step (Given/When/Then/And) + if strings.HasPrefix(lower, "given ") || strings.HasPrefix(lower, "when ") || + strings.HasPrefix(lower, "then ") || strings.HasPrefix(lower, "and ") || + strings.HasPrefix(lower, "but ") { + if !inScenario { + // Need a scenario wrapper + gherkin.WriteString(" Scenario: Default scenario\n") + inScenario = true + } + gherkin.WriteString(fmt.Sprintf(" %s\n", line)) + continue + } + + // Bullet points become Given/When/Then + if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") { + if !inScenario { + gherkin.WriteString(" Scenario: Default scenario\n") + inScenario = true + } + step := strings.TrimPrefix(strings.TrimPrefix(line, "- "), "* ") + + // Heuristic mapping + lowerStep := strings.ToLower(step) + if strings.Contains(lowerStep, "should") || strings.Contains(lowerStep, "must") { + gherkin.WriteString(fmt.Sprintf(" Then %s\n", step)) + } else if strings.Contains(lowerStep, "when") { + gherkin.WriteString(fmt.Sprintf(" When %s\n", step)) + } else { + gherkin.WriteString(fmt.Sprintf(" Given %s\n", step)) + } + } else if !strings.HasPrefix(line, "#") { + // New scenario title + gherkin.WriteString(fmt.Sprintf(" Scenario: %s\n", line)) + inScenario = true + } + } + + return gherkin.String() +} + +// extractCodeFromMarkdown extracts code blocks of specific languages from markdown +func extractCodeFromMarkdown(markdown string, languages ...string) string { + lines := strings.Split(markdown, "\n") + var inBlock bool + var blockLang string + var content strings.Builder + + langMap := make(map[string]bool) + for _, lang := range languages { + langMap[strings.ToLower(lang)] = true + } + + for _, line := range lines { + if match := codeBlockPattern.FindStringSubmatch(line); match != nil { + if !inBlock { + blockLang = "" + if len(match) > 1 { + blockLang = strings.ToLower(match[1]) + } + if langMap[blockLang] { + inBlock = true + } + } else { + inBlock = false + if content.Len() > 0 { + return content.String() + } + } + continue + } + + if inBlock { + content.WriteString(line) + content.WriteString("\n") + } + } + + return content.String() +} diff --git a/cli/internal/spec/parser_test.go b/cli/internal/spec/parser_test.go new file mode 100644 index 0000000..326e3f3 --- /dev/null +++ b/cli/internal/spec/parser_test.go @@ -0,0 +1,139 @@ +package spec + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestParseContract(t *testing.T) { + contractPath := filepath.Join("testdata", "CONTRACT-AUTH.1.0.md") + + contract, err := ParseContract(contractPath) + if err != nil { + t.Fatalf("ParseContract() error = %v", err) + } + + if contract.ID != "AUTH.1.0" { + t.Errorf("ID = %q, want %q", contract.ID, "AUTH.1.0") + } + + if contract.Path != contractPath { + t.Errorf("Path = %q, want %q", contract.Path, contractPath) + } + + // Check sections + expectedSections := []string{"Purpose", "Scenarios", "Architecture", "API", "Data Model", "Implementation", "Testing"} + for _, section := range expectedSections { + if _, ok := contract.Sections[section]; !ok { + t.Errorf("Missing section: %s", section) + } + } + + // Check code blocks + if len(contract.CodeBlocks) < 3 { + t.Errorf("CodeBlocks count = %d, want at least 3 (mermaid, yaml, json)", len(contract.CodeBlocks)) + } + + foundMermaid := false + foundYAML := false + foundJSON := false + for _, block := range contract.CodeBlocks { + switch block.Language { + case "mermaid": + foundMermaid = true + case "yaml": + foundYAML = true + case "json": + foundJSON = true + } + } + + if !foundMermaid { + t.Error("No mermaid code block found") + } + if !foundYAML { + t.Error("No yaml code block found") + } + if !foundJSON { + t.Error("No json code block found") + } +} + +func TestExtractMermaidDiagrams(t *testing.T) { + contractPath := filepath.Join("testdata", "CONTRACT-AUTH.1.0.md") + contract, err := ParseContract(contractPath) + if err != nil { + t.Fatalf("ParseContract() error = %v", err) + } + + diagrams := contract.ExtractMermaidDiagrams() + if len(diagrams) == 0 { + t.Error("ExtractMermaidDiagrams() returned no diagrams") + } + + if len(diagrams) > 0 && !contains(diagrams[0], "graph TD") { + t.Error("Mermaid diagram doesn't contain expected content") + } +} + +func TestExtractGherkinScenarios(t *testing.T) { + contractPath := filepath.Join("testdata", "CONTRACT-AUTH.1.0.md") + contract, err := ParseContract(contractPath) + if err != nil { + t.Fatalf("ParseContract() error = %v", err) + } + + gherkin := contract.ExtractGherkinScenarios() + if gherkin == "" { + t.Error("ExtractGherkinScenarios() returned empty string") + return + } + + if !contains(gherkin, "Feature:") { + t.Errorf("Gherkin doesn't contain Feature keyword. Got:\n%s", gherkin) + return + } + + if !contains(gherkin, "Given") { + t.Errorf("Gherkin doesn't contain Given step. Got:\n%s", gherkin) + } +} + +func TestExtractOpenAPISpec(t *testing.T) { + contractPath := filepath.Join("testdata", "CONTRACT-AUTH.1.0.md") + contract, err := ParseContract(contractPath) + if err != nil { + t.Fatalf("ParseContract() error = %v", err) + } + + openapi := contract.ExtractOpenAPISpec() + if openapi == "" { + t.Error("ExtractOpenAPISpec() returned empty string") + } + + if !contains(openapi, "openapi:") && !contains(openapi, "paths:") { + t.Error("OpenAPI spec doesn't contain expected keywords") + } +} + +func TestExtractJSONSchema(t *testing.T) { + contractPath := filepath.Join("testdata", "CONTRACT-AUTH.1.0.md") + contract, err := ParseContract(contractPath) + if err != nil { + t.Fatalf("ParseContract() error = %v", err) + } + + schema := contract.ExtractJSONSchema() + if schema == "" { + t.Error("ExtractJSONSchema() returned empty string") + } + + if !contains(schema, "$schema") { + t.Error("JSON schema doesn't contain $schema keyword") + } +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/cli/internal/spec/plugin.go b/cli/internal/spec/plugin.go new file mode 100644 index 0000000..b74ca50 --- /dev/null +++ b/cli/internal/spec/plugin.go @@ -0,0 +1,259 @@ +package spec + +import ( + "fmt" + "path/filepath" + "strings" + "time" +) + +// FormatPlugin defines the interface for spec format handlers +type FormatPlugin interface { + // Name returns the format identifier (e.g., "gherkin", "mermaid") + Name() string + + // Extension returns the file extension (e.g., ".feature", ".mmd") + Extension() string + + // SubDir returns the subdirectory name (e.g., "gherkin", "mermaid") + SubDir() string + + // Extract extracts spec content from a contract + Extract(contract *Contract) string + + // Generate generates contract markdown from spec content + Generate(spec string, sourceFile string) string + + // Detect determines if a file is this format + Detect(path string, content []byte) bool +} + +// PluginRegistry manages available format plugins +type PluginRegistry struct { + plugins map[string]FormatPlugin +} + +// NewPluginRegistry creates a registry with default plugins +func NewPluginRegistry() *PluginRegistry { + return &PluginRegistry{ + plugins: make(map[string]FormatPlugin), + } +} + +// Register adds a plugin to the registry +func (r *PluginRegistry) Register(plugin FormatPlugin) { + r.plugins[plugin.Name()] = plugin +} + +// Get retrieves a plugin by name +func (r *PluginRegistry) Get(name string) FormatPlugin { + return r.plugins[name] +} + +// All returns all registered plugins +func (r *PluginRegistry) All() []FormatPlugin { + plugins := make([]FormatPlugin, 0, len(r.plugins)) + for _, p := range r.plugins { + plugins = append(plugins, p) + } + return plugins +} + +// DetectFormat auto-detects format from file path and content +func (r *PluginRegistry) DetectFormat(path string, content []byte) FormatPlugin { + for _, plugin := range r.plugins { + if plugin.Detect(path, content) { + return plugin + } + } + return nil +} + +// DefaultRegistry returns a registry with all standard plugins +func DefaultRegistry() *PluginRegistry { + r := NewPluginRegistry() + r.Register(&gherkinPluginImpl{}) + r.Register(&mermaidPluginImpl{}) + r.Register(&openapiPluginImpl{}) + r.Register(&jsonschemaPluginImpl{}) + return r +} + +// Built-in plugin implementations +// (Defined here to avoid circular imports with plugins/ package) + +type gherkinPluginImpl struct{} + +func (p *gherkinPluginImpl) Name() string { return "gherkin" } +func (p *gherkinPluginImpl) Extension() string { return ".feature" } +func (p *gherkinPluginImpl) SubDir() string { return "gherkin" } + +func (p *gherkinPluginImpl) Extract(contract *Contract) string { + return contract.ExtractGherkinScenarios() +} + +func (p *gherkinPluginImpl) Generate(spec string, sourceFile string) string { + return generateContractFromSpec("gherkin", spec, sourceFile) +} + +func (p *gherkinPluginImpl) Detect(path string, content []byte) bool { + return detectGherkin(path, content) +} + +type mermaidPluginImpl struct{} + +func (p *mermaidPluginImpl) Name() string { return "mermaid" } +func (p *mermaidPluginImpl) Extension() string { return ".mmd" } +func (p *mermaidPluginImpl) SubDir() string { return "mermaid" } + +func (p *mermaidPluginImpl) Extract(contract *Contract) string { + diagrams := contract.ExtractMermaidDiagrams() + if len(diagrams) == 0 { + return "" + } + // Combine multiple diagrams + var combined string + for i, d := range diagrams { + if i > 0 { + combined += "\n\n%% Diagram " + string(rune(i+1)) + "\n\n" + } + combined += d + } + return combined +} + +func (p *mermaidPluginImpl) Generate(spec string, sourceFile string) string { + return generateContractFromSpec("mermaid", spec, sourceFile) +} + +func (p *mermaidPluginImpl) Detect(path string, content []byte) bool { + return detectMermaid(path, content) +} + +type openapiPluginImpl struct{} + +func (p *openapiPluginImpl) Name() string { return "openapi" } +func (p *openapiPluginImpl) Extension() string { return ".yaml" } +func (p *openapiPluginImpl) SubDir() string { return "openapi" } + +func (p *openapiPluginImpl) Extract(contract *Contract) string { + return contract.ExtractOpenAPISpec() +} + +func (p *openapiPluginImpl) Generate(spec string, sourceFile string) string { + return generateContractFromSpec("openapi", spec, sourceFile) +} + +func (p *openapiPluginImpl) Detect(path string, content []byte) bool { + return detectOpenAPI(path, content) +} + +type jsonschemaPluginImpl struct{} + +func (p *jsonschemaPluginImpl) Name() string { return "schema" } +func (p *jsonschemaPluginImpl) Extension() string { return ".json" } +func (p *jsonschemaPluginImpl) SubDir() string { return "schemas" } + +func (p *jsonschemaPluginImpl) Extract(contract *Contract) string { + return contract.ExtractJSONSchema() +} + +func (p *jsonschemaPluginImpl) Generate(spec string, sourceFile string) string { + return generateContractFromSpec("schema", spec, sourceFile) +} + +func (p *jsonschemaPluginImpl) Detect(path string, content []byte) bool { + return detectJSONSchema(path, content) +} + +// Helper functions for built-in plugins + +func generateContractFromSpec(format string, specContent string, sourceFile string) string { + basename := filepath.Base(sourceFile) + nameWithoutExt := strings.TrimSuffix(basename, filepath.Ext(basename)) + contractID := strings.ToUpper(strings.ReplaceAll(nameWithoutExt, "-", "_")) + contractID = strings.ReplaceAll(contractID, ".", "_") + + var md strings.Builder + md.WriteString(fmt.Sprintf("# CONTRACT-%s.1.0\n\n", contractID)) + md.WriteString(fmt.Sprintf("> Auto-generated from %s on %s\n\n", basename, time.Now().Format("2006-01-02"))) + + switch format { + case "gherkin": + md.WriteString("## Purpose\n\n") + md.WriteString(fmt.Sprintf("Behavior specification imported from %s.\n\n", basename)) + md.WriteString("## Scenarios\n\n") + md.WriteString("```gherkin\n") + md.WriteString(specContent) + md.WriteString("\n```\n\n") + + case "mermaid": + md.WriteString("## Purpose\n\n") + md.WriteString(fmt.Sprintf("Architecture diagram imported from %s.\n\n", basename)) + md.WriteString("## Architecture\n\n") + md.WriteString("```mermaid\n") + md.WriteString(specContent) + md.WriteString("\n```\n\n") + + case "openapi": + md.WriteString("## Purpose\n\n") + md.WriteString(fmt.Sprintf("API specification imported from %s.\n\n", basename)) + md.WriteString("## API\n\n") + md.WriteString("```yaml\n") + md.WriteString(specContent) + md.WriteString("\n```\n\n") + + case "schema": + md.WriteString("## Purpose\n\n") + md.WriteString(fmt.Sprintf("Data schema imported from %s.\n\n", basename)) + md.WriteString("## Data Model\n\n") + md.WriteString("```json\n") + md.WriteString(specContent) + md.WriteString("\n```\n\n") + } + + md.WriteString("## Implementation\n\n") + md.WriteString("_TODO: Document implementing files and components._\n\n") + md.WriteString("## Testing\n\n") + md.WriteString("_TODO: Document test strategy._\n") + + return md.String() +} + +func detectGherkin(path string, content []byte) bool { + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".feature" { + return true + } + text := string(content) + return strings.Contains(text, "Feature:") && (strings.Contains(text, "Scenario:") || strings.Contains(text, "Given ")) +} + +func detectMermaid(path string, content []byte) bool { + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".mmd" || ext == ".mermaid" { + return true + } + text := strings.ToLower(string(content)) + return strings.Contains(text, "graph ") || strings.Contains(text, "sequenceDiagram") || + strings.Contains(text, "classDiagram") || strings.Contains(text, "erDiagram") || + strings.Contains(text, "flowchart ") +} + +func detectOpenAPI(path string, content []byte) bool { + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".yaml" && ext != ".yml" { + return false + } + text := string(content) + return strings.Contains(text, "openapi:") || strings.Contains(text, "paths:") +} + +func detectJSONSchema(path string, content []byte) bool { + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".json" { + return false + } + text := string(content) + return strings.Contains(text, "$schema") +} diff --git a/cli/internal/spec/plugins/gherkin.go b/cli/internal/spec/plugins/gherkin.go new file mode 100644 index 0000000..2775977 --- /dev/null +++ b/cli/internal/spec/plugins/gherkin.go @@ -0,0 +1,137 @@ +package plugins + +import ( + "bufio" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/willackerly/rebar/cli/internal/spec" +) + +// GherkinPlugin handles Gherkin/Cucumber feature files +type GherkinPlugin struct{} + +func (p *GherkinPlugin) Name() string { return "gherkin" } +func (p *GherkinPlugin) Extension() string { return ".feature" } +func (p *GherkinPlugin) SubDir() string { return "gherkin" } + +func (p *GherkinPlugin) Extract(contract *spec.Contract) string { + // Look for Scenarios/Behavior section + for name, content := range contract.Sections { + lower := strings.ToLower(name) + if strings.Contains(lower, "scenario") || strings.Contains(lower, "behavior") || strings.Contains(lower, "test") { + // Check if already Gherkin-formatted + if strings.Contains(content, "Feature:") || strings.Contains(content, "Scenario:") { + return content + } + // Convert bullet points to Gherkin + return convertToGherkin(contract.Name, content) + } + } + + // Check code blocks for gherkin + for _, block := range contract.CodeBlocks { + if block.Language == "gherkin" || block.Language == "feature" { + return block.Content + } + } + + return "" +} + +func (p *GherkinPlugin) Generate(specContent string, sourceFile string) string { + basename := filepath.Base(sourceFile) + nameWithoutExt := strings.TrimSuffix(basename, filepath.Ext(basename)) + contractID := strings.ToUpper(strings.ReplaceAll(nameWithoutExt, "-", "_")) + contractID = strings.ReplaceAll(contractID, ".", "_") + + var md strings.Builder + md.WriteString(fmt.Sprintf("# CONTRACT-%s.1.0\n\n", contractID)) + md.WriteString(fmt.Sprintf("> Auto-generated from %s on %s\n\n", basename, time.Now().Format("2006-01-02"))) + md.WriteString("## Purpose\n\n") + md.WriteString(fmt.Sprintf("Behavior specification imported from %s.\n\n", basename)) + md.WriteString("## Scenarios\n\n") + md.WriteString("```gherkin\n") + md.WriteString(specContent) + md.WriteString("\n```\n\n") + md.WriteString("## Implementation\n\n") + md.WriteString("_TODO: Document implementing files and components._\n\n") + md.WriteString("## Testing\n\n") + md.WriteString("_TODO: Document test strategy._\n") + + return md.String() +} + +func (p *GherkinPlugin) Detect(path string, content []byte) bool { + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".feature" { + return true + } + + // Check content for Gherkin keywords + text := string(content) + return strings.Contains(text, "Feature:") && (strings.Contains(text, "Scenario:") || strings.Contains(text, "Given ")) +} + +// convertToGherkin converts bullet-point scenarios to proper Gherkin format +func convertToGherkin(featureName, content string) string { + var gherkin strings.Builder + gherkin.WriteString(fmt.Sprintf("Feature: %s\n\n", featureName)) + + scanner := bufio.NewScanner(strings.NewReader(content)) + inScenario := false + scenarioNum := 1 + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + if inScenario { + gherkin.WriteString("\n") + } + continue + } + + // Check if line is a Given/When/Then step + lower := strings.ToLower(line) + if strings.HasPrefix(lower, "given ") || strings.HasPrefix(lower, "when ") || + strings.HasPrefix(lower, "then ") || strings.HasPrefix(lower, "and ") || + strings.HasPrefix(lower, "but ") { + // Capitalize first letter + line = strings.ToUpper(string(line[0])) + line[1:] + gherkin.WriteString(fmt.Sprintf(" %s\n", line)) + inScenario = true + continue + } + + // Bullet points become steps + if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") { + step := strings.TrimPrefix(strings.TrimPrefix(line, "- "), "* ") + step = strings.TrimSpace(step) + + // Heuristic mapping based on keywords + lower := strings.ToLower(step) + if strings.Contains(lower, "should") || strings.Contains(lower, "must") || + strings.Contains(lower, "expect") || strings.Contains(lower, "verify") { + gherkin.WriteString(fmt.Sprintf(" Then %s\n", step)) + } else if strings.Contains(lower, "when") || strings.Contains(lower, "trigger") || + strings.Contains(lower, "click") || strings.Contains(lower, "submit") { + gherkin.WriteString(fmt.Sprintf(" When %s\n", step)) + } else { + gherkin.WriteString(fmt.Sprintf(" Given %s\n", step)) + } + inScenario = true + } else if !strings.HasPrefix(line, "#") { + // Non-bullet, non-comment line = scenario title + if inScenario { + gherkin.WriteString("\n") + } + gherkin.WriteString(fmt.Sprintf(" Scenario: %s\n", line)) + inScenario = true + scenarioNum++ + } + } + + return gherkin.String() +} diff --git a/cli/internal/spec/plugins/jsonschema.go b/cli/internal/spec/plugins/jsonschema.go new file mode 100644 index 0000000..5eaf70a --- /dev/null +++ b/cli/internal/spec/plugins/jsonschema.go @@ -0,0 +1,118 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/willackerly/rebar/cli/internal/spec" +) + +// JSONSchemaPlugin handles JSON Schema files +type JSONSchemaPlugin struct{} + +func (p *JSONSchemaPlugin) Name() string { return "schema" } +func (p *JSONSchemaPlugin) Extension() string { return ".json" } +func (p *JSONSchemaPlugin) SubDir() string { return "schemas" } + +func (p *JSONSchemaPlugin) Extract(contract *spec.Contract) string { + // Look for Data, Schema, or Model sections + for name, content := range contract.Sections { + lower := strings.ToLower(name) + if strings.Contains(lower, "data") || strings.Contains(lower, "schema") || + strings.Contains(lower, "model") || strings.Contains(lower, "type") { + extracted := extractCodeFromMarkdown(content, "json", "jsonschema") + if extracted != "" && strings.Contains(extracted, "$schema") { + return extracted + } + } + } + + // Check code blocks directly + for _, block := range contract.CodeBlocks { + lang := strings.ToLower(block.Language) + if (lang == "json" || lang == "jsonschema") && strings.Contains(block.Content, "$schema") { + return block.Content + } + } + + return "" +} + +func (p *JSONSchemaPlugin) Generate(specContent string, sourceFile string) string { + basename := filepath.Base(sourceFile) + nameWithoutExt := strings.TrimSuffix(basename, filepath.Ext(basename)) + contractID := strings.ToUpper(strings.ReplaceAll(nameWithoutExt, "-", "_")) + contractID = strings.ReplaceAll(contractID, ".", "_") + + var md strings.Builder + md.WriteString(fmt.Sprintf("# CONTRACT-%s.1.0\n\n", contractID)) + md.WriteString(fmt.Sprintf("> Auto-generated from %s on %s\n\n", basename, time.Now().Format("2006-01-02"))) + md.WriteString("## Purpose\n\n") + md.WriteString(fmt.Sprintf("Data schema imported from %s.\n\n", basename)) + md.WriteString("## Data Model\n\n") + md.WriteString("```json\n") + md.WriteString(specContent) + md.WriteString("\n```\n\n") + md.WriteString("## Implementation\n\n") + md.WriteString("_TODO: Document implementing files and components._\n\n") + md.WriteString("## Testing\n\n") + md.WriteString("_TODO: Document test strategy._\n") + + return md.String() +} + +func (p *JSONSchemaPlugin) Detect(path string, content []byte) bool { + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".json" { + return false + } + + text := string(content) + return strings.Contains(text, "$schema") +} + +// extractCodeFromMarkdown is duplicated here to avoid circular import +// TODO: Move to shared utility package +func extractCodeFromMarkdown(markdown string, languages ...string) string { + lines := strings.Split(markdown, "\n") + var inBlock bool + var blockLang string + var content strings.Builder + + langMap := make(map[string]bool) + for _, lang := range languages { + langMap[strings.ToLower(lang)] = true + } + + codeBlockPattern := "```" + + for _, line := range lines { + if strings.HasPrefix(line, codeBlockPattern) { + if !inBlock { + blockLang = "" + rest := strings.TrimPrefix(line, codeBlockPattern) + if rest != "" { + blockLang = strings.ToLower(strings.TrimSpace(rest)) + } + if langMap[blockLang] { + inBlock = true + } + } else { + inBlock = false + if content.Len() > 0 { + return content.String() + } + } + continue + } + + if inBlock { + content.WriteString(line) + content.WriteString("\n") + } + } + + return content.String() +} diff --git a/cli/internal/spec/plugins/mermaid.go b/cli/internal/spec/plugins/mermaid.go new file mode 100644 index 0000000..7b4f146 --- /dev/null +++ b/cli/internal/spec/plugins/mermaid.go @@ -0,0 +1,79 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/willackerly/rebar/cli/internal/spec" +) + +// MermaidPlugin handles Mermaid diagram files +type MermaidPlugin struct{} + +func (p *MermaidPlugin) Name() string { return "mermaid" } +func (p *MermaidPlugin) Extension() string { return ".mmd" } +func (p *MermaidPlugin) SubDir() string { return "mermaid" } + +func (p *MermaidPlugin) Extract(contract *spec.Contract) string { + diagrams := []string{} + for _, block := range contract.CodeBlocks { + if block.Language == "mermaid" { + diagrams = append(diagrams, block.Content) + } + } + + if len(diagrams) == 0 { + return "" + } + + // Combine multiple diagrams with comments + var combined strings.Builder + for i, diagram := range diagrams { + if i > 0 { + combined.WriteString("\n\n%% Diagram ") + combined.WriteString(fmt.Sprintf("%d", i+1)) + combined.WriteString("\n\n") + } + combined.WriteString(diagram) + } + + return combined.String() +} + +func (p *MermaidPlugin) Generate(specContent string, sourceFile string) string { + basename := filepath.Base(sourceFile) + nameWithoutExt := strings.TrimSuffix(basename, filepath.Ext(basename)) + contractID := strings.ToUpper(strings.ReplaceAll(nameWithoutExt, "-", "_")) + contractID = strings.ReplaceAll(contractID, ".", "_") + + var md strings.Builder + md.WriteString(fmt.Sprintf("# CONTRACT-%s.1.0\n\n", contractID)) + md.WriteString(fmt.Sprintf("> Auto-generated from %s on %s\n\n", basename, time.Now().Format("2006-01-02"))) + md.WriteString("## Purpose\n\n") + md.WriteString(fmt.Sprintf("Architecture diagram imported from %s.\n\n", basename)) + md.WriteString("## Architecture\n\n") + md.WriteString("```mermaid\n") + md.WriteString(specContent) + md.WriteString("\n```\n\n") + md.WriteString("## Implementation\n\n") + md.WriteString("_TODO: Document implementing files and components._\n\n") + md.WriteString("## Testing\n\n") + md.WriteString("_TODO: Document test strategy._\n") + + return md.String() +} + +func (p *MermaidPlugin) Detect(path string, content []byte) bool { + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".mmd" || ext == ".mermaid" { + return true + } + + // Check content for Mermaid diagram types + text := strings.ToLower(string(content)) + return strings.Contains(text, "graph ") || strings.Contains(text, "sequenceDiagram") || + strings.Contains(text, "classDiagram") || strings.Contains(text, "erDiagram") || + strings.Contains(text, "flowchart ") +} diff --git a/cli/internal/spec/plugins/openapi.go b/cli/internal/spec/plugins/openapi.go new file mode 100644 index 0000000..54dd81b --- /dev/null +++ b/cli/internal/spec/plugins/openapi.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/willackerly/rebar/cli/internal/spec" +) + +// OpenAPIPlugin handles OpenAPI specification files +type OpenAPIPlugin struct{} + +func (p *OpenAPIPlugin) Name() string { return "openapi" } +func (p *OpenAPIPlugin) Extension() string { return ".yaml" } +func (p *OpenAPIPlugin) SubDir() string { return "openapi" } + +func (p *OpenAPIPlugin) Extract(contract *spec.Contract) string { + // Check for API section + for name, content := range contract.Sections { + lower := strings.ToLower(name) + if strings.Contains(lower, "api") || strings.Contains(lower, "endpoint") { + // Extract code block from section + extracted := extractCodeFromMarkdown(content, "yaml", "yml", "openapi") + if extracted != "" { + return extracted + } + } + } + + // Check code blocks directly + for _, block := range contract.CodeBlocks { + lang := strings.ToLower(block.Language) + if lang == "yaml" || lang == "openapi" || lang == "yml" { + if strings.Contains(block.Content, "paths:") || strings.Contains(block.Content, "openapi:") { + return block.Content + } + } + } + + return "" +} + +func (p *OpenAPIPlugin) Generate(specContent string, sourceFile string) string { + basename := filepath.Base(sourceFile) + nameWithoutExt := strings.TrimSuffix(basename, filepath.Ext(basename)) + contractID := strings.ToUpper(strings.ReplaceAll(nameWithoutExt, "-", "_")) + contractID = strings.ReplaceAll(contractID, ".", "_") + + var md strings.Builder + md.WriteString(fmt.Sprintf("# CONTRACT-%s.1.0\n\n", contractID)) + md.WriteString(fmt.Sprintf("> Auto-generated from %s on %s\n\n", basename, time.Now().Format("2006-01-02"))) + md.WriteString("## Purpose\n\n") + md.WriteString(fmt.Sprintf("API specification imported from %s.\n\n", basename)) + md.WriteString("## API\n\n") + md.WriteString("```yaml\n") + md.WriteString(specContent) + md.WriteString("\n```\n\n") + md.WriteString("## Implementation\n\n") + md.WriteString("_TODO: Document implementing files and components._\n\n") + md.WriteString("## Testing\n\n") + md.WriteString("_TODO: Document test strategy._\n") + + return md.String() +} + +func (p *OpenAPIPlugin) Detect(path string, content []byte) bool { + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".yaml" && ext != ".yml" { + return false + } + + text := string(content) + return strings.Contains(text, "openapi:") || strings.Contains(text, "paths:") +} + +// extractCodeFromMarkdown extracts code blocks of specific languages from markdown +func extractCodeFromMarkdown(markdown string, languages ...string) string { + lines := strings.Split(markdown, "\n") + var inBlock bool + var blockLang string + var content strings.Builder + + langMap := make(map[string]bool) + for _, lang := range languages { + langMap[strings.ToLower(lang)] = true + } + + codeBlockPattern := "```" + + for _, line := range lines { + if strings.HasPrefix(line, codeBlockPattern) { + if !inBlock { + blockLang = "" + rest := strings.TrimPrefix(line, codeBlockPattern) + if rest != "" { + blockLang = strings.ToLower(strings.TrimSpace(rest)) + } + if langMap[blockLang] { + inBlock = true + } + } else { + inBlock = false + if content.Len() > 0 { + return content.String() + } + } + continue + } + + if inBlock { + content.WriteString(line) + content.WriteString("\n") + } + } + + return content.String() +} diff --git a/cli/internal/spec/sync.go b/cli/internal/spec/sync.go new file mode 100644 index 0000000..aa8347f --- /dev/null +++ b/cli/internal/spec/sync.go @@ -0,0 +1,130 @@ +package spec + +import ( + "fmt" + "os" + "path/filepath" +) + +// Sync performs bidirectional synchronization between contracts and specs +func Sync(opts SyncOptions) error { + fmt.Println("Synchronizing contracts <-> specs") + + // Load manifest + manifest, err := LoadManifest(filepath.Join(opts.RepoRoot, opts.OutDir)) + if err != nil { + return fmt.Errorf("loading manifest: %w", err) + } + + if len(manifest.Mappings) == 0 { + fmt.Println("No mappings in manifest. Run 'rebar spec export' first.") + return nil + } + + contractsExported := 0 + specsImported := 0 + conflicts := []SyncConflict{} + + // Check each mapping for changes + for _, mapping := range manifest.Mappings { + contractPath := filepath.Join(opts.RepoRoot, mapping.Contract) + + // Check if contract exists + if _, err := os.Stat(contractPath); os.IsNotExist(err) { + fmt.Printf(" ⚠ Contract removed: %s\n", mapping.Contract) + continue + } + + // Check if contract changed + contractChanged, err := HasContractChanged(contractPath, &mapping) + if err != nil { + fmt.Printf(" ✗ %s: %v\n", mapping.Contract, err) + continue + } + + // Check if specs changed + specsChanged, changedPaths, err := HasSpecChanged(opts.RepoRoot, &mapping) + if err != nil { + fmt.Printf(" ✗ %s: %v\n", mapping.Contract, err) + continue + } + + // Handle different cases + if contractChanged && specsChanged { + // Conflict: both sides changed + conflicts = append(conflicts, SyncConflict{ + Contract: mapping.Contract, + Spec: fmt.Sprintf("%d specs", len(changedPaths)), + Reason: "both-modified", + }) + fmt.Printf(" ⚠ Conflict: %s (both contract and specs changed)\n", mapping.Contract) + for _, spec := range changedPaths { + fmt.Printf(" - %s\n", spec) + } + + } else if contractChanged { + // Contract changed, re-export + fmt.Printf(" → %s (contract updated, re-exporting)\n", mapping.Contract) + if !opts.DryRun { + // Re-export this contract + contractBasename := filepath.Base(mapping.Contract) + exportOpts := ExportOptions{ + RepoRoot: opts.RepoRoot, + ContractDir: opts.ContractDir, + OutDir: opts.OutDir, + Patterns: []string{contractBasename}, + Force: true, + } + if err := Export(exportOpts); err != nil { + fmt.Printf(" ✗ export failed: %v\n", err) + } else { + contractsExported++ + } + } + + } else if specsChanged { + // Specs changed, prompt to import + fmt.Printf(" ← %s (specs updated)\n", mapping.Contract) + for _, spec := range changedPaths { + fmt.Printf(" - %s\n", spec) + } + + if !opts.Force && !opts.DryRun { + fmt.Printf(" ⚠ Manual review required. Use 'rebar spec import %s' to update contract.\n", changedPaths[0]) + } else if !opts.DryRun { + // Auto-import + importOpts := ImportOptions{ + RepoRoot: opts.RepoRoot, + ContractDir: opts.ContractDir, + SpecPaths: changedPaths, + Force: true, + } + if err := Import(importOpts); err != nil { + fmt.Printf(" ✗ import failed: %v\n", err) + } else { + specsImported++ + } + } + + } else { + // No changes + fmt.Printf(" ✓ %s (in sync)\n", mapping.Contract) + } + } + + fmt.Printf("\n") + if len(conflicts) > 0 { + fmt.Printf("⚠ %d conflicts require manual resolution:\n", len(conflicts)) + for _, conflict := range conflicts { + fmt.Printf(" - %s\n", conflict.Contract) + } + } else { + fmt.Printf("✓ Sync complete (%d exported, %d imported)\n", contractsExported, specsImported) + } + + if opts.DryRun { + fmt.Println("(dry-run: no changes applied)") + } + + return nil +} diff --git a/cli/internal/spec/sync_test.go b/cli/internal/spec/sync_test.go new file mode 100644 index 0000000..f8c8826 --- /dev/null +++ b/cli/internal/spec/sync_test.go @@ -0,0 +1,244 @@ +package spec + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestSyncNoChanges(t *testing.T) { + tmpDir := setupSyncTest(t) + + // Sync (no changes expected) + opts := SyncOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + OutDir: "specs", + } + + if err := Sync(opts); err != nil { + t.Fatalf("Sync() error = %v", err) + } + + // Verify manifest unchanged + manifest, err := LoadManifest(filepath.Join(tmpDir, "specs")) + if err != nil { + t.Fatal(err) + } + + if len(manifest.Mappings) != 1 { + t.Errorf("Mappings count = %d, want 1", len(manifest.Mappings)) + } +} + +func TestSyncContractChanged(t *testing.T) { + tmpDir := setupSyncTest(t) + + // Modify contract + contractPath := filepath.Join(tmpDir, "architecture", "CONTRACT-AUTH.1.0.md") + time.Sleep(10 * time.Millisecond) // Ensure timestamp difference + + contractData, err := os.ReadFile(contractPath) + if err != nil { + t.Fatal(err) + } + + modifiedData := string(contractData) + "\n\n## New Section\n\nAdded content.\n" + if err := os.WriteFile(contractPath, []byte(modifiedData), 0644); err != nil { + t.Fatal(err) + } + + // Sync (should re-export) + opts := SyncOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + OutDir: "specs", + } + + if err := Sync(opts); err != nil { + t.Fatalf("Sync() error = %v", err) + } + + // Verify manifest updated + manifest, err := LoadManifest(filepath.Join(tmpDir, "specs")) + if err != nil { + t.Fatal(err) + } + + mapping := manifest.FindMapping("architecture/CONTRACT-AUTH.1.0.md") + if mapping == nil { + t.Fatal("Mapping not found") + } + + // Checksum should be updated + newChecksum, _ := ComputeChecksum(contractPath) + if mapping.ContractChecksum != newChecksum { + t.Error("Contract checksum not updated after sync") + } +} + +func TestSyncSpecChanged(t *testing.T) { + tmpDir := setupSyncTest(t) + + // Modify a spec file (use actual exported filename) + specPath := filepath.Join(tmpDir, "specs", "gherkin", "AUTH.1.0.feature") + time.Sleep(10 * time.Millisecond) + + specData, err := os.ReadFile(specPath) + if err != nil { + t.Fatal(err) + } + + modifiedSpec := string(specData) + "\n Scenario: New scenario\n Given something\n" + if err := os.WriteFile(specPath, []byte(modifiedSpec), 0644); err != nil { + t.Fatal(err) + } + + // Sync (should detect spec change) + opts := SyncOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + OutDir: "specs", + DryRun: true, // Don't auto-import in test + } + + if err := Sync(opts); err != nil { + t.Fatalf("Sync() error = %v", err) + } + + // In real usage, would prompt user or import with --force + // Here we just verify detection worked (no error) +} + +func TestSyncBothChanged(t *testing.T) { + tmpDir := setupSyncTest(t) + + // Modify both contract and spec + contractPath := filepath.Join(tmpDir, "architecture", "CONTRACT-AUTH.1.0.md") + specPath := filepath.Join(tmpDir, "specs", "gherkin", "AUTH.1.0.feature") + time.Sleep(10 * time.Millisecond) + + // Modify contract + contractData, err := os.ReadFile(contractPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(contractPath, []byte(string(contractData)+"\nModified"), 0644); err != nil { + t.Fatal(err) + } + + // Modify spec + specData, err := os.ReadFile(specPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(specPath, []byte(string(specData)+"\n# Modified"), 0644); err != nil { + t.Fatal(err) + } + + // Sync (should detect conflict) + opts := SyncOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + OutDir: "specs", + DryRun: true, + } + + if err := Sync(opts); err != nil { + t.Fatalf("Sync() error = %v", err) + } + + // Conflict detection works if no error +} + +func TestSyncDryRun(t *testing.T) { + tmpDir := setupSyncTest(t) + + // Modify contract + contractPath := filepath.Join(tmpDir, "architecture", "CONTRACT-AUTH.1.0.md") + time.Sleep(10 * time.Millisecond) + + contractData, err := os.ReadFile(contractPath) + if err != nil { + t.Fatal(err) + } + + originalChecksum, _ := ComputeChecksum(contractPath) + + if err := os.WriteFile(contractPath, []byte(string(contractData)+"\nModified"), 0644); err != nil { + t.Fatal(err) + } + + // Sync dry-run + opts := SyncOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + OutDir: "specs", + DryRun: true, + } + + if err := Sync(opts); err != nil { + t.Fatalf("Sync() error = %v", err) + } + + // Verify manifest NOT updated + manifest, err := LoadManifest(filepath.Join(tmpDir, "specs")) + if err != nil { + t.Fatal(err) + } + + mapping := manifest.FindMapping("architecture/CONTRACT-AUTH.1.0.md") + if mapping == nil { + t.Fatal("Mapping not found") + } + + if mapping.ContractChecksum != originalChecksum { + t.Error("Manifest was updated in dry-run mode") + } +} + +// setupSyncTest creates a test environment with exported specs +func setupSyncTest(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + contractDir := filepath.Join(tmpDir, "architecture") + outDir := filepath.Join(tmpDir, "specs") + + if err := os.MkdirAll(contractDir, 0755); err != nil { + t.Fatal(err) + } + + // Copy test contract + testContract := filepath.Join("testdata", "CONTRACT-AUTH.1.0.md") + contractData, err := os.ReadFile(testContract) + if err != nil { + t.Fatal(err) + } + + targetContract := filepath.Join(contractDir, "CONTRACT-AUTH.1.0.md") + if err := os.WriteFile(targetContract, contractData, 0644); err != nil { + t.Fatal(err) + } + + // Export to create initial specs and manifest + opts := ExportOptions{ + RepoRoot: tmpDir, + ContractDir: "architecture", + OutDir: "specs", + Force: true, + } + + if err := Export(opts); err != nil { + t.Fatal(err) + } + + // Verify setup + manifestPath := filepath.Join(outDir, ManifestFile) + if _, err := os.Stat(manifestPath); os.IsNotExist(err) { + t.Fatal("Manifest not created in setup") + } + + return tmpDir +} diff --git a/cli/internal/spec/testdata/CONTRACT-AUTH.1.0.md b/cli/internal/spec/testdata/CONTRACT-AUTH.1.0.md new file mode 100644 index 0000000..fe8b248 --- /dev/null +++ b/cli/internal/spec/testdata/CONTRACT-AUTH.1.0.md @@ -0,0 +1,90 @@ +# CONTRACT-AUTH.1.0 + +Authentication contract for user login and session management. + +## Purpose + +Provide secure authentication with JWT tokens. + +## Scenarios + +Given valid credentials +When user logs in +Then session token is created +And token expires after 24 hours + +## Architecture + +```mermaid +graph TD + Client-->AuthService + AuthService-->TokenStore + AuthService-->UserDB +``` + +## API + +```yaml +openapi: 3.0.0 +paths: + /login: + post: + summary: Authenticate user + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + password: + type: string + responses: + '200': + description: Login successful + content: + application/json: + schema: + type: object + properties: + token: + type: string +``` + +## Data Model + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string", + "format": "email" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["userId", "email"] +} +``` + +## Implementation + +Implemented in: +- `auth/service.go` +- `auth/token.go` + +## Testing + +T2 package tests in `auth/service_test.go` diff --git a/cli/internal/spec/testdata/sample.feature b/cli/internal/spec/testdata/sample.feature new file mode 100644 index 0000000..780ae6c --- /dev/null +++ b/cli/internal/spec/testdata/sample.feature @@ -0,0 +1,13 @@ +Feature: User Registration + + Scenario: Successful registration + Given a new user with valid email + When they submit registration form + Then account is created + And confirmation email is sent + + Scenario: Duplicate email + Given an existing user + When new user tries to register with same email + Then registration is rejected + And error message is shown diff --git a/cli/internal/spec/types.go b/cli/internal/spec/types.go new file mode 100644 index 0000000..9ef6609 --- /dev/null +++ b/cli/internal/spec/types.go @@ -0,0 +1,114 @@ +package spec + +import "time" + +// ExportOptions configures contract export to standard formats +type ExportOptions struct { + RepoRoot string // Repository root path + ContractDir string // Directory containing contracts (default: architecture) + OutDir string // Output directory for specs (default: specs) + Format string // Export only this format (empty = all) + Force bool // Overwrite existing files + DryRun bool // Show what would be exported + Patterns []string // Contract file patterns (empty = all) +} + +// ImportOptions configures spec import to REBAR contracts +type ImportOptions struct { + RepoRoot string // Repository root path + ContractDir string // Directory for contracts (default: architecture) + SpecPaths []string // Paths to spec files to import + Force bool // Overwrite existing contracts + DryRun bool // Show what would be imported +} + +// SyncOptions configures bidirectional sync +type SyncOptions struct { + RepoRoot string // Repository root path + ContractDir string // Directory containing contracts + OutDir string // Spec output directory + Force bool // Resolve conflicts by overwriting + DryRun bool // Show changes without applying +} + +// Manifest tracks sync state between contracts and specs +type Manifest struct { + Version string `json:"version"` + LastSync time.Time `json:"lastSync"` + Mappings []SpecMapping `json:"mappings"` +} + +// SpecMapping tracks a single contract and its exported specs +type SpecMapping struct { + Contract string `json:"contract"` // Contract file path + ContractChecksum string `json:"contractChecksum"` // SHA256 of contract + Exports []ExportedSpec `json:"exports"` // Exported spec files +} + +// ExportedSpec represents one exported spec file +type ExportedSpec struct { + Type string `json:"type"` // gherkin, mermaid, openapi, schema, adr + Path string `json:"path"` // Spec file path relative to repo root + Checksum string `json:"checksum"` // SHA256 of spec file +} + +// Contract represents parsed contract metadata +type Contract struct { + Path string + ID string + Name string + Version string + Content string + Sections map[string]string // section name → content + CodeBlocks []CodeBlock +} + +// CodeBlock represents a fenced code block in markdown +type CodeBlock struct { + Language string + Content string + Line int +} + +// SpecFormat represents a standard specification format +type SpecFormat string + +const ( + FormatGherkin SpecFormat = "gherkin" + FormatMermaid SpecFormat = "mermaid" + FormatOpenAPI SpecFormat = "openapi" + FormatSchema SpecFormat = "schema" + FormatADR SpecFormat = "adr" +) + +// ExportResult tracks the outcome of an export operation +type ExportResult struct { + Contract string + Exported []ExportedSpec + Skipped []string // Reasons for skipping + Errors []string +} + +// ImportResult tracks the outcome of an import operation +type ImportResult struct { + SpecPath string + Contract string // Generated/updated contract path + Created bool // true if new contract, false if updated + Skipped string // Reason for skipping + Error string +} + +// SyncResult tracks the outcome of a sync operation +type SyncResult struct { + ContractsExported int + SpecsImported int + Conflicts []SyncConflict + Errors []string +} + +// SyncConflict represents a bidirectional change conflict +type SyncConflict struct { + Contract string + Spec string + Reason string // "both-modified", "checksum-mismatch" +} diff --git a/conventions.md b/conventions.md index af069a6..998710b 100644 --- a/conventions.md +++ b/conventions.md @@ -19,7 +19,10 @@ Everything below is recommended for Tier 2+ (teams, shared repos, CI enforcement ## Branch Naming -Branches reference the contract they're working on: +Branches reference the contract they're working on. Branch names use the +**bare contract ID** (no namespace prefix) because `/` and `:` in branch +names cause portability problems on Windows and some tooling, and the +repo context is already implicit in the branch's repo. ``` /CONTRACT-- @@ -43,14 +46,17 @@ chore/upgrade-dependencies ## Commit Messages -Reference contracts in commit messages using conventional commit format: +Reference contracts in commit messages using conventional commit format. +The `()` prefix uses the **bare ID** for readability (the +repo context is already implicit in the commit). The trailer line uses +the full namespaced form. ``` (): -CONTRACT: +CONTRACT: : ``` **Examples:** @@ -61,7 +67,7 @@ feat(C1-BLOBSTORE): add retry logic for transient storage failures Blob uploads occasionally fail with 503 during peak traffic. Add exponential backoff with 3 retries. -CONTRACT: C1-BLOBSTORE.2.1 +CONTRACT: github.com/willackerly/rebar:C1-BLOBSTORE.2.1 ``` ``` @@ -70,7 +76,7 @@ fix(S2-API-GATEWAY): validate auth token expiry before forwarding Expired tokens were being forwarded to downstream services, causing cascading 401 errors. -CONTRACT: S2-API-GATEWAY.1.0 +CONTRACT: github.com/willackerly/rebar:S2-API-GATEWAY.1.0 ``` ``` @@ -79,8 +85,8 @@ contract(C3-CRYPTO-BRIDGE): bump to 2.0 — add key rotation interface BREAKING: New required method `RotateKey()` on CryptoBridge interface. All implementations must add this method. -CONTRACT: C3-CRYPTO-BRIDGE.2.0 -SUPERSEDES: C3-CRYPTO-BRIDGE.1.0 +CONTRACT: github.com/willackerly/rebar:C3-CRYPTO-BRIDGE.2.0 +SUPERSEDES: github.com/willackerly/rebar:C3-CRYPTO-BRIDGE.1.0 ``` ### Commit Types @@ -100,6 +106,12 @@ SUPERSEDES: C3-CRYPTO-BRIDGE.1.0 Every source file declares its contract in the first 15 lines: +All `CONTRACT:` references carry a **repo namespace** in Go-module form +(`host/org/repo`). The namespace is inferred from `git remote get-url +origin` by `rebar contract migrate-namespace` and recorded in +`.rebarrc`. The examples below use `github.com/willackerly/rebar`; +substitute your repo's namespace. + ### Direct Implementation The file directly implements the contract's interface: @@ -107,7 +119,7 @@ The file directly implements the contract's interface: ```go // Package blobstore implements encrypted blob storage. // -// CONTRACT:C1-BLOBSTORE.2.1 +// CONTRACT:github.com/willackerly/rebar:C1-BLOBSTORE.2.1 package blobstore ``` @@ -115,7 +127,7 @@ package blobstore /** * CryptoBridge — client-side AES-256-GCM encryption at the gateway boundary. * - * @contract CONTRACT:C3-CRYPTO-BRIDGE.1.0 + * @contract CONTRACT:github.com/willackerly/rebar:C3-CRYPTO-BRIDGE.1.0 */ export class CryptoBridge { ``` @@ -124,7 +136,7 @@ export class CryptoBridge { """ Key exchange primitives for P2P session setup. -CONTRACT:I2-KEY-EXCHANGE.1.0 +CONTRACT:github.com/willackerly/rebar:I2-KEY-EXCHANGE.1.0 """ ``` @@ -136,7 +148,7 @@ interface: ```go // Package httputil provides HTTP middleware for the API gateway. // -// Architecture: CONTRACT:S2-API-GATEWAY.1.0 +// Architecture: CONTRACT:github.com/willackerly/rebar:S2-API-GATEWAY.1.0 package httputil ``` @@ -178,11 +190,19 @@ Rare, but some files bridge two contracts: ```go // Package bridge connects the blob store to the relay. // -// CONTRACT:C1-BLOBSTORE.2.1 -// CONTRACT:C2-RELAY.1.0 +// CONTRACT:github.com/willackerly/rebar:C1-BLOBSTORE.2.1 +// CONTRACT:github.com/willackerly/rebar:C2-RELAY.1.0 package bridge ``` +When a file bridges contracts from **different** repos, the namespaces +make the cross-repo nature visible at a glance: + +```go +// CONTRACT:github.com/willackerly/rebar:S1-STEWARD.1.0 +// CONTRACT:github.com/filedag/filedag:C7-INDEX.1.0 ← consumed from upstream +``` + **Dual-tag enforcement:** A file tagged with two contracts will satisfy orphan detection for both. However, enforcement scripts count each file once per contract reference — a dual-tagged file is counted as one @@ -207,7 +227,7 @@ When reviewing a PR that touches contracts: ### Modified Contract (type: `contract`, changes existing file) - [ ] Version bumped appropriately (minor for additive, major for breaking) -- [ ] `grep -rn "CONTRACT:{old-id}"` run — all implementing code updated +- [ ] `grep -rn "CONTRACT:.*{old-id}"` run — all implementing code updated (matches both legacy and namespaced refs) - [ ] Old version marked `SUPERSEDED BY` - [ ] Breaking changes documented in Change History - [ ] Contract tests updated to cover new behavior diff --git a/scripts/_rebar-config.sh b/scripts/_rebar-config.sh index 6a8fee0..16e7d47 100644 --- a/scripts/_rebar-config.sh +++ b/scripts/_rebar-config.sh @@ -62,3 +62,39 @@ _rebar_script_version() { local file="$1" grep '^# rebar-scripts:' "$file" 2>/dev/null | head -1 | sed 's/^# rebar-scripts:[[:space:]]*//' } + +# Return the configured contract namespace (host/org/repo), or empty +# string if unset. When empty, enforcement scripts run in "legacy" mode +# (accept bare CONTRACT: references). When set, enforcement runs in +# "strict" mode and requires CONTRACT::. +_rebar_namespace() { + if [ -n "${REBAR_CONTRACT_NAMESPACE:-}" ]; then + echo "$REBAR_CONTRACT_NAMESPACE" + return + fi + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local project_root + project_root="$(cd "$script_dir/.." && pwd)" + local rc_file="$project_root/.rebarrc" + if [ -f "$rc_file" ]; then + local ns + ns=$(grep '^contract_namespace' "$rc_file" 2>/dev/null | head -1 | sed 's/.*=[[:space:]]*//' | tr -d ' ') + echo "$ns" + return + fi + echo "" +} + +# Strip the optional namespace prefix from a CONTRACT: reference, leaving +# just ... The ID never contains ':', so everything +# after the last ':' is the bare ID + version. +# +# Usage: bare=$(_rebar_strip_namespace "github.com/foo/bar:S1-STEWARD.1.0") +# # → S1-STEWARD.1.0 +_rebar_strip_namespace() { + local ref="$1" + # Use parameter expansion to strip everything up to and including the + # last ':' (POSIX-safe in bash 3.2). + echo "${ref##*:}" +} diff --git a/scripts/check-contract-headers.sh b/scripts/check-contract-headers.sh index 627e47c..7fa21e7 100755 --- a/scripts/check-contract-headers.sh +++ b/scripts/check-contract-headers.sh @@ -44,38 +44,64 @@ for ext in $EXTENSIONS; do done missing=0 +unnamespaced=0 total=0 +# Configured namespace (empty in legacy / unmigrated repos). +REBAR_NS="$(_rebar_namespace 2>/dev/null || true)" + while IFS= read -r file; do # Skip test files, generated files, vendor, node_modules case "$file" in *_test.go|*.test.ts|*.test.tsx|*.test.js|*.spec.ts|*.spec.tsx|*.spec.js) continue ;; - */vendor/*|*/node_modules/*|*/dist/*|*/build/*|*/.git/*) continue ;; + */vendor/*|*/node_modules/*|*/dist/*|*/build/*|*/.git/*|*/.claude/*) continue ;; *_generated*|*.gen.*|*.pb.go|*.pb.ts) continue ;; esac total=$((total + 1)) - # Check first 15 lines for CONTRACT: or Architecture: - if ! head -15 "$file" | grep -q "CONTRACT:\|Architecture:"; then + header=$(head -15 "$file") + if ! echo "$header" | grep -q "CONTRACT:\|Architecture:"; then echo "MISSING: $file" missing=$((missing + 1)) + continue + fi + + # Strict mode: when a namespace is configured, the header must use the + # namespaced form (CONTRACT:: ...). Legacy bare refs fail. + if [ -n "$REBAR_NS" ]; then + if ! echo "$header" | grep -qE 'CONTRACT:[a-zA-Z0-9][a-zA-Z0-9_./-]+:[A-Z][A-Za-z0-9_-]*\.[0-9]+\.[0-9]+'; then + echo "MISSING-NAMESPACE: $file (header has legacy CONTRACT: ref; expected CONTRACT:$REBAR_NS:)" + unnamespaced=$((unnamespaced + 1)) + fi fi done < <(find "${DIRS[@]}" \( "${FIND_ARGS[@]}" \) -type f 2>/dev/null) echo "" -echo "Scanned $total source files, $missing missing contract headers." +echo "Scanned $total source files, $missing missing headers, $unnamespaced missing namespace." -if [ "$missing" -gt 0 ]; then +if [ "$missing" -gt 0 ] || [ "$unnamespaced" -gt 0 ]; then # NOTE: literal CONTRACT prefix split via variable so the shadow-ref # detector in compute-registry.sh doesn't false-positive on these example # strings. See feedback/processed/2026-04-25-bootstrap-template-script-drift-and-bash3.2.md # for the related cli/cmd/context.go fix. P="CONTRACT:" echo "" - echo "Every source file must declare which contract it implements:" - echo " // ${P}C1-BLOBSTORE.2.1" - echo " // Architecture: ${P}S2-API-GATEWAY.1.0" + if [ "$missing" -gt 0 ]; then + echo "Every source file must declare which contract it implements:" + if [ -n "$REBAR_NS" ]; then + echo " // ${P}$REBAR_NS:C1-BLOBSTORE.2.1" + echo " // Architecture: ${P}$REBAR_NS:S2-API-GATEWAY.1.0" + else + echo " // ${P}C1-BLOBSTORE.2.1" + echo " // Architecture: ${P}S2-API-GATEWAY.1.0" + fi + fi + if [ "$unnamespaced" -gt 0 ]; then + echo "" + echo "Strict mode is active (contract_namespace=$REBAR_NS in .rebarrc)." + echo "Run \`rebar contract migrate-namespace --write\` to prefix legacy references." + fi echo "" echo "See architecture/README.md for the full convention." exit 1 diff --git a/scripts/check-contract-refs.sh b/scripts/check-contract-refs.sh index 8cacaaf..f8a20e8 100755 --- a/scripts/check-contract-refs.sh +++ b/scripts/check-contract-refs.sh @@ -16,40 +16,79 @@ if [ ! -d "$ARCH_DIR" ]; then exit 1 fi +# Load shared config (namespace, tier helpers). +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +[ -f "$SCRIPT_DIR/_rebar-config.sh" ] && source "$SCRIPT_DIR/_rebar-config.sh" +REBAR_NS="$(_rebar_namespace 2>/dev/null || true)" + broken=0 +unnamespaced=0 total=0 +# Regex matches both legacy (CONTRACT:.) and namespaced +# (CONTRACT::.) forms. The namespace portion may contain +# letters, digits, ., /, _, and -. +REF_REGEX='CONTRACT:([a-zA-Z0-9][a-zA-Z0-9_./-]+:)?[A-Z][A-Za-z0-9_-]*\.[0-9]+\.[0-9]+' + # Find all CONTRACT: references in source files while IFS= read -r line; do file=$(echo "$line" | cut -d: -f1) lineno=$(echo "$line" | cut -d: -f2) - # Extract the contract ID (e.g., C1-BLOBSTORE.2.1) - ref=$(echo "$line" | grep -o 'CONTRACT:[A-Za-z0-9_-]*\.[0-9]*\.[0-9]*' | head -1 | sed 's/CONTRACT://') - - [ -z "$ref" ] && continue + # Extract the full reference, then strip "CONTRACT:" prefix. + full_ref=$(echo "$line" | grep -oE "$REF_REGEX" | head -1) + [ -z "$full_ref" ] && continue + full_ref="${full_ref#CONTRACT:}" total=$((total + 1)) - # Check if the contract file exists - expected="${ARCH_DIR}/CONTRACT-${ref}.md" + # Detect whether this ref is namespaced. The ID is `^[A-Z][A-Za-z0-9_-]*\.$` + # which never contains ':'. So a ':' present in full_ref means it's namespaced. + if [[ "$full_ref" == *:* ]]; then + # Namespaced. Validate namespace matches the configured one when in strict mode. + ref_ns="${full_ref%:*}" + bare_id="${full_ref##*:}" + if [ -n "$REBAR_NS" ] && [ "$ref_ns" != "$REBAR_NS" ]; then + # Foreign-namespace reference (e.g. consuming an upstream contract). + # Don't try to resolve it against this repo's architecture/ — that's + # a CONSUMES.md concern. Just count it. + continue + fi + else + bare_id="$full_ref" + # Strict mode: in a migrated repo (REBAR_NS set), legacy refs are an error. + if [ -n "$REBAR_NS" ]; then + echo "MISSING-NAMESPACE: $file:$lineno references legacy CONTRACT:$bare_id" + echo " Expected: CONTRACT:$REBAR_NS:$bare_id" + unnamespaced=$((unnamespaced + 1)) + continue + fi + fi + + # Check if the contract file exists. + expected="${ARCH_DIR}/CONTRACT-${bare_id}.md" if [ ! -f "$expected" ]; then - echo "BROKEN: $file:$lineno references CONTRACT:$ref" + echo "BROKEN: $file:$lineno references CONTRACT:$full_ref" echo " Expected: $expected" broken=$((broken + 1)) fi -done < <(grep -rn "CONTRACT:[A-Za-z0-9_-]*\.[0-9]*\.[0-9]*" \ +done < <(grep -rEn "$REF_REGEX" \ --include="*.go" --include="*.ts" --include="*.tsx" --include="*.js" \ --include="*.py" --include="*.rs" --include="*.jsx" \ - . 2>/dev/null | grep -v "node_modules\|vendor\|dist\|\.git") + . 2>/dev/null | grep -v "node_modules\|vendor\|dist\|\.git\|\.claude/worktrees") echo "" -echo "Checked $total contract references, $broken broken." +echo "Checked $total contract references, $broken broken, $unnamespaced missing namespace." -if [ "$broken" -gt 0 ]; then +if [ "$broken" -gt 0 ] || [ "$unnamespaced" -gt 0 ]; then echo "" - echo "Fix by either:" - echo " 1. Creating the missing contract in $ARCH_DIR/" - echo " 2. Updating the code reference to the correct contract version" + if [ "$unnamespaced" -gt 0 ]; then + echo "Run \`rebar contract migrate-namespace --write\` to prefix legacy references with the configured namespace." + fi + if [ "$broken" -gt 0 ]; then + echo "For broken refs:" + echo " 1. Create the missing contract in $ARCH_DIR/" + echo " 2. Update the code reference to the correct contract version" + fi exit 1 fi diff --git a/scripts/check-registry.sh b/scripts/check-registry.sh index 0111ecc..18d3b30 100755 --- a/scripts/check-registry.sh +++ b/scripts/check-registry.sh @@ -60,13 +60,18 @@ for contract in "$ARCH_DIR"/CONTRACT-*.md; do # Extract contract ID from filename (e.g., CONTRACT-C1-BLOBSTORE.2.1.md → C1-BLOBSTORE.2.1) id=$(basename "$contract" .md | sed 's/^CONTRACT-//') + # Strip trailing .M.m version to get the bare ID, used to anchor the + # impl-ref grep so it accepts both legacy and namespaced forms. + bare_id=$(echo "$id" | sed -E 's/\.[0-9]+\.[0-9]+$//') - # Search for references in source code - ref_count=$(grep -rn "CONTRACT:${id}" \ + # Search for references in source code (accepts CONTRACT: and + # CONTRACT::). + ref_pattern="CONTRACT:([a-zA-Z0-9][a-zA-Z0-9_./-]+:)?${bare_id}\\.[0-9]+\\.[0-9]+" + ref_count=$(grep -rEn "$ref_pattern" \ --include="*.go" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" \ --include="*.py" --include="*.rs" --include="*.mjs" --include="*.cjs" \ . 2>/dev/null \ - | grep -v "node_modules\|vendor\|dist\|\.git\|architecture/" \ + | grep -v "node_modules\|vendor\|dist\|\.git\|architecture/\|\.claude/worktrees" \ | wc -l | tr -d ' ') if [ "$ref_count" -eq 0 ]; then diff --git a/scripts/compute-registry.sh b/scripts/compute-registry.sh index a7d5d25..ba17f9f 100755 --- a/scripts/compute-registry.sh +++ b/scripts/compute-registry.sh @@ -19,6 +19,21 @@ ARCH_DIR="$PROJECT_ROOT/architecture" REGISTRY="$ARCH_DIR/CONTRACT-REGISTRY.md" MODE="${1:-generate}" +# Configured namespace (host/org/repo). Empty in legacy / unmigrated +# repos. When set, the ID column displays the namespaced form. +[ -f "$SCRIPT_DIR/_rebar-config.sh" ] && source "$SCRIPT_DIR/_rebar-config.sh" +REBAR_NS="$(_rebar_namespace 2>/dev/null || true)" + +# format_id — Return the canonical display form of a contract ID. +# Pre-migration: bare ID (S1-STEWARD). Post-migration: :. +format_id() { + if [ -n "$REBAR_NS" ]; then + echo "${REBAR_NS}:$1" + else + echo "$1" + fi +} + # ─── Helpers ──────────────────────────────────────────────────────────────── # Parse contract filename → ID + version (reused logic from steward.sh) @@ -79,17 +94,21 @@ classify_prefix() { # `bin/ask`, `bin/ask-mcp-server`, `bin/rebar`) count_implementations() { local id="$1" + # Match both legacy (CONTRACT:.) and namespaced + # (CONTRACT::.) references. The `\.` anchors the ID so + # CONTRACT: doesn't match a prefix of . + local pattern="CONTRACT:([a-zA-Z0-9][a-zA-Z0-9_./-]+:)?${id}\\.[0-9]+\\.[0-9]+" set +o pipefail { - grep -rln "CONTRACT:${id}" "$PROJECT_ROOT" \ + grep -rEln "$pattern" "$PROJECT_ROOT" \ --include='*.go' --include='*.ts' --include='*.tsx' --include='*.js' \ --include='*.py' --include='*.rs' --include='*.java' --include='*.rb' \ --include='*.jsx' --include='*.sh' 2>/dev/null if [ -d "$PROJECT_ROOT/bin" ]; then - grep -rln "CONTRACT:${id}" "$PROJECT_ROOT/bin" 2>/dev/null + grep -rEln "$pattern" "$PROJECT_ROOT/bin" 2>/dev/null fi } \ - | grep -v "node_modules\|vendor\|dist\|\.git\|architecture/" \ + | grep -v "node_modules\|vendor\|dist\|\.git\|architecture/\|\.claude/worktrees" \ | sort -u \ | wc -l | tr -d ' ' set -o pipefail @@ -116,8 +135,9 @@ for contract in "$ARCH_DIR"/CONTRACT-*.md; do local_status=$(extract_status "$contract") local_purpose=$(extract_purpose "$contract") impl_count=$(count_implementations "$CONTRACT_ID") + display_id=$(format_id "$CONTRACT_ID") - entry="| $CONTRACT_ID | $CONTRACT_VERSION | $local_status | $impl_count | ${local_purpose:-—} |" + entry="| $display_id | $CONTRACT_VERSION | $local_status | $impl_count | ${local_purpose:-—} |" category=$(classify_prefix "$CONTRACT_ID") case "$category" in @@ -220,17 +240,32 @@ HEADER local shadows_tmp shadows_tmp="$(mktemp)" set +o pipefail + # Match both legacy and namespaced refs. The awk pass extracts the bare + # contract ID (post-colon if namespaced, post-`CONTRACT:` otherwise). { - grep -rEhno 'CONTRACT:[A-Za-z0-9_-]+\.[0-9]+\.[0-9]+' "$PROJECT_ROOT" \ + grep -rEhno 'CONTRACT:([a-zA-Z0-9][a-zA-Z0-9_./-]+:)?[A-Z][A-Za-z0-9_-]*\.[0-9]+\.[0-9]+' "$PROJECT_ROOT" \ --include='*.go' --include='*.ts' --include='*.tsx' --include='*.js' \ --include='*.py' --include='*.rs' --include='*.java' --include='*.rb' \ --include='*.jsx' --include='*.sh' 2>/dev/null if [ -d "$PROJECT_ROOT/bin" ]; then - grep -rEhno 'CONTRACT:[A-Za-z0-9_-]+\.[0-9]+\.[0-9]+' "$PROJECT_ROOT/bin" 2>/dev/null + grep -rEhno 'CONTRACT:([a-zA-Z0-9][a-zA-Z0-9_./-]+:)?[A-Z][A-Za-z0-9_-]*\.[0-9]+\.[0-9]+' "$PROJECT_ROOT/bin" 2>/dev/null fi } \ - | grep -v "node_modules\|vendor\|dist\|\.git\|architecture/" \ - | sed -E 's/^[^:]*:[^:]*://; s/^CONTRACT://; s/\.[0-9]+\.[0-9]+$//' \ + | grep -v "node_modules\|vendor\|dist\|\.git\|architecture/\|\.claude/worktrees" \ + | awk '{ + # Strip the leading "::" that grep -n prepends. + sub(/^[^:]*:[^:]*:/, "") + # Extract just the CONTRACT:... token (already filtered by grep). + match($0, /CONTRACT:[^[:space:]]+/) + ref = substr($0, RSTART, RLENGTH) + sub(/^CONTRACT:/, "", ref) + # Bare ID = everything after the last colon (namespace stripped). + n = split(ref, parts, ":") + bare = parts[n] + # Strip trailing .M.m version. + sub(/\.[0-9]+\.[0-9]+$/, "", bare) + print bare + }' \ | sort -u > "$shadows_tmp" set -o pipefail diff --git a/scripts/steward.sh b/scripts/steward.sh index 50010a1..8e39613 100755 --- a/scripts/steward.sh +++ b/scripts/steward.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # steward.sh — Automated project health scanner -# CONTRACT:S1-STEWARD.1.0 +# CONTRACT:github.com/willackerly/rebar:S1-STEWARD.1.0 # rebar-scripts: 2026.03.20 # # Usage: @@ -89,11 +89,15 @@ scan_contract() { local impl_files=() local test_files=() + # Match both legacy (CONTRACT:.) and namespaced + # (CONTRACT::.) references for this contract ID. The `\.` + # anchors the ID so suffixed IDs (e.g. -EXT) don't collide. + local impl_pattern="CONTRACT:([a-zA-Z0-9][a-zA-Z0-9_./-]+:)?${id}\\.[0-9]+\\.[0-9]+" while IFS= read -r line; do local filepath filepath="$(echo "$line" | cut -d: -f1)" impl_files+=("$filepath") - done < <(grep -rn "CONTRACT:${id}" "$PROJECT_ROOT" \ + done < <(grep -rEn "$impl_pattern" "$PROJECT_ROOT" \ --include='*.go' --include='*.ts' --include='*.js' --include='*.py' \ --include='*.rs' --include='*.java' --include='*.rb' --include='*.c' \ --include='*.cpp' --include='*.h' --include='*.cs' --include='*.swift' \ @@ -103,6 +107,7 @@ scan_contract() { | grep -v "^${ARCH_DIR}" \ | grep -v "/.git/" \ | grep -v "/node_modules/" \ + | grep -v "\.claude/worktrees" \ | sort -u || true) # Deduplicate impl_files by filepath diff --git a/setup-rebar.sh b/setup-rebar.sh index 3019ce2..136cc17 100755 --- a/setup-rebar.sh +++ b/setup-rebar.sh @@ -1,26 +1,27 @@ #!/usr/bin/env bash -# setup-rebar.sh — One-line installer for rebar. +# setup-rebar.sh — One-line installer for rebar (versioned). # -# Clones rebar to $REBAR_DIR (default ~/.rebar), runs the canonical -# `bin/install` to add ASK + rebar to your PATH, and exits pointing -# you at `rebar new` / `rebar adopt` for actual project bootstrap. +# Installs rebar to ~/.rebar/versions//, creates a 'current' +# symlink for PATH wiring, runs bin/install to add ASK + rebar CLIs. # -# This is intentionally a thin shim over rebar's existing tooling -# (`bin/install`, `rebar new`, `rebar adopt`) — not a parallel -# bootstrap. Anything beyond clone + PATH wiring belongs in the -# Go CLI so it stays in sync with the rest of the project. +# For release tags (vX.Y.Z): downloads prebuilt binaries from GitHub Releases +# For branches: clones and builds from source +# +# Multiple rebar versions can coexist. Projects pin their version via +# .rebar-version; the CLI auto-resolves the correct framework install. # # Usage (curl pipe): -# curl -fsSL https://raw.githubusercontent.com/willackerly/rebar/v3.0.1-alpha/setup-rebar.sh | bash +# curl -fsSL https://raw.githubusercontent.com/willackerly/rebar/v3.0.1-alpha/setup-rebar.sh | bash -s -- v3.0.1-alpha # # Usage (local): -# ./setup-rebar.sh [--server HOST:PORT] [--dir PATH] +# ./setup-rebar.sh [version] [--server HOST:PORT] set -euo pipefail REBAR_REPO="${REBAR_REPO:-https://github.com/willackerly/rebar.git}" -REBAR_REF="${REBAR_REF:-v3.0.1-alpha}" -REBAR_DIR="${REBAR_DIR:-$HOME/.rebar}" +REBAR_GITHUB="${REBAR_GITHUB:-ttschampel/rebar}" +REBAR_REF="${1:-v3.0.1-alpha}" +REBAR_BASE="${REBAR_BASE:-$HOME/.rebar}" ASK_SERVER="${ASK_SERVER:-}" if [[ -t 1 ]]; then @@ -37,37 +38,39 @@ ok() { printf '%s[rebar]%s %s\n' "$GREEN" "$NC" "$*"; } usage() { cat </, updates the 'current' +symlink for PATH, runs bin/install to wire ASK + rebar CLIs. -Clones rebar to \$REBAR_DIR (default ~/.rebar), then runs bin/install -to add the ASK and rebar CLIs to your PATH. Project bootstrap (creating -or adopting a project) is left to the rebar CLI itself — run -\`rebar new\` or \`rebar adopt\` after this completes. +Multiple versions can coexist. Projects pin via .rebar-version. Usage: - $0 [--server HOST:PORT] [--dir PATH] [--ref REF] + $0 [--server HOST:PORT] $0 --help -Env vars (override flags): - REBAR_DIR target install dir (default: ~/.rebar) - REBAR_REPO git remote to clone from (default: github.com/willackerly/rebar) - REBAR_REF branch/tag to check out (default: v3.0.1-alpha) - ASK_SERVER remote ASK server, written to your shell RC by bin/install +Examples: + curl ... | bash -s -- v3.1.0 + ./setup-rebar.sh v3.0.1-alpha --server localhost:8080 -Next steps after install: - rebar new my-project -d "what it does" # create a new rebar project - rebar adopt # adopt rebar in an existing repo +Env vars: + REBAR_BASE install base dir (default: ~/.rebar) + REBAR_REPO git remote (default: github.com/willackerly/rebar) + ASK_SERVER remote ASK server + +Next steps: + rebar new my-project -d "what it does" + rebar adopt EOF } +# First positional arg is version (already captured as $1 → REBAR_REF) +# Remaining args are flags +shift || true # consume version arg while [[ $# -gt 0 ]]; do case "$1" in --server) ASK_SERVER="$2"; shift 2 ;; --server=*) ASK_SERVER="${1#--server=}"; shift ;; - --dir) REBAR_DIR="$2"; shift 2 ;; - --dir=*) REBAR_DIR="${1#--dir=}"; shift ;; - --ref) REBAR_REF="$2"; shift 2 ;; - --ref=*) REBAR_REF="${1#--ref=}"; shift ;; -h|--help) usage; exit 0 ;; *) err "unknown option: $1"; usage >&2; exit 2 ;; esac @@ -78,27 +81,119 @@ if ! command -v git >/dev/null 2>&1; then exit 1 fi -if [[ -d "$REBAR_DIR/.git" ]]; then - log "Updating existing rebar checkout at $REBAR_DIR" - if ! git -C "$REBAR_DIR" diff --quiet || ! git -C "$REBAR_DIR" diff --cached --quiet; then - warn "$REBAR_DIR has uncommitted changes — skipping fetch/checkout" +# Detect platform for binary downloads +detect_platform() { + local os arch + case "$(uname -s)" in + Darwin) os="Darwin" ;; + Linux) os="Linux" ;; + MINGW*|MSYS*|CYGWIN*) os="Windows" ;; + *) err "Unsupported OS: $(uname -s)"; exit 1 ;; + esac + case "$(uname -m)" in + x86_64|amd64) arch="x86_64" ;; + arm64|aarch64) arch="arm64" ;; + *) err "Unsupported arch: $(uname -m)"; exit 1 ;; + esac + echo "${os}_${arch}" +} + +# Download and extract release binary +download_release() { + local version="$1" + local platform + platform=$(detect_platform) + local archive="rebar_${version}_${platform}.tar.gz" + local url="https://github.com/${REBAR_GITHUB}/releases/download/${version}/${archive}" + + log "Downloading release ${version} for ${platform}" + local tmpdir + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$tmpdir/$archive" || { + err "Failed to download $url" + err "Release may not exist. Use a branch name to build from source." + exit 1 + } + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$tmpdir/$archive" || { + err "Failed to download $url" + err "Release may not exist. Use a branch name to build from source." + exit 1 + } else - git -C "$REBAR_DIR" fetch --tags origin "$REBAR_REF" - git -C "$REBAR_DIR" checkout "$REBAR_REF" - git -C "$REBAR_DIR" pull --ff-only origin "$REBAR_REF" || true + err "curl or wget required for release downloads" + exit 1 + fi + + log "Extracting to $REBAR_DIR" + mkdir -p "$REBAR_DIR" + tar -xzf "$tmpdir/$archive" -C "$REBAR_DIR" + chmod +x "$REBAR_DIR/rebar" + + # Move binary to bin/ and create structure + mkdir -p "$REBAR_DIR/bin" + mv "$REBAR_DIR/rebar" "$REBAR_DIR/bin/rebar" +} + +# Clone and build from source +install_from_source() { + local ref="$1" + if [[ -d "$REBAR_DIR/.git" ]]; then + log "Updating $ref at $REBAR_DIR" + if ! git -C "$REBAR_DIR" diff --quiet || ! git -C "$REBAR_DIR" diff --cached --quiet; then + warn "Uncommitted changes in $REBAR_DIR — skipping update" + else + git -C "$REBAR_DIR" fetch --tags origin "$ref" + git -C "$REBAR_DIR" checkout "$ref" + git -C "$REBAR_DIR" pull --ff-only origin "$ref" || true + fi + elif [[ -e "$REBAR_DIR" ]]; then + err "$REBAR_DIR exists but is not a git checkout." + err "Remove it or choose a different version." + exit 1 + else + log "Installing $REBAR_REPO ($ref) → $REBAR_DIR" + git clone --branch "$ref" --depth=1 "$REBAR_REPO" "$REBAR_DIR" + fi + + # Build the binary + if ! command -v go >/dev/null 2>&1; then + err "go is required to build from source. Install it or use a release tag." + exit 1 + fi + + log "Building rebar CLI from source" + (cd "$REBAR_DIR/cli" && go build -ldflags "-X github.com/willackerly/rebar/cli/cmd.Version=$ref" -o ../bin/rebar) +} + +# Versioned install directory +REBAR_DIR="$REBAR_BASE/versions/$REBAR_REF" +mkdir -p "$REBAR_BASE/versions" + +# Detect if this is a release tag or branch +if [[ "$REBAR_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + # Release tag: try to download prebuilt binary + log "Detected release tag: $REBAR_REF" + if [[ -e "$REBAR_DIR/bin/rebar" ]]; then + log "$REBAR_REF already installed at $REBAR_DIR" + else + download_release "$REBAR_REF" fi -elif [[ -e "$REBAR_DIR" ]]; then - err "$REBAR_DIR exists but is not a git checkout." - err "Move it aside or set REBAR_DIR= and re-run." - exit 1 else - log "Cloning $REBAR_REPO ($REBAR_REF) → $REBAR_DIR" - git clone --branch "$REBAR_REF" --depth=1 "$REBAR_REPO" "$REBAR_DIR" + # Branch or non-standard ref: build from source + log "Detected branch/ref: $REBAR_REF (will build from source)" + install_from_source "$REBAR_REF" fi +# Update 'current' symlink for PATH +ln -sf "versions/$REBAR_REF" "$REBAR_BASE/current" +log "Updated ~/.rebar/current → $REBAR_REF" + if [[ ! -x "$REBAR_DIR/bin/install" ]]; then - err "$REBAR_DIR/bin/install not found or not executable." - err "The clone may have failed or the ref does not contain bin/install." + err "$REBAR_DIR/bin/install not found." exit 1 fi @@ -107,15 +202,19 @@ install_args=() log "Running $REBAR_DIR/bin/install ${install_args[*]:-}" "$REBAR_DIR/bin/install" "${install_args[@]}" -ok "rebar installed at $REBAR_DIR (ref: $REBAR_REF)" +ok "rebar $REBAR_REF installed" cat < EOF diff --git a/templates/project-bootstrap/agents/README.md b/templates/project-bootstrap/agents/README.md new file mode 100644 index 0000000..898d923 --- /dev/null +++ b/templates/project-bootstrap/agents/README.md @@ -0,0 +1,40 @@ +# Agents Directory + +Role-based agents for AI-powered coordination. Populated via `ask init` or +`rebar init`. + +## Structure + +``` +agents/ + subagent-guidelines.md ← shared behavioral contract for all subagents + architect/ ← system design agent + product/ ← user experience agent + englead/ ← delivery planning agent + steward/ ← quality scanning agent + merger/ ← integration coordination agent + featurerequest/ ← feature intake agent +``` + +Each role directory contains `AGENT.md` (role-specific prompt template). + +## Customization + +- **subagent-guidelines.md** — edit to add project-specific rules (testing + patterns, forbidden patterns, style conventions) +- **Role prompts** — edit `/AGENT.md` to tune agent behavior + +See rebar framework's `/agents/` for full role definitions and examples. + +## Usage + +Query agents via the ASK CLI: + +```bash +ask architect "Should we cache at the DB or app layer?" +ask product "Does this UX meet accessibility requirements?" +ask englead "Timeline estimate for the auth refactor?" +ask steward summary +``` + +Agents read QUICKCONTEXT.md, TODO.md, and relevant contracts before responding. diff --git a/templates/project-bootstrap/agents/subagent-guidelines.md b/templates/project-bootstrap/agents/subagent-guidelines.md new file mode 100644 index 0000000..66bef52 --- /dev/null +++ b/templates/project-bootstrap/agents/subagent-guidelines.md @@ -0,0 +1,220 @@ +# Subagent Guidelines + +Shared behavioral contract for all subagent invocations. The orchestrating +agent should instruct every subagent to read this file before starting work. + + + +--- + +## Context Loading Order + +When you start, read files in this order: + +1. **This file** (`agents/subagent-guidelines.md`) — you're here +2. **Your assigned template** from `agents/subagent-prompts/