diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdcfe4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +# Normalize line endings to LF in the repository and working tree. scc runs on +# Windows/WSL where autocrlf otherwise makes the entire tree show as Modified and +# breaks gofmt/line-ending-sensitive checks on Windows CI. LF everywhere keeps +# manifest hashes, Go source, and shell hooks consistent across platforms. +* text=auto eol=lf + +# Go, docs, shell hooks, and config are always LF. +*.go text eol=lf +*.mod text eol=lf +*.sum text eol=lf +*.sh text eol=lf +*.md text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.tmpl text eol=lf +*.mjs text eol=lf +*.js text eol=lf + +# Binary assets must never be line-ending converted. +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.gz binary +*.zip binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1419465 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: ci + +# Test + validate gate. Runs on: +# - every pull request — validate changes before they can merge. +# - push to main — validate the merge result on the default branch, since the +# release workflow is manual (workflow_dispatch) and does not run on push, +# so a merge would otherwise go unverified. +# - workflow_call — reusable, so the manual release pipeline requires this gate +# before building or publishing any binaries. +on: + push: + branches: [main] + pull_request: + workflow_call: + +permissions: + contents: read + +jobs: + test: + name: test (${{ matrix.os }}) + # Windows is a first-class target and the tool is path/line-ending heavy, so + # exercise the suite on all three OSes. fail-fast is off so one platform's + # failure still lets the others report. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + # Build/test with a current, patched toolchain. Keep this pin AHEAD of + # go.mod's directive and do NOT switch to go-version-file: go.mod — + # install-compat with older toolchains rides on GOTOOLCHAIN=auto. + # Pinned to .12 for the GO-2026-5856 crypto/tls fix; the float '1.25' + # still resolves to .11 on hosted runners (manifest lag). Float back + # once runners catch up. + go-version: '1.25.12' + cache: true + + - name: go vet + run: go vet ./... + + - name: build + run: go build ./... + + # The race detector needs a C toolchain + external linker, which the + # Windows runners don't provide out of the box, so run race+coverage on + # Linux/macOS and a plain (still cgo-free) test pass on Windows. + - name: test (race + coverage) + if: runner.os != 'Windows' + run: go test -race -coverprofile=coverage.out ./... + + - name: test (coverage, no race) + if: runner.os == 'Windows' + # Force bash so the coverprofile arg parses like Linux/macOS; the Windows + # runner's default PowerShell mangles `-coverprofile=coverage.out ./...` + # into a bogus `.out` package pattern. + shell: bash + run: go test -coverprofile=coverage.out ./... + + lint: + name: lint & vulncheck + # gofmt and the static analyzers only need to run once; keep them on Linux + # (the Windows runner checks code out with CRLF endings, which gofmt -l would + # flag on every file). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: '1.25.12' # GO-2026-5856 crypto/tls fix; see the test job's note + cache: true + + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "::error::these files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + # Scan the module (and its dependencies) for known-vulnerable, *reachable* + # code paths. This is meant to fail the build — shipping binaries built on a + # flagged stdlib/dep is exactly what we're guarding against. + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + "$(go env GOPATH)/bin/govulncheck" ./... + + # golangci-lint runs the conservative set configured in .golangci.yml. + - name: golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: v2.12.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bbbfe6d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,222 @@ +name: release + +# Manual, versioned release — the only way a new version ships. +# +# Trigger from the Actions tab ("Run workflow") or: +# gh workflow run release.yml --field version=v0.1.0 +# (or: make release VERSION=v0.1.0) +# +# It runs the full pipeline for the given version: CI gate → cross-platform +# binaries → publish every npm package (the launcher + 6 platform binaries, all +# at the same version) → tag the commit + GitHub Release. Nothing is released on +# push; releasing is always a deliberate manual action. +on: + workflow_dispatch: + inputs: + version: + description: 'Release version (vX.Y.Z)' + required: true + type: string + +# Least privilege at the workflow level: only the final "release" job needs to +# write (create the tag + GitHub Release). validate/build/npm-publish keep the +# read-only default and override to what they actually need. +permissions: + contents: read + +jobs: + validate: + name: validate version + runs-on: ubuntu-latest + steps: + - name: Check format + env: + VERSION: ${{ inputs.version }} + run: | + case "$VERSION" in + v[0-9]*.[0-9]*.[0-9]*) echo "releasing $VERSION" ;; + *) echo "::error::version must look like vX.Y.Z (got '$VERSION')"; exit 1 ;; + esac + + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 0 # tags — needed to tell a retry from a re-release + + # A version is immutable. Re-dispatching an already-released version from a + # NEW commit is the one failure this pipeline cannot report: npm-publish + # skips every package that is already on the registry, the tag stays put, + # and the whole run goes green having shipped nothing. Fail loudly instead. + # + # Re-dispatching the SAME commit is still allowed: that is the intended way + # to resume a run that died between npm-publish and the tag/Release step. + - name: Refuse to re-release an existing version from a different commit + env: + VERSION: ${{ inputs.version }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + if ! git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "$VERSION is unreleased — proceeding" + exit 0 + fi + tagged=$(git rev-list -n1 "refs/tags/$VERSION") + if [ "$tagged" != "$SHA" ]; then + echo "::error::$VERSION is already released and points at $tagged, but this run is on $SHA." + echo "::error::Publishing is idempotent, so re-running would ship nothing. Bump the version instead." + exit 1 + fi + echo "$VERSION already tagged at this exact commit — resuming a partial release" + + # Reuse the full CI gate (gofmt, vet, build, race tests, vulncheck) — no + # binaries are built or published unless this passes. + ci: + needs: validate + uses: ./.github/workflows/ci.yml + + build: + name: build ${{ matrix.goos }}/${{ matrix.goarch }} + needs: ci + runs-on: ubuntu-latest + # Cross-compile only; no write access needed. Keep the read-only default + # (inherited from the workflow-level permissions above) explicit here. + permissions: + contents: read + strategy: + fail-fast: true + matrix: + include: + - { goos: linux, goarch: amd64 } + - { goos: linux, goarch: arm64 } + - { goos: darwin, goarch: amd64 } + - { goos: darwin, goarch: arm64 } + - { goos: windows, goarch: amd64 } + - { goos: windows, goarch: arm64 } + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: '1.25.12' # see ci.yml's note before changing + cache: true + + - name: Build & package + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + bin=scc + [ "$GOOS" = "windows" ] && bin=scc.exe + mkdir -p dist + go build -trimpath \ + -ldflags "-s -w -X github.com/protonspy/spec-claude-code/internal/cli.version=${VERSION}" \ + -o "dist/${bin}" ./cmd/scc + name="scc_${VERSION}_${GOOS}_${GOARCH}" + cd dist + if [ "$GOOS" = "windows" ]; then + zip -q "${name}.zip" "${bin}" + else + tar -czf "${name}.tar.gz" "${bin}" + fi + rm -f "${bin}" + + # Upload only what this run built, never `dist/*` — a stale tarball sitting + # in the checked-out dist/ would otherwise ride along into every Release. + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: scc-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/scc_${{ inputs.version }}_* + if-no-files-found: error + retention-days: 7 + + # Publish every npm package at the requested version. Authenticates with the + # NPM_TOKEN secret (must be an Automation token, which bypasses 2FA); + # id-token: write lets --provenance attach a signed build attestation. + npm-publish: + name: publish npm packages + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for npm --provenance attestation + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + # Trusted publishing requires npm >= 11.5.1 (newer than what ships with + # Node). Pin the major line so the release toolchain is reproducible. + - name: Upgrade npm + run: npm install -g npm@11 + + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + path: artifacts + merge-multiple: true + + - name: Assemble npm packages + env: + VERSION: ${{ inputs.version }} + run: node npm/scripts/build-packages.mjs "$VERSION" artifacts + + - name: Publish (platform packages first, then the launcher) + env: + # Automation token (bypasses 2FA). setup-node wrote an .npmrc that + # reads this. --provenance still attaches a signed attestation via the + # job's id-token: write permission. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + # Idempotent publish: skip any package@version already on the registry so + # re-running after a partial failure can finish instead of hard-failing on + # the first already-published package (mirrors the Makefile's npm-publish). + publish() { + local dir="$1" + local name ver + name=$(node -p "require('./$dir/package.json').name") + ver=$(node -p "require('./$dir/package.json').version") + if npm view "$name@$ver" version >/dev/null 2>&1; then + echo "skip $name@$ver (already published)" + return 0 + fi + echo ">> publishing $name@$ver" + npm publish "$dir" --access public --provenance + } + # The platform packages must land before the launcher that depends on them. + for d in npm/dist/scc-*/; do + publish "${d%/}" + done + publish npm/dist/scc + + # Tag the released commit and create the GitHub Release — only after npm + # publishing succeeds, so a failed publish never leaves a dangling tag/release. + release: + name: tag + GitHub release + needs: npm-publish + runs-on: ubuntu-latest + # This is the only job that mutates the repo (creates the tag + Release), so + # scope write access here instead of granting it workflow-wide. + permissions: + contents: write + steps: + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + path: dist + merge-multiple: true + + - name: Checksums + run: cd dist && sha256sum * | tee checksums.txt + + - name: Create tag + release + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + tag_name: ${{ inputs.version }} + name: ${{ inputs.version }} + target_commitish: ${{ github.sha }} + make_latest: 'true' + files: dist/* diff --git a/.gitignore b/.gitignore index aaadf73..f62cab4 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,13 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# The locally built binary (`make build`). +/scc +/scc.exe + +# Release/publish staging: cross-compiled tarballs, downloaded CI artifacts, and +# the assembled npm publish tree. All reproducible from a tag — never committed. +/dist/ +/artifacts/ +npm/dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4a6175b --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,32 @@ +# golangci-lint configuration (v2 schema — golangci-lint v2.x). +# +# Deliberately conservative: only the standard, high-signal analyzers are +# enabled so CI stays green-friendly instead of drowning in style nits. Add more +# linters here as the codebase grows. +version: "2" + +linters: + default: none + enable: + - errcheck # unchecked errors + - govet # go vet's analyzers + - ineffassign # assignments never read + - staticcheck # staticcheck (SA/S/ST) suite + - unused # unused code + exclusions: + rules: + # Test code is intentionally lax about unchecked errors (Close/Chdir/…) and + # uses patterns staticcheck false-positives on (e.g. Hash("a") != Hash("a") + # to assert determinism). + - path: _test\.go + linters: + - errcheck + - staticcheck + # QF1001 (apply De Morgan's law) and S1016 (struct literal -> conversion) + # are style suggestions where the explicit form reads clearer; keep it. + - linters: + - staticcheck + text: "QF1001" + - linters: + - staticcheck + text: "S1016" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..607f2ae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`scc` is a single Go binary (`github.com/protonspy/spec-claude-code`) that enforces Spec-Driven Development inside a Claude Code repo. One surface: a headless CLI, so an AI agent or a CI job drives it exactly as well as a human does. + +The artifacts it governs (`.claude/`, `specs/`, `docs/`) are plain Markdown/JSON. There is no server and no database — the files *are* the API. scc has no directory of its own: its state lives in one file under `.claude/`, alongside what Claude Code already keeps there. + +This file covers working *on* scc. The product's own rules and methodology are not documented here: they live in `design/` while being designed, and ship as templates the binary scaffolds into `.claude/rules/`. + +Note: this repo is not itself an scc workspace (no `.claude/`, `specs/`, or `docs/` are committed) — those trees only exist in workspaces the binary scaffolds, and in test temp dirs. + +**Status: v0.3.0-shaped.** Everything through `design/plan.md` phase 10 is built and green: scaffolding (`init`), artifact creation (`spec`, `plan`), and all eight validators behind `scc validate`. `scc update` (phase 11) is not started, deliberately — a three-way merge is untestable until a second template version exists, so until then the upgrade story is "re-run `init`; it never overwrites what you edited". + +`scc` is a redesign of `csdd` (`github.com/protonspy/csdd`), narrowed to spec-driven development and deliberately leaner. When reaching for something from there, port the *decision*, not the file. Already decided against: a TUI, an embedded web dashboard, an MCP server, a devcontainer. + +## Commands + +```bash +make build # -> ./scc (set VERSION=vX.Y.Z to stamp the version) +make check # the CI gate: gofmt -l + go vet + go test -race +make test # go test -race -coverprofile=coverage.out ./... +make fmt # fails if anything is not gofmt-clean +make help # every target with its one-line description +``` + +A single test / package: + +```bash +go test ./internal/workspace/ -run TestSafeName -v +go test ./internal/cli/ -run 'TestVersion.*' -race +``` + +Lint locally the way CI does (golangci-lint v2 schema, conservative set in `.golangci.yml`): + +```bash +golangci-lint run # v2.12.2 in CI +govulncheck ./... # CI fails the build on reachable vulns +``` + +Release is manual and CI-driven: `make release VERSION=v0.1.0` dispatches `release.yml`. The `dist`/`npm-build`/`npm-publish` targets exist as a bootstrap/fallback path only. + +## Architecture + +``` +cmd/scc/main.go os.Exit(cli.Run(os.Args[1:])) + | + internal/cli the whole command surface + | + scaffold · validate write / check + | \ + assets · manifest ears · mdscan templates, hashes, grammars + | + paths · workspace · render · textutil · finding + | + plain files on disk: .claude/ · specs/ · plans/ · docs/ · CLAUDE.md +``` + +`internal/cli/cli.go` is the whole dispatcher: `Run(args)` switches on `args[0]` and hands off to `run` in a file named for that resource. Each handler owns its own `flag.FlagSet`. Adding a subcommand means adding a case there plus one file — nothing is registered dynamically, so the command set is readable in one place. + +| Package | Role | +|---|---| +| `internal/paths` | Every directory/file name in the on-disk layout, in one place. Never hardcode `".claude"` or `"specs"` elsewhere. | +| `internal/workspace` | Resolves the root by walking up for the `.claude/scc-manifest.json` marker; owns `KebabCheck`, `SafeName`, `AtomicWrite`. Knows nothing about specs or wikis. | +| `internal/render` | CLI terminal output (`✓ ✗ ! •`, `NO_COLOR`/TTY aware), split across stdout/stderr. | +| `internal/textutil` | Line-ending and BOM normalization, in exactly one place. | +| `internal/finding` | One finding type and one frozen JSON shape (`{findings, count}`) for every validator, plus the grouped human report. | +| `internal/manifest` | `.claude/scc-manifest.json`: `{path, hash, version}` per managed file, deterministic serialization, `Status → pristine\|edited\|missing`. Unknown fields are preserved. | +| `internal/assets` | The embedded template set. **Workspace templates are data-free** (that is what makes a future three-way merge possible); **artifact templates take data** (`spec new` renders them and the user owns the result). | +| `internal/scaffold` | Applies the template set to a root. Idempotent, never overwrites, manifest written last. | +| `internal/mdscan` | The only Markdown parser: fence- and HTML-comment-aware headings, checkboxes, links, wikilinks, slugs, plus a small frontmatter reader. `Body` is the comment/fence-stripped text every validator applies its grammar to. | +| `internal/ears` | EARS requirement parsing, all five patterns plus complex. | +| `internal/validate` | The eight validators, one file each, sharing `mdscan` and `finding`. | +| `internal/cli` | The dispatcher and every command handler. | + +`go.mod` is stdlib-only. Keep it that way unless a dependency earns its place — the binary is distributed to six platforms and every dep is a supply-chain surface. + +## Conventions + +**Exit codes are the contract.** `0` ok · `1` usage/runtime error · `2` validation findings. Every lint/validate command returns `2` on findings so CI and agents can branch on it. A finding is a legitimate answer to a lint question, not a failure of the tool — don't collapse `2` into `1`. + +**A validator that fires on scc's own output is the worst bug in the product.** The templates carry their instructions in HTML comments and fenced examples, which is exactly what `mdscan` excludes — and `TestFreshArtifactsPassTheirOwnValidators` in `internal/cli` is the gate. Treat it as required reading before changing a template or a validator: one wrong finding teaches the user to disbelieve all eight. + +**Machine-readable output.** Bind the `--json` flag via `addJSON` and emit through `emitJSON` (`internal/cli/jsonout.go`) so the flag name, help text, and stdout/stderr split stay identical across commands — stdout is a clean JSON stream, diagnostics go to stderr. + +**Hostile input.** Any positional name that becomes a path segment must pass `workspace.SafeName` *before* `filepath.Join` — without it `scc spec delete .. --force` resolves to the workspace root. Resource names are kebab-case (`workspace.KebabCheck`). + +**Writes are atomic.** Use `workspace.AtomicWrite` for anything a concurrent reader might see. + +**The marker is the file `.claude/scc-manifest.json`, never the `.claude/` directory.** Two reasons, and both are load-bearing: `~/.claude` is Claude Code's own global config directory and exists on every machine that runs it, so an upward walk accepting the *directory* would resolve the root to `$HOME` for any command run outside a workspace — every command would then read and write the user's global configuration. And `.claude/` exists in every repo that merely *uses* Claude Code, where scc was never initialized. `workspace.Find` therefore stats a regular file. + +**scc has exactly one file and no config file.** The manifest is it — content hashes, doubling as the marker. scc runs no tests and no linters, so it has nothing to configure; a project's test and lint commands are a rule under `.claude/rules/`, which is Markdown the orchestrator already reads. Resist adding `scc.json`: a JSON schema to version, read by nothing inside the binary, is dead weight. + +**Never author what the user owns.** Upgrades preserve user-edited files rather than overwriting them; the manifest of content hashes is what makes a pristine file distinguishable from an edited one. + +**Determinism.** Normalize rendered output to LF (`textutil.NormalizeNewlines`) so scaffolded files hash identically regardless of the build machine's checkout. Sort before serializing. + +**Windows is a first-class target** and CI runs the suite on Linux/macOS/Windows. `.gitattributes` forces LF everywhere — CRLF would break gofmt, manifest hashes, and shell hooks. Watch for path separators, file-locking/delete-pending behavior, and the fact that the race detector doesn't run on the Windows job. + +## Testing + +Tests live beside the code and lean on a few package-local helpers rather than a framework: + +- `capture(t, f)` in `internal/cli/cli_test.go` swaps `os.Stdout`/`os.Stderr` for pipes — the only safe way to assert on CLI output, since `render` writes to the real files. `run(t, args...)` wraps it around `cli.Run`. It drains both pipes concurrently, so a command that outruns the pipe buffer fails the test instead of deadlocking. +- `cli.Run` returns an exit code and never calls `os.Exit`, so the whole surface is drivable in-process. +- Compare resolved paths with `os.SameFile`, not string equality: `t.TempDir()` can sit under a symlink (`/var` → `/private/var` on macOS) and Windows reports 8.3 short names. +- `_test.go` files are exempt from `errcheck`/`staticcheck` in `.golangci.yml` — deliberate, for patterns like `Hash("a") != Hash("a")` asserting determinism. + +## Releasing + +`release.yml` is `workflow_dispatch` only: validate the version → run the full CI gate → cross-compile 6 targets → publish the npm packages → tag + GitHub Release. Details that are load-bearing: + +- **A version is immutable.** Re-dispatching an already-released version from a *different* commit is refused, because publishing is idempotent and the run would otherwise go green having shipped nothing. +- **Publishing is idempotent.** Already-published packages are skipped, so a run that died after `npm-publish` can be resumed by re-dispatching the same commit. +- **Adding a platform touches three places** that must agree: `TARGETS` in `npm/scripts/build-packages.mjs`, `PLATFORMS` in the `Makefile`, and the `build` matrix in `release.yml`. +- Actions are pinned by commit SHA. Keep them pinned. + +## Commits + +Conventional Commits, scoped by package or surface, with a descriptive subject written as a claim about behavior — e.g. `feat(cli): return exit 2 when spec validation reports findings`. Changes land through PRs on `main`. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b0802fd --- /dev/null +++ b/Makefile @@ -0,0 +1,115 @@ +# scc — build, test, and npm release tasks. +# +# Day-to-day: +# make build # local binary -> ./scc +# make check # gofmt + vet + race tests (the CI gate) +# +# Release (manual — dispatches the release workflow; CI builds + publishes npm): +# make release VERSION=v0.1.0 # = gh workflow run release.yml --field version=v0.1.0 +# +# Manual npm publish (bootstrap / fallback, when CI can't do it): +# make dist VERSION=v0.1.0 # cross-compile all 6 targets into dist/ +# make npm-build VERSION=v0.1.0 # assemble npm/dist/ (7 packages) from those +# make npm-dry-run # validate every package without publishing +# make npm-publish [OTP=123456] # publish (skips already-published) +# +# Auth for a manual publish: either an Automation token +# npm config set //registry.npmjs.org/:_authToken +# or pass a fresh 2FA code: make npm-publish OTP=123456 + +SHELL := bash +MODULE := github.com/protonspy/spec-claude-code +BIN := scc +VERSION ?= dev +LDFLAGS := -s -w -X $(MODULE)/internal/cli.version=$(VERSION) +DIST ?= dist +ARTIFACTS ?= $(DIST) + +# GOOS/GOARCH targets shipped to npm — must match npm/scripts/build-packages.mjs +# and the release.yml build matrix. +PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## Show this help + @grep -hE '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN{FS=":.*## "}{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' + +.PHONY: build +build: ## Build the binary locally (-> ./scc); set VERSION to stamp it + go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/$(BIN) + +.PHONY: test +test: ## Run tests (race + coverage) + go test -race -coverprofile=coverage.out ./... + +.PHONY: fmt +fmt: ## Fail if any file is not gofmt-clean + @unformatted=$$(gofmt -l .); \ + if [ -n "$$unformatted" ]; then echo "not gofmt-clean:"; echo "$$unformatted"; exit 1; fi; \ + echo "gofmt clean" + +.PHONY: vet +vet: ## go vet ./... + go vet ./... + +.PHONY: check +check: fmt vet test ## Run the full CI gate (gofmt + vet + race tests) + +# require-version fails fast when the release VERSION is left at the 'dev' +# default. Without it, dist/npm-build produce 'dev'-named tarballs (and an +# invalid npm semver), which only surfaces later as a cryptic `tar: Cannot open` +# when npm-build can't find a matching artifact. +.PHONY: require-version +require-version: + @case '$(VERSION)' in v*.*.*) : ;; *) echo "set a release VERSION like v0.1.1 (got '$(VERSION)'); e.g. make $(MAKECMDGOALS) VERSION=v0.1.1" >&2; exit 1 ;; esac + +.PHONY: dist +dist: require-version ## Cross-compile every npm target into $(DIST)/ (set VERSION=vX.Y.Z) + @rm -rf '$(DIST)' && mkdir -p '$(DIST)' + @set -euo pipefail; for p in $(PLATFORMS); do \ + goos=$${p%/*}; goarch=$${p#*/}; bin=$(BIN); [ "$$goos" = windows ] && bin=$(BIN).exe; \ + echo ">> $$goos/$$goarch"; \ + GOOS=$$goos GOARCH=$$goarch CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o '$(DIST)'/$$bin ./cmd/$(BIN); \ + name=$(BIN)_$(VERSION)_$${goos}_$${goarch}; \ + if [ "$$goos" = windows ]; then (cd '$(DIST)' && zip -q $$name.zip $$bin); \ + else (cd '$(DIST)' && tar -czf $$name.tar.gz $$bin); fi; \ + rm -f '$(DIST)'/$$bin; \ + done; \ + echo "artifacts in $(DIST)/" + +.PHONY: npm-build +npm-build: require-version ## Assemble npm/dist/ from the artifacts (VERSION=vX.Y.Z; ARTIFACTS=dir; needs `make dist`) + node npm/scripts/build-packages.mjs '$(VERSION)' '$(ARTIFACTS)' + +.PHONY: npm-dry-run +npm-dry-run: ## Dry-run publish every assembled package + @set -euo pipefail; \ + if [ ! -f npm/dist/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make npm-build VERSION=vX.Y.Z first" >&2; exit 1; fi; \ + for d in npm/dist/$(BIN)-*/ npm/dist/$(BIN)/; do \ + [ -f "$$d/package.json" ] || continue; \ + echo "== $$d"; npm publish "$$d" --access public --dry-run; done + +.PHONY: npm-publish +npm-publish: ## Publish the assembled packages, skips already-published; OTP=123456 if 2FA + @set -euo pipefail; \ + if [ ! -f npm/dist/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make dist VERSION=vX.Y.Z && make npm-build VERSION=vX.Y.Z" >&2; exit 1; fi; \ + otp=; if [ -n "$(OTP)" ]; then otp="--otp=$(OTP)"; fi; \ + for d in npm/dist/$(BIN)-*/ npm/dist/$(BIN)/; do \ + [ -f "$$d/package.json" ] || continue; \ + name=$$(cd "$$d" && node -p "require('./package.json').name"); \ + ver=$$(cd "$$d" && node -p "require('./package.json').version"); \ + if npm view "$$name@$$ver" version >/dev/null 2>&1; then \ + echo "skip $$name@$$ver (already published)"; continue; fi; \ + echo ">> publishing $$name@$$ver"; npm publish "$$d" --access public $$otp; \ + done + +.PHONY: release +release: require-version ## Dispatch the manual release workflow (CI builds binaries + publishes npm) for VERSION + gh workflow run release.yml --field version='$(VERSION)' + @echo "dispatched release $(VERSION) — track it with: gh run list --workflow=release.yml" + +.PHONY: clean +clean: ## Remove build artifacts (dist/, npm/dist/, ./scc, coverage) + rm -rf '$(DIST)' npm/dist $(BIN) $(BIN).exe coverage.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a5a0eb --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# scc + +Spec-driven development for Claude Code — a single Go binary that turns the SDD +workflow into a mechanically validated contract for humans and AI agents. + +> **Status: pre-1.0.** Scaffolding, artifact creation, and validation all work. +> `scc update` — bringing an existing workspace onto improved templates through a +> three-way merge — is not built yet; until it is, re-running `scc init` is the +> upgrade path. It is additive and never overwrites a file you edited, so it is safe, +> but it also will not deliver improved templates to files that already exist. + +## Use it + +```bash +scc init # scaffold .claude/rules/, the review agents, the layout +scc spec new user-auth # specs/user-auth/: requirements.md, design.md, tasks.md +scc plan new checkout-revamp # plans/checkout-revamp.md +scc validate # every check; exit 2 means it found something +``` + +| Command | What it does | +|---|---| +| `init` | Scaffolds the workspace and records what it wrote. Idempotent; never overwrites your edits. | +| `spec new\|list\|show\|delete\|validate` | The three-artifact vehicle for work whose *what* and *how* need settling first. | +| `plan new\|list\|delete\|validate` | One file, for everything else: a checklist, a decomposition into specs, or both. | +| `skill validate` | Conformance to the published [Agent Skills](https://agentskills.io/specification) spec. | +| `validate` | Every applicable validator, one exit code, one JSON document. | + +What gets checked: EARS grammar across all five patterns, requirement numbering, +one methodology annotation per task, traceability in both directions, plan +one-source-of-truth, skill conformance, wiki link/orphan graph, ADR numbering and +superseding, glossary vocabulary drift, dependencies missing from `docs/stack.md`, and +codewiki citations that no longer resolve. + +What deliberately is **not** checked: your source code. scc never parses it, so it +cannot tell you the code honors what the artifact says — that stays the orchestrator's +accountability, and a checker that was confidently incomplete would be worse than none. + +## Install + +```bash +npx @protonspy/scc help # no install +npm i -g @protonspy/scc # then: scc help +``` + +Or from source (Go 1.25+): + +```bash +go install github.com/protonspy/spec-claude-code/cmd/scc@latest +``` + +Prebuilt binaries for Linux, macOS, and Windows on x64/arm64 are attached to each +[release](https://github.com/protonspy/spec-claude-code/releases). + +## Design + +One surface: a headless CLI. Every capability is reachable through flags, with +`--json` output and a stable exit-code contract, so an agent or a CI job drives it +exactly as well as a human does. + +| Exit code | Meaning | +|---|---| +| `0` | ok | +| `1` | usage or runtime error | +| `2` | the command ran and reported validation findings | + +The artifacts it governs are plain Markdown and JSON in your repo — `.claude/`, +`specs/`, `docs/`. No server, no database, no conversion layer: the files *are* the +API, and they are the ones Claude Code already reads. `scc` adds no directory of its +own and no config file — it owns a single manifest inside `.claude/`. + +## Development + +```bash +make check # the CI gate: gofmt + go vet + go test -race +make build # -> ./scc +make help # every target +``` + +See [CLAUDE.md](CLAUDE.md) for architecture and conventions. + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/cmd/scc/main.go b/cmd/scc/main.go new file mode 100644 index 0000000..79e1e25 --- /dev/null +++ b/cmd/scc/main.go @@ -0,0 +1,16 @@ +// scc — spec-driven development for Claude Code. +// +// A single binary with one surface: a headless CLI. Every capability is +// reachable through flags with --json output and a stable exit-code contract, +// so an AI agent or a CI job drives it exactly as well as a human does. +package main + +import ( + "os" + + "github.com/protonspy/spec-claude-code/internal/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:])) +} diff --git a/design/orchestration.md b/design/orchestration.md new file mode 100644 index 0000000..742febb --- /dev/null +++ b/design/orchestration.md @@ -0,0 +1,762 @@ +# Orchestration model + +How work enters an scc workspace and how it gets built. This is the design that +replaces `csdd`'s fixed, human-gated pipeline; it is not implemented yet. + +`research.md` beside this file records where these decisions sit relative to the field and +the literature — including the measured results behind §3's impact-analysis step, §5's +skill conformance, §10's delta format, and §11. Read it before revisiting a decision here; +several of them are evidence-backed rather than preferences. + +## 0 · What scc ships + +Same philosophy as csdd: **scc is a template plus a CLI.** The binary scaffolds a +workspace — rules, skills, agents, spec templates — into `.claude/`, `specs/`, and +`docs/`, all of it plain Markdown compiled into the binary via `//go:embed`, and then +validates what lives there. There is no conversion layer and no runtime: the files +Claude Code already reads *are* the API, and the CLI is how they are created, +checked, and upgraded. + +scc adds **no directory of its own** and **no config file**. It owns exactly one file, +`.claude/scc-manifest.json`: content hashes, so an upgrade can tell a pristine file +from an edited one — and, by existing at all, the workspace marker. It has nothing to +configure because it runs nothing; the project's test and lint commands (§7) are a rule +under `.claude/rules/`, Markdown the orchestrator already reads. + +The marker is that file rather than `.claude/` itself because `~/.claude` is Claude +Code's global config on every machine — a walk accepting the directory would resolve +any out-of-workspace command's root to `$HOME` — and because `.claude/` also exists in +repos that merely use Claude Code, where scc was never initialized. + +That is why everything below describes both a rule and its artifact. A rule the +binary cannot scaffold is documentation; a rule it cannot check is a suggestion. + +## 1 · Routing — which vehicle + +The premise: **the orchestrator routes.** csdd made every change walk the same +path — requirements → design → tasks, each phase blocked on human approval. That +is the right ceremony for a large feature and pure overhead for a small one. Here +the orchestrator picks the vehicle to match the work, and the tool's job is to +make that choice explicit, recorded, and mechanically checkable. + +``` + does this need requirements + and a design settled + before any code? + │ + ┌──────── yes ───┴─── no ────────┐ + │ │ + Spec Plan + specs// plans/.md + requirements → design → tasks structure + checklist + │ │ + │ ┌───────────┴───────────┐ + │ │ │ + │ a direct checklist a decomposition that + │ of work to do references Specs + │ │ │ + └──────────────► every task carries a methodology (§3) ◄┘ +``` + +**Two vehicles, not three.** Everything that isn't worth a spec is a **plan** — a +document with a structure and a checklist. That covers both ends of what used to be +three: an initiative too large for one spec, decomposed into specs; and a change small +enough that the *what* was never in doubt, recorded as a checklist and nothing more. A +plan can be either, or both at once — an initiative whose decomposition includes a +handful of items it just does itself. + +| Vehicle | When | Path | Contents | +|---|---|---|---| +| **Spec** | The *what* and the *how* need settling before code. | `specs//` | `requirements.md` · `design.md` · `tasks.md` | +| **Plan** | Everything else. | `plans/.md` | structure, plus a checklist of tasks and/or references to specs | + +The collapse is what makes the small case affordable. The earlier design had small +changes write nothing at all, because a `specs/` tree cluttered with three-line features +makes the specs that matter harder to find. A plan is **one file in a different +directory**, so that cost never appears — and the record survives the session, which is +what a small change was previously losing. + +**A plan is not a feature, so it does not live in `specs/`.** Each directory name means +exactly one thing: `specs/` holds features, `plans/` holds plans, `docs/` is the +knowledge base. csdd put plans in `docs/plans/`; a plan is work, not knowledge, so it +sits beside `specs/` rather than inside `docs/`. + +**A plan is one file, not a directory,** because a plan holds no state beyond its own +checklist. Where an item *is* a task, its checkbox is the state. Where an item +references a spec, the state is **derived** from that spec, never copied — two records +of the same fact can disagree, and the copy is the one that goes stale. Every item has +exactly one source of truth. + +**A plan's leaves are ordinary specs.** `plans/checkout-revamp.md` references +`specs/cart-totals/`, `specs/payment-intents/`. They are not nested under the plan: a +leaf is a normal spec that happens to have been created by one, built by exactly the +same rules whether a plan produced it or a human asked directly. + +> **Vocabulary warning.** In GitHub Spec Kit — the most widely integrated toolkit — "plan" +> means the *opposite*: `/speckit.plan` produces the architecture, data model, and API +> contracts *inside* one feature, i.e. what we call design. Ours is the decomposition +> *above* specs. The collision is a known, accepted cost: our plan covers both a +> decomposition and a bare checklist, and no better single word covers both. Someone +> arriving from spec-kit will need this paragraph. + +### Why both vehicles write a file + +Neither vehicle is session-only, and **autonomy is the reason.** csdd's human approved +each phase, so they *saw* the requirements go by. Here the orchestrator writes and +proceeds. The artifact is the only channel through which anyone — the reviewer, the next +session, the orchestrator itself after a compaction — ever learns what was decided. A +session's context dies; the file is what makes the work resumable and the reasoning +reviewable. + +It is also the only thing `scc` can act on: §5 validates artifacts, §2 records the gate +answer on one, §4's methodology annotations are checked in a file. No artifact, no tool +— just rules and two reviewers. + +What the two-vehicle split buys is that the *weight* of the record matches the weight of +the work. A one-line change gets one checklist item in a plan, not three ceremonial +files in `specs/`. + +## 2 · Autonomy — the gate is a question, not a flag + +The spec phases are **autonomous by default**: the orchestrator writes +requirements, design, and tasks, then starts implementing — it does not stop to +be approved at each phase the way csdd did. + +But autonomy is the user's call, so **the orchestrator asks once, at kickoff**: +run automatically, or gate the phases for review? The answer is recorded on the +artifact, which matters for two reasons — the run is reproducible from the file, +and the orchestrator never asks twice for the same piece of work. + +Asking beats a flag here: the person who needs to make the call is in the +conversation, not on a command line. + +**Where it is recorded:** frontmatter on the spec's `requirements.md`, or on the plan. +Frontmatter because it is the one part of a Markdown artifact that is already +machine-readable, so `scc` can check the value without reading prose. + +```yaml +--- +autonomy: auto # or: gated +ci: wait # or: no-wait (§9) +--- +``` + +`scc spec new --autonomy=gated --ci=no-wait` writes both. They are validated +only when present: a missing key means the run predates the convention, not that the +user did something wrong. + +### Automatic still escalates + +"Automatic" is not "never stop." The practice literature describes three oversight shapes — +approval before each action, review after, and **escalation triggers**: run autonomously, +but halt on specific risk signals. Asking once and then never stopping is only the middle +option, and it treats a task that moves money like a task that renames a variable. + +So autonomous mode keeps one exception: **the risk that mandates TDD also warrants a +checkpoint.** §3 already requires the orchestrator to classify every task against that list +— money, complex algorithms, hypothesis validation, high-risk complexity. A task annotated +`(TDD)` for one of those reasons is precisely the task worth surfacing before it lands, even +in a run the user asked to be automatic. + +One classifier, two consumers: it picks the methodology, and it picks what deserves a human +glance. Adding a second, separate risk taxonomy for gating would guarantee the two drift +apart. + +The related principle from the same literature — **autonomy is earned through demonstrated +performance, not assumed** — is why the question exists at all rather than defaulting to +automatic silently. + +## 3 · Methodology — per task, Unit by default + +Every task is built one of two ways. The orchestrator decides per task, not per +project — a feature routinely has both. + +### Before either cycle — find the tests that already cover this + +**Identify the existing tests that exercise the paths this task will change, and run +them, before writing anything.** This is not part of Unit or TDD; it precedes both, +because it is about not breaking what works rather than about proving what's new. + +This step exists because of a measured result, not a preference. TDAD (arXiv 2603.17973) +found that giving an agent generic test-first *procedure* while leaving it ignorant of +which tests actually cover the code being modified **increased regressions above the +no-intervention baseline** — roughly 9.9% against ~6.1% for doing nothing. What worked was +impact analysis: identifying the tests that genuinely exercise the modified paths, which +brought regressions to ~1.8%. Procedure without context was worse than no procedure. + +The orchestrator does this analysis, and it can: it reads the code. `scc` does not (§5), +so this lands as a rule rather than a command — which is exactly the split the rest of the +design already draws. + +The failure it prevents is specific: a task that adds a case to a function whose existing +tests nobody looked at, whose new test passes, and whose old behavior silently changed. + +### Unit (default) + +Write the code, then write a unit test for **each function** in it. That is the whole +cycle — **no RED/GREEN.** There is no failing-test step to observe, because the code +already exists; the deliverable is unit tests covering every function. + +Two conditions make this the default rather than a shortcut, and both are load-bearing. + +**Immediately, per function — not at the end.** The empirical record supports test-last +only in its *iterative* form: controlled experiments with professionals put TDD's advantage +over iterative test-last at "small and in practice relatively unimportant", with one study +finding the test-last group more productive and writing *more* tests. Nothing supports +writing the tests at the end of the feature. Finish a function, test that function, move on. + +**The test comes from the requirement, not from the code.** This is where an agent fails +differently from a human. A human writing tests late tends to write too few; an agent tends +to write tests that assert **what the code does** rather than what it should do — green +tests that faithfully encode the bug. So when writing a Unit test, the reference is the +requirement the task cites (§10), and reading the implementation to decide what to assert is +the failure mode, not the method. + +The default because most code is plumbing: the shape of the thing is not in doubt, and tests +written straight after it are cheaper and just as binding — as long as none are missing and +none merely mirror the code. Those two clauses are the whole risk of code-first, and the +rules the orchestrator is accountable for (§5). + +### TDD (RED/GREEN required) + +Write the failing test first, **watch it fail (RED)**, then make it pass (GREEN), +then refactor. Skipping RED is not TDD — a test that has never failed has not been +shown to test anything. + +RED/GREEN belongs to TDD and only to TDD. It is not a stricter Unit, and Unit is +not a lazier TDD: they are two different cycles, and the annotation on the task +says which one is in effect. + +Mandatory when the cost of being wrong is high: + +- **money** — any calculation, rounding, split, or conversion involving currency +- **complex algorithms** — anything whose correctness is not obvious by reading it +- **hypothesis / thesis validation** — code written to prove something holds +- **anything else** where the complexity is real and the chance of getting it + wrong is high + +The trigger is risk, not size. A three-line rounding helper that touches money is +TDD; a two-hundred-line CRUD handler is Unit. + +## 4 · Task grammar + +The same grammar governs every task line, whether it sits in a spec's `tasks.md` or in a +plan's checklist — the methodology is a property of the task, not of the vehicle that +carried it. + +The annotation is **required**: a task with no methodology is a task where nobody +decided, which is exactly the failure this design exists to prevent. + +``` +- [ ] 1.1 (Unit) Parse the manifest file — R1.2, R1.4 +- [ ] 1.2 (TDD) Calculate the pro-rata split across accounts — R2.1 +- [ ] 1.3 (Unit) Render the summary table — R3.1 +``` + +`(Unit)` / `(TDD)` — required, exactly one per task. That is the whole grammar: +there is no parallel-dispatch marker, because implementation is sequential (§8). + +**Requirements are cited after an em dash**, by the `R.` IDs +`requirements.md` numbers them with (§10). Required in a spec's `tasks.md`, since that +citation is the only thing that makes the traceability check in §5 possible; omitted in +a plan, which has no requirements to cite. The syntax greps cleanly, never collides with +the task's own number, and survives a reader who has never seen this document. + +`scc` validates the grammar and exits `2` when a task is missing its methodology, so no +work — spec or plan — reaches implementation with the decision unmade. + +### How big is a task — independently verifiable + +**A task is the right size when it can be verified on its own.** Not "one file", not "an +hour": verifiable alone, which is what makes §7's per-task loop (scoped tests + lint) +possible at all. + +The measured reason to care: subtask success rates run substantially higher than whole-task +success rates — agents execute individual steps correctly and still fail end-to-end +workflows — and structuring work so a failure can be retried at the subtask level cut retry +cost by ~73% against retrying a whole plan. Granularity is not tidiness; it decides what a +failure costs. + +Too coarse and a red result tells you a feature is broken. Too fine and the checklist becomes +bookkeeping about work smaller than the act of recording it. + +## 5 · What scc checks — and what it deliberately doesn't + +**scc checks structure.** That is the product: every artifact it governs has a shape, and +a shape is checkable. Findings exit `2`. + +| Validator | What it holds to a shape | +|---|---| +| **spec** | EARS grammar across all five patterns (§10) · requirements numbered and unique · one methodology per task (§4) · traceability: every requirement reaches a task, every task cites a requirement, nothing orphaned · delta markers well-formed | +| — | *Numbering is unique but deliberately **not** contiguous. A requirement removed through a delta (§10) leaves a hole, so a gap is the mechanism working rather than a defect, and reporting it would be a false positive on the practice this design asks people to use. An ADR gap is a finding; a requirement gap is not.* | +| **plan** | checklist grammar · every reference resolves to a real `specs/` entry · no item both carries a checkbox and references a spec (§1's one-source-of-truth rule) | +| **skill** | the published Agent Skills spec: `name` 1–64 chars, lowercase alphanumeric and hyphens, no leading/trailing or consecutive hyphen, **matching the parent directory** · `description` 1–1024 chars · body within the recommended budget · references one level deep | +| **wiki** | broken wikilinks · orphan pages · index/log desync · sources dropped in `docs/raw/` and never processed | +| **codewiki** | at `docs/codewiki/`, one page per area: every `[path:start-end]()` citation resolves against the checkout · slugs unique and derived from their headings · no section citing nothing | +| **adr** | numbering contiguous · superseded records marked rather than edited · `adr:` citations resolve | +| **glossary** | each concept has one canonical term · an avoided synonym used as a whole token is a finding | +| **stack** | every dependency in the project's manifest (`go.mod`, `package.json`, …) appears in `docs/stack.md` — a manifest is structured data, not source, so this is checkable without reading code | + +Two of these are worth calling out because they are stronger than they look. + +**`skill` has an external contract.** The Agent Skills spec is published and maintained +outside Anthropic (`agentskills.io`), and 32 tools across competing vendors read the same +format. So this validator is not scc's opinion about skills — it is conformance to a +standard, which is the most defensible kind of check we can ship. Implemented in Go rather +than delegating to the reference `skills-ref` validator, because scc is a single binary and +shelling out to Node would break that for a handful of regexes. + +The `Structure`-tree check an earlier draft listed for `codewiki` is **dropped**: the +design never settled what that tree looks like on disk, and a check written against a +guess is exactly the false positive that would cost the user's trust in the other three. +The three above are unambiguous. + +**`stack` turns a rule into a gate.** "Technology not listed in `docs/stack.md` is an open +decision, never adopted silently" reads like an unenforceable principle, but dependency +manifests are structured files. Diffing the manifest against `stack.md` catches the silent +dependency, and does it in any ecosystem that has a manifest. + +### The discipline eight validators require + +A catalogue this size can destroy its own value. In studied static-analysis deployments +**35–91% of warnings are non-actionable**, false positives are the single most common reason +developers suppress warnings (34.4% of suppressions), and the two documented barriers to +adopting such tools are false positives and cumbersome configuration. Alert fatigue does not +discriminate: valid findings get ignored alongside the noise. + +Three rules, non-negotiable: + +1. **A false positive costs more than a miss.** One wrong finding teaches the user to + disbelieve all eight. When a check cannot be certain, it stays silent — the same trade §5 + already made about not parsing code, now with numbers behind it. +2. **Zero configuration to be useful.** Configuration is the other documented adoption + barrier. A validator that must be tuned first won't be. +3. **Few findings, each fixable.** A finding the user cannot act on is noise wearing a useful + shape. + +There is a reason to expect this to work: **structural checks have the lowest false-positive +rate of any class.** A `SKILL.md` whose `name` doesn't match its directory is not a judgment +call — it is true or false. The boundary in §5 protects trust in the tool, not only its scope. + +### What it deliberately doesn't check + +It does **not** parse your source to verify that each function has a test, and it does not +observe RED before GREEN. Those are **rules the orchestrator applies**, not gates the +binary enforces — the orchestrator decides which tests to run. + +This is the deliberate break from csdd, whose instinct was to make everything +deterministic. Two reasons it doesn't extend to code: + +- **A per-language parser makes scc own every ecosystem it touches.** Go is free + (`go/ast` is stdlib), and every other language is a parser to write, a dependency + to carry, and a grammar to chase. A tool that only works on Go workspaces is not + the tool being built here. +- **A wrong answer is worse than no answer.** A checker that misses a missing test + reports "clean" and the rule quietly stops existing. Judgment that knows it is + judgment beats a validator that is confidently incomplete. + +So the enforcement boundary is: **scc guarantees the decision was made and +recorded; the orchestrator is accountable for honoring it.** The annotation is what +makes that accountability legible — a reviewer reading the task list can see which tasks +claimed TDD and check them. + +## 6 · Where the rules live + +`.claude/rules/` is the home of the rules, as in csdd. The scaffolded `CLAUDE.md` +stays small and points at them. + +This is a token-budget decision, not organization: every Claude Code session pays +for `CLAUDE.md` in context, so the routing table and the two cycles do not belong +inline there. `.claude/rules/` files are read when the rule is relevant. + +The evidence for keeping `CLAUDE.md` short is **context rot**, not the frequently-cited +`AGENTS.md` efficiency study. Chroma's measurement across 18 frontier models found accuracy +degrading non-uniformly as input grows — 30–50% before the documented context limit, and +sooner on more complex tasks. Length has a cost in correctness, not just in tokens. (The +`AGENTS.md` study measured only wall-clock and output tokens and explicitly put quality out +of scope; the "three sections, under 150 lines" prescription attributed to it is not in it. +Don't cite it for a length rule.) + +**No `AGENTS.md`.** Claude Code doesn't read it natively, so shipping one means shipping an +import from `CLAUDE.md` too — a second file and an indirection to buy portability to tools +this product isn't for. Anyone who wants cross-tool context can write it themselves. + +## 7 · Agents — review only + +Two subagents ship with the workspace, and both of them read rather than write: + +| Agent | Role | +|---|---| +| **code-review** | Reviews the diff for correctness and quality. | +| **security-review** | Reviews the diff for security defects specifically. | + +Splitting review by lens is deliberate: a single reviewer asked for "everything" +reliably under-weights security, because the correctness findings are easier to +produce and crowd it out. + +Review is where a subagent genuinely earns its cost, and for the opposite reason +implementation doesn't (§8): **a cold context is the feature.** The author of a +change is its worst reader — they see what they meant. And a reviewer reads instead +of writes, so running one on a cheaper model costs far less than delegating +authorship would. + +### The orchestrator closes the loop on every task + +A task is not done when the code is written. Whoever built it — which is always the +orchestrator — finishes by verifying it, in this order: + +1. **Build the task** — Unit or TDD, per the annotation on the task line. +2. **Run the tests in its scope** — the tests covering what was just built, not the + whole suite. +3. **Run the lint** — the project's linter, as the best-practices layer that finds + the programming defects tests don't: unused code, unchecked errors, shadowed + variables, unsafe conversions. +4. **Move on, or fix** — a red task is not finished. + +**Scope, not suite,** because per-task feedback needs to be fast and attributable. +Running everything after each task buys little: the failure that a full run catches +and a scoped run misses is breakage *between* tasks, and that is worth looking for +once the work is integrated, not N times along the way. The full suite runs at the +end of the spec (or of each of a plan's leaves). + +**Tests and lint are both required, because they answer different questions.** The +tests say the code does what the task asked. The lint says the code is written the +way this project writes code. A task that passes its tests with an unchecked error +in it is not finished, and neither check substitutes for the other. + +The linter is whatever the project already uses — scc does not own it, for the same +reason it does not parse source (§5). + +### The file is the record; the todo list is the session + +Claude Code already tracks work in progress with its own checklist, and the +orchestrator drives each task through it — one item in progress at a time, marked off +as it goes. That is the execution surface, and scc does not replace it. + +The two must not drift apart: + +- **`specs//tasks.md`**, or the checklist in **`plans/.md`**, is the + durable record. It survives the session, it is what gets reviewed and committed, and it + is what scc validates. +- **The session checklist** is working state. It is where the orchestrator tracks the + task it is on right now. + +So checking an item off in the session means checking the `- [ ]` box in the file too. A +session that ends with the checklist complete and the file untouched has lost everything +except the code — the next session has no idea which tasks were done, and neither does +the reviewer. + +This is the same reason scc has no daemon and no database (§0): Claude Code's own +mechanisms are the interface, and the Markdown is the memory. + +## 8 · Why implementation is sequential + +**The orchestrator writes the code. There is no implementation subagent, and no +parallel dispatch.** This was designed the other way first — a task-level +implementation agent, worktree per task, four layers of isolation to keep concurrent +runs from poisoning each other — and then rejected. Recording why, so it doesn't get +re-proposed: + +- **The only upside was wall-clock.** Everything else about delegating implementation + was a cost. +- **It puts the weakest participant on the hardest work.** Subagents typically run a + cheaper model. Delegating implementation hands off *writing the code* — the part + that most needs capability — while the orchestrator keeps routing, which needs the + least. That is backwards. +- **Every agent pays for discovery again.** It starts with no context and rediscovers + the codebase. Within one spec, the orchestrator's accumulated context is the + *asset*, not clutter: it uses the right parser in task 1.2 precisely because it + wrote task 1.1. +- **File-disjointness is not independence, and the merge does not catch the + difference.** Two tasks touching no common file both need a `Money` type that + doesn't exist yet. Each agent creates its own, in its own file, with different + semantics. The merge is *clean*. You end up with two `Money` types and no signal + that anything went wrong. Sequential execution cannot produce this, because the + later task sees the earlier task's code. + +The last one is decisive. The isolation machinery was sound against tree +interference, edit conflicts, resource collisions, and cross-task breakage — and +blind to the failure that partial context actually causes. + +Sequential execution has a second benefit worth naming: nothing needs isolating, so +worktrees, per-agent resource namespacing, merge-conflict resolution, and +result attribution all stop being problems rather than being solved. + +None of this rules out parallelism as such — only *dispatched, task-level* +parallelism. Feature-level parallelism is real and supported: the user runs several +Claude Code sessions, one per feature, each in its own worktree, and merges them into +`main`. See §9, which lays out why that version survives the objections above. + +## 9 · Delivery — branch, worktree, PR + +Work does not happen on `main` and does not end with a green test run. It ends with a +pull request. + +### Branch in a worktree — one per session + +Each unit of work — a spec, or a plan's leaf — gets its own branch, developed in its +own **git worktree**. + +The reason is that **the user may run several Claude Code sessions at once**, one per +feature, and merge them all into `main` when they land. The worktree is what makes +that possible: session A on `feat/billing` and session B on `feat/auth` each get their +own directory, neither disturbs the other, and neither touches the checkout the user +is actually sitting in. A shared tree with `git switch` cannot do this at all — two +sessions would fight over one working directory. + +So parallelism is back, and it is worth being precise about why this version is fine +when §8's was not. **The user drives this one; the orchestrator drove that one.** Four +differences, and they all matter: + +| | §8 — rejected | §9 — this | +|---|---|---| +| Unit of work | one task | one feature | +| Who splits it | the orchestrator, guessing from file overlap | the human, who knows the domain | +| Who does the work | a subagent, typically a cheaper model | a full session, same capability as any other | +| Discovery cost | re-paid per task | paid once per feature, amortized across it | + +The `Money` failure that killed §8 is the clearest case. Two *tasks* in one spec both +needing a type that doesn't exist yet is routine — they are neighbors by construction. +Two *features* a human deliberately separated are much less likely to collide, and +within each session the accumulated context prevents it outright. The risk drops from +"expected" to "possible", and the human picked the split, so it is their call to make. + +### What running several sessions still costs + +Worktrees isolate files. They do not isolate the world outside them, so two of these +concerns from §8 survive at feature granularity and should be said plainly: + +- **Shared external resources.** Two sessions running the suite at the same time will + fight over a fixed port, one test database, or a shared temp path. Either the suite + namespaces those per worktree, or the test runs have to be serialized. Nothing about + a worktree fixes this. +- **Cross-feature breakage.** Two features green on their own branches can be broken + together. Only CI on `main` after the merge sees that — which is a good reason to + care about the merge order and about `main` staying green. + +Both are the user's to accept: they chose to run two sessions. Naming them is the +point, so the cost is visible before it is paid. + +### The delivery sequence + +Once the last task is done: + +1. **Full suite + lint** on the integrated branch — the per-task scoped runs (§7) + cannot see breakage between tasks. +2. **`code-review` and `security-review`** on the diff (§7), and fix what they find. + The PR should arrive already reviewed; a PR is for the human, and spending their + attention on findings a subagent would have caught is the waste this ordering + avoids. +3. **Commit and push** — Conventional Commits, generated from the diff and the spec. +4. **Open the PR.** + +### Waiting for CI is a question, not a policy + +CI is the one part of this the orchestrator cannot make faster, and its runtime varies +from thirty seconds to thirty minutes. So the orchestrator **asks**: + +- **Wait** — it watches the PR's checks until they settle. Red means fix, push, and + keep watching; the work is not finished while CI is failing. +- **Don't wait** — opening the PR is the finish line. CI runs; the human picks it up. + +Recorded as `ci: wait | no-wait` in the same frontmatter block as §2's answer. + +Ask this at kickoff, together with §2's autonomy question, not when the PR is open. +By then the work is done and the user may well be gone — which is exactly the +situation "don't wait" exists for, and exactly when a blocking question is most +expensive. Both answers get recorded on the artifact for the same reason: the run +stays reproducible from the file, and nobody gets asked twice. + +### Degrading + +- **No remote, or no `gh`** — commit on the branch and stop there, saying so. A branch + the user can push themselves is a real deliverable; silently skipping the PR is not. +- **Worktrees left behind accumulate.** Remove the worktree once its branch is merged; + keep it if it still holds unmerged work, and say which. + +## 10 · The three spec artifacts + +§1 routes work into a spec at `specs//`; this is what those three files +actually are. + +### requirements.md — EARS, all five patterns + +Requirements use EARS (Alistair Mavin et al., Rolls-Royce, IEEE RE 2009), numbered +`R.` and written as list items so the later phases can cite them: + +```markdown +- **R1.1** The billing engine shall compute the order total +- **R1.2** When a coupon is applied, the billing engine shall recompute the total +``` + +Only lines in that position are parsed. Prose elsewhere in `requirements.md` is not a +requirement and gets no findings — the alternative is a validator that fires on the +document's own introduction. This is not ceremony — it is the only reason `scc` can check a +requirement at all. Prose has nothing to validate; an EARS clause has named parts, and a +missing one is a finding. + +The ruleset: **zero or many preconditions · zero or one trigger · one system name · one or +many responses**, and the clauses always appear in that order. That yields five patterns +plus combinations: + +| Pattern | Shape | +|---|---| +| Ubiquitous | `The shall ` | +| State-driven | `While , the shall ` | +| Event-driven | `When , the shall ` | +| Optional feature | `Where , the shall ` | +| Unwanted behavior | `If , then the shall ` | +| Complex | more than one keyword — `While , when , the shall ` | + +**All five are valid and the validator must accept all five.** An earlier draft of this +document described EARS as event-driven only (`WHEN … THE SYSTEM SHALL …`). A validator +built on that would reject four legitimate patterns, and — worse — would push authors to +invent a trigger for a requirement that is simply always true. Unwanted behavior (`If … +then …`) is the pattern most often missing from generated requirements, and the one that +most often matters. + +### Changing an existing spec — deltas, not rewrites + +A change to a spec that already exists is written as a **delta** against it, with each +affected requirement marked `ADDED`, `MODIFIED`, or `REMOVED` (the shape OpenSpec settled +on for brownfield work) — **as a marker on the requirement line itself**, in +`requirements.md`, not in a separate file: + +```markdown +- **R2.3** (MODIFIED) When the cart is empty, the checkout shall skip the summary +- **R2.7** (ADDED) If the coupon has expired, then the checkout shall reject it +- **R1.4** (REMOVED) +``` + +Inline rather than a `deltas/` directory, because the second reason below is about two +sessions touching *different requirements in one file* — which a marker per requirement +gives directly, and a separate file per change does not. A `REMOVED` requirement is a +statement that it is gone: nothing is left to hold to EARS, and no task has to reach it. + +Three reasons, and the second is the one that decides it: + +1. **You specify the change, not the system.** Adopting scc on an existing codebase must + not require writing the spec for everything that already works. Specs grow one change + at a time, filling in around the work actually done. +2. **Deltas scoped to individual requirements make concurrent edits safe.** Two sessions + (§9) can change the same spec without conflicting, as long as they touch different + requirements. Whole-file rewrites collide on contact. +3. **A reviewer reads intent instead of reconstructing it.** The delta says what changed + and why; a raw diff makes them infer it. + +The delta is how a change is *proposed and reviewed*. Once it lands, it is folded into the +spec — the spec stays the current statement of the feature, never an append-only log of +deltas. + +**Requirements are subject to "omit, don't fill" too.** Structured requirements measurably +improve generated code — adding structured specification to a natural-language baseline +significantly improves output quality — but the curve is not monotonic: over-specification +can constrain the model's reasoning or introduce requirements that conflict with each other, +and correctness drops. EARS structure, yes; exhaustive enumeration of everything the feature +might touch, no. A requirements document that specifies more than the feature actually +decides is not merely long — it can produce worse code than a shorter one. + +### design.md — scaled by complexity + +**The design must fit the decision being made.** The common failure is a design that +invents architecture for a change that had no architectural question in it: a +component diagram for a two-function addition, a data-model section for something that +touches no data. + +That is worse than verbose. **Invented architecture constrains.** The next person — or +the next session — reads it as a decision somebody made, and honors it. Filler becomes +binding. + +So the sections are conditional, and the rule is **omit, don't fill**: + +| When the change… | design.md carries | +|---|---| +| decides nothing structural | what changes, where, and why — a few paragraphs. No components, no diagram. | +| moves a boundary, a data shape, or an external contract | those sections only, for the parts that actually change. | +| has real alternatives with trade-offs | the alternatives and why one won — and an ADR if the decision is hard to reverse. | + +A heading filled with "N/A", or with prose written to satisfy the heading, is worse +than an absent heading: it reads as a decision, and no one can tell it apart from one. + +**This constrains scc more than it constrains the orchestrator.** A required heading is +a request for filler — a template shipping an `## Architecture` section *causes* the +behavior being complained about, and a validator demanding that section makes it +mandatory. So the design template marks those sections clearly optional, and validation +checks that the design exists and traces to its requirements. It never checks that a +particular section is present. + +### tasks.md — the grammar in §4 + +Exactly one methodology per task, `(Unit)` or `(TDD)`, validated. Tasks cite the +requirements they satisfy, which is what makes §5's traceability check possible. + +## 11 · The spec is anchored, not disposable + +A spec does not stop being true when its feature merges. **Work that touches an area a +spec covers updates that spec as part of the delivery** — as a delta (§10), in the same +branch, in the same PR as the code. + +The field has a name for this choice. Three tiers are in circulation: **spec-first** (specs +precede code, then are discarded), **spec-anchored** (specs persist and evolve with the +code), and **spec-as-source** (only specs are edited; code is generated). scc is +**spec-anchored**, and the reason is the same one that made the artifact mandatory in the +first place (§1): under autonomy, the file is the only record of intent. A record that +stops being maintained stops being a record — and a stale requirement read as current is +worse than an absent one, because it is believed. + +What that does *not* mean: + +- **No mechanical drift detection.** Verifying that a spec still matches the code requires + understanding the code, which §5 rules out. The literature's version of spec-anchored + includes automated drift checks; ours doesn't, and that is a deliberate downgrade rather + than an oversight. Keeping the spec current is the orchestrator's obligation, checked by + the reviewer, not proven by the binary. +- **Not permanence for its own sake.** A feature genuinely deleted takes its spec with it. + Anchored means *maintained while the code exists*, not *never removed*. + +The knowledge base stays separate and keeps its own job. `docs/wiki/` and `docs/adr/` +answer *why* — the durable reasoning, the decisions, the material read from outside. The +spec answers *what this feature does now*. Neither replaces the other, and the spec being +anchored is what keeps them from having to. + +## 12 · Upgrading a workspace — three-way merge, not backups + +A new scc version ships improved templates. Bringing a workspace up to it must never lose +what the user changed, and **must not make them re-merge by hand**. + +The mechanism, borrowed from Copier rather than from csdd: re-render the templates **as the +old version produced them**, re-render the **new** version, diff those two, and apply that +diff to the working tree as a **three-way merge**. Local edits survive unless they collide on +the same lines, and a collision surfaces as **standard git conflict markers**. + +Why that beats csdd's numbered `.old` backups: a backup file preserves the user's work and +then hands them the merge as homework. Conflict markers put the resolution in the tool they +already use for exactly this, and it is the same mechanism §9 relies on for branches — one +conflict idiom in the whole product instead of two. + +**This constrains the manifest.** A three-way merge needs the old rendered *text*, not just +its hash. scc embeds its templates, so it can re-render any past version — but only if it +knows which version produced each file. So `.claude/scc-manifest.json` records **a version +per entry alongside the content hash**: the hash answers "did the user edit this?", the +version answers "what did it look like before?" Both are required, and a manifest carrying +only hashes cannot support this. + +Files that exist to be edited once and then owned by the user are **excluded from the merge** +rather than merged badly. + +## Open questions + +- **Does a Plan's decomposition get validated for coverage** — i.e. can scc tell + that the specs and tasks a plan produced actually cover the plan? This is an + artifact-level question, so unlike the code checks above it is fair game. +- **What records the routing decision?** The choice of Spec vs Plan is a judgment call; + whether it carries a written justification (auditable, verbose) or stays implicit in + which artifact exists (cheap, unaccountable) is undecided. +- **What happens to a spec after its feature merges?** It stops describing the code and + starts describing what was once intended — and a stale requirement read as current is + worse than an absent one. csdd keeps `specs/` forever. The alternative is that the + spec is scaffolding: durable knowledge migrates into `docs/wiki/` and the decisions + into `docs/adr/` (both of which scc keeps), after which the spec can be archived or + deleted without losing anything. That would make the knowledge base the permanent + record and the spec a working document — but it needs a rule for *when* the migration + is due, or it never happens. diff --git a/design/plan.md b/design/plan.md new file mode 100644 index 0000000..40b1d52 --- /dev/null +++ b/design/plan.md @@ -0,0 +1,572 @@ +# Implementation plan — from `orchestration.md` to a shipped binary + +`orchestration.md` says what scc *is*. This says in what order it gets built, what +each step delivers, and where the design still owes an answer before code can be +written. + +It is a plan in the §1 sense — a decomposition, plus a checklist for the items it +just does itself — but it lives in `design/` rather than `plans/` because this repo +is not an scc workspace (no `.claude/`, `specs/`, or `docs/` are committed). When +scc can scaffold itself, this file migrates. + +Section references (§n) are to `orchestration.md` throughout. + +**Status: phases 1–10 are built and green; phase 11 (`scc update`) is not started.** +The command surface is `init`, `spec new|list|show|delete|validate`, +`plan new|list|delete|validate`, `skill validate`, `validate`, `version`, `help`. §9 +records what the build settled that the plan left open, and where it deviated. Every +decision listed in §5 has been folded back into `orchestration.md`. + +## 0 · Ground rules for every phase + +- **`make check` stays green at every commit.** gofmt + vet + `go test -race`, on + Linux/macOS/Windows. +- **One phase ≈ one to three PRs**, Conventional Commits scoped by package + (`feat(manifest):`, `feat(cli):`). No phase lands half a package. +- **`go.mod` stays stdlib-only.** Three places below would be easier with a + dependency — YAML frontmatter, diff3, TOML manifests — and each one is solved + narrowly instead (§0's "every dep is a supply-chain surface"). +- **Nothing reads source code.** The enforcement boundary in §5 is a design + constraint on this plan, not a later concern: no phase adds a parser for a + project's own language. +- **Every validator ships with its false-positive argument.** §5's discipline + ("a false positive costs more than a miss") is checked in review by asking, per + check, *what input makes this wrong?* If the answer isn't "none", the check + stays silent instead. + +## 1 · Where the code already is + +Green and done: build/test/release infrastructure, and the four foundation +packages — `paths`, `workspace`, `render`, `textutil`. The command surface is +`version` and `help`. `cli.Run` returns an exit code and never calls `os.Exit`, +so the whole surface is drivable in-process from tests. + +What that gives this plan for free: root resolution via the marker file, hostile-input +name checks (`SafeName`, `KebabCheck`), atomic writes, LF/BOM normalization, the +`0/1/2` exit contract, and the `--json` convention (`addJSON` + `emitJSON`). + +Two gaps in the current state worth naming, both closed in Phase 2: `paths` has no +`.claude/rules/` segment and no `docs/` segments, and nothing embeds a template yet. + +## 2 · The surface being built + +| Command | Phase | What it does | +|---|---|---| +| `scc init` | 2 | Scaffold the workspace; write the manifest. Idempotent. | +| `scc spec new\|list\|show\|delete` | 4 | Create and manage `specs//`. | +| `scc plan new\|list\|delete` | 4 | Create and manage `plans/.md`. | +| `scc skill validate` | 6 | Agent Skills conformance. | +| `scc spec validate` | 7 | EARS, numbering, one methodology per task, traceability, deltas. | +| `scc plan validate` | 8 | Checklist grammar, references resolve, one source of truth. | +| `scc wiki\|adr\|glossary\|stack\|codewiki validate` | 9 | The knowledge base. | +| `scc validate` | 10 | Every validator, one exit code, one JSON document. | +| `scc update` | 11 | Three-way merge onto a new template version (§12). | + +Resource-first, matching the dispatcher's existing shape: `Run` switches on +`args[0]`, hands off to `run` in a file named for that resource, and +each handler owns its `flag.FlagSet`. + +New packages, in dependency order: + +| Package | Phase | Role | +|---|---|---| +| `internal/manifest` | 1 | Entries of `{path, hash, version}`; deterministic (de)serialization; pristine-vs-edited. | +| `internal/assets` | 1 | The embedded template set (`//go:embed`) and its rendering. | +| `internal/finding` | 1 | One finding type and one JSON shape for all eight validators. | +| `internal/scaffold` | 2 | Apply the template set to a root, recording what it wrote. | +| `internal/mdscan` | 5 | Fence-aware Markdown scanning: headings, checkboxes, links, frontmatter, slugs. | +| `internal/ears` | 7 | Parse a requirement into EARS clauses; all five patterns plus complex. | +| `internal/validate` | 6–10 | One file per validator, sharing `mdscan` and `finding`. | +| `internal/merge` | 11 | Line-level diff and diff3 with git conflict markers. | + +`internal/validate` is one package with a file per validator rather than eight +packages, so the shared helpers (resolve a path, read-and-normalize, emit a +finding at a line) stay unexported and identical across all eight — which is what +keeps message wording and finding shape from drifting. + +## 3 · Why this order + +Three constraints fix the sequence almost completely. + +**The manifest is upstream of everything that writes.** `init` cannot record what +it wrote without it, and `update` cannot tell a pristine file from an edited one. +So Phase 1. + +**Scaffolding mechanism before scaffolded content.** The two fail differently and +review differently: `init` is code (idempotence, atomicity, Windows paths), the rules +and templates are prose that has to be read line by line. Splitting them keeps a +400-line Markdown PR from hiding a bug in the writer, and vice versa. + +**Validators need their subject to exist.** `spec validate` is testable against +fixtures on day one, but the check that actually matters — that the *shipped* +templates and skills pass their own validators — needs the content from Phase 3. +Within the validators, order by increasing false-positive risk: `skill` is an +external, mechanical contract; `codewiki` resolves citations against a live +checkout and is the most likely to be wrong. + +`update` goes last for a reason that isn't difficulty: **an upgrade is untestable +until two template versions exist.** Shipping it before there is a real diff +between v0.1.0 and v0.2.0 means shipping it tested only against synthetic +history. + +## 4 · The phases + +### Phase 1 — Foundations (3 PRs) + +**1.1 `internal/manifest`.** `Entry{Path, Hash, Version}`, a manifest holding +entries sorted by path, `Load`/`Save` (through `workspace.AtomicWrite`), +`Hash(content)` as SHA-256 over `textutil.NormalizeNewlines`, and +`Status(root, entry) → Pristine | Edited | Missing`. + +Load-bearing details, each of which is a test: + +- **Paths are stored slash-separated and relative to the root.** A manifest + written on Windows must be read on Linux — this is the one place where the + layout crosses machines. +- **Serialization is deterministic.** Sorted keys, LF, trailing newline: the same + workspace on two machines produces a byte-identical manifest, or every upgrade + shows a spurious diff. +- **The hash is of the *pristine render*, not of the file on disk.** That is what + makes "did the user edit this?" answerable, and Phase 11 depends on it being + this and not the other thing. +- **Unknown fields are preserved, not dropped.** An older binary reading a newer + manifest must not silently delete what it doesn't understand. + +**1.2 `internal/assets`.** `//go:embed templates/**` plus `Render(name, data)`, +normalizing to LF. A golden test asserts every embedded file is LF-clean, non-empty, +and ends in a newline. + +One decision here that Phase 11 leans on: **scaffolded templates are data-free.** +No project name, no date, no path interpolated into a rule or a spec template. Then +a template version renders byte-identically in every workspace, its hash identifies +its content globally, and an upgrade can reconstruct the old text from the manifest +alone. The files that genuinely want per-project content — `CLAUDE.md`, and the rule +holding the project's test and lint commands — are exactly the files §12 excludes +from the merge because the user owns them, so the two rules agree instead of +fighting. + +**1.3 `internal/finding`.** `Finding{File, Line, Rule, Message}`, a set with `Add` +and a stable sort by `(file, line, rule)`, a frozen JSON shape +(`{"findings": [...], "count": n}`), human output through `render`, and +`ExitCode()` returning `ExitOK` or `ExitFindings`. `Rule` is a stable slug +(`ears.missing-shall`, `skill.name-mismatch`) so a CI job can filter without +matching prose. + +### Phase 2 — `scc init`, the mechanism (2 PRs) + +**2.1 Layout.** Add to `paths`, in one commit: `RulesSeg = "rules"` and the `docs/` +segments (`wiki`, `adr`, `raw`, `codewiki`, `glossary.md`, `stack.md`). This is the +commit CLAUDE.md is waiting for — "the domain segments land there as `design/` +settles". + +**2.2 `internal/scaffold` + `cli/init.go`.** Walk the embedded set, write each file +that is missing, record every managed file in the manifest, report what it did. + +- **Idempotent.** A second `init` writes nothing and exits `0`. +- **Never overwrites.** An existing file is skipped whether it is pristine or + edited — §0's "never author what the user owns". `--force` overwrites, and names + every edited file it is about to clobber. +- **The manifest is written last**, so a crashed `init` leaves no workspace marker + and the retry is clean rather than half-adopted. +- Flags: `--root`, `--json`, `--force`. + +Tests: init into `t.TempDir()`; every expected path exists; `workspace.Find` from a +nested subdirectory resolves back to it; second run is a no-op; an edited file +survives; `--json` reports per-file actions; compare resolved paths with +`os.SameFile`, never string equality. + +### Phase 3 — The template set (2–3 PRs) + +The product's actual content. `.claude/rules/`, one file per concern, because §6's +whole point is that a rule is read when it is relevant: + +| File | Carries | +|---|---| +| `routing.md` | §1 — the two vehicles and the routing question. | +| `autonomy.md` | §2 — ask once at kickoff; automatic still escalates on `(TDD)` risk. | +| `methodology.md` | §3 — impact analysis first, then Unit or TDD; when TDD is mandatory. | +| `tasks.md` | §4 — the grammar, and "independently verifiable" as the size rule. | +| `verification.md` | §7 — build, scoped tests, lint, fix; scope not suite. | +| `delivery.md` | §9 — branch in a worktree, review before the PR, the CI question, degrading. | +| `specs.md` | §10–§11 — EARS, deltas, design scaled by complexity, spec-anchored. | +| `knowledge-base.md` | `docs/` — what belongs in the wiki, in an ADR, in the glossary, in the stack. | +| `project.md` | The project's own test and lint commands. Shipped as a stub, owned by the user from first edit. This file is why scc needs no config file. | + +Plus `CLAUDE.md` (small, links out — §6's context-rot argument is the reason, and +it is worth stating inside the file so nobody "helpfully" inlines the rules), the +two review agents from §7 (`code-review`, `security-review` — split by lens +deliberately), the spec templates (`requirements.md`, `design.md` with its optional +sections marked *optional*, `tasks.md`), and the plan template. + +Two things to get right here, both of which the design already argues: + +- **The design template must not require sections.** §10: a required heading is a + request for filler, and invented architecture constrains the next session. The + template marks structural sections optional and says why. +- **Shipped skills are validated by our own validator.** Whatever skills ship get a + test in Phase 6 asserting `scc skill validate` finds nothing in them. A tool that + ships non-conforming skills has no standing to check anyone else's. + +### Phase 4 — Creating specs and plans (2 PRs) + +`scc spec new|list|show|delete`, `scc plan new|list|delete`. + +- Every positional goes through `SafeName` **and** `KebabCheck` before any + `filepath.Join` — `scc spec delete ..` must not resolve to the workspace root. +- Commands require a workspace (`workspace.IsWorkspace`) and otherwise exit `1` + saying to run `init`. +- Creating an existing artifact fails with `workspace.ErrAlreadyExists` unless + `--force`. +- `list` output is sorted; `--json` on all of them. + +**Spec and plan files are not recorded in the manifest.** They are authored from +birth — created from a template and then owned — which is precisely §12's excluded +category. The manifest stays a record of *scc-managed* files, which is what keeps +`update` from ever touching a requirement. + +`new` also takes `--autonomy=auto|gated` and `--ci=wait|no-wait`, written into the +artifact's frontmatter. See §5 below — this is a decision this plan makes, not one +the design states. + +### Phase 5 — `internal/mdscan` (1 PR) + +Every validator reads Markdown; exactly one package knows how. A line-based, +fence-aware scanner over `textutil.NormalizeNewlines`d input: headings with level +and line number, fenced-code ranges, list items and checkboxes, links and +wikilinks, GitHub-compatible slugs, and a deliberately small frontmatter parser +(flat `key: value`, quoted strings, simple inline lists — not YAML, and it rejects +what it doesn't understand rather than guessing). + +This is where false positives are prevented for all eight validators at once, so the +tests are the point: a `- [ ]` inside a fenced block is not a task, a `[[link]]` in +a code span is not a wikilink, a `#` inside a fence is not a heading, CRLF input +behaves identically to LF, and a tab-indented fence still closes. + +### Phase 6 — `scc skill validate` (1 PR) + +The Agent Skills contract from §5: `name` 1–64 chars, lowercase alphanumeric and +hyphens, no leading, trailing, or consecutive hyphen, **matching the parent +directory**; `description` 1–1024 chars; body within the published budget; +references one level deep. Walk `.claude/skills/*/SKILL.md`. + +Read the published spec for the exact budget number rather than reproducing one from +memory — the value of this validator is that it is somebody else's standard, and a +number we invented would forfeit that. + +First validator because it is the most mechanical: a `name` that doesn't match its +directory is true or false, never a judgment call. It also freezes the finding shape +under real use before seven more validators depend on it. + +### Phase 7 — `scc spec validate` (2 PRs) + +**7.1 `internal/ears`.** Parse a requirement line into +`{preconditions[], trigger?, system, responses[]}` and accept **all five patterns +plus combinations** — ubiquitous, state-driven (`While`), event-driven (`When`), +optional feature (`Where`), unwanted behavior (`If … then`). §10 is explicit that a +validator built on the event-driven pattern alone would reject four legitimate +patterns and push authors to invent triggers for requirements that are simply always +true. Clause order is part of the grammar and is checked. + +Only lines in the numbered-requirement position are parsed. Prose elsewhere in +`requirements.md` is not a requirement and gets no findings — the alternative is a +validator that fires on the document's own introduction. + +**7.2 The spec validator.** Numbering unique and contiguous, exactly one `(Unit)` or +`(TDD)` per task (§4 — the annotation is required, and this is the check that keeps +work from reaching implementation with the decision unmade), and traceability in both +directions: every requirement reaches at least one task, every task cites at least +one requirement that exists, nothing orphaned. + +Traceability needs a citation syntax the design doesn't fix. See §5 below. + +Delta markers (`ADDED`/`MODIFIED`/`REMOVED`) are deferred within this phase until +the design says where a delta lives — also §5. + +### Phase 8 — `scc plan validate` (1 PR) + +Checklist grammar; every `specs/` reference resolves to a real directory; and §1's +one-source-of-truth rule — no item both carries a checkbox and references a spec, +because then the same fact has two records and the copy is the one that goes stale. + +Coverage validation ("do the specs a plan produced actually cover the plan?") is an +open question in the design and stays out until it is answered. + +### Phase 9 — The knowledge base (4 PRs) + +| Validator | Checks | Note | +|---|---|---| +| `wiki` | Broken wikilinks, orphan pages, index/log desync, unprocessed `docs/raw/`. | All four are graph facts over Markdown — no code read. | +| `adr` | Numbering contiguous, superseded records marked rather than edited, `adr:` citations resolve. | Smallest of the four. | +| `glossary` | One canonical term per concept; an avoided synonym used as a whole token. | Whole-token matching only — substring matching here is a false-positive generator. | +| `stack` | Every dependency in the project's manifest appears in `docs/stack.md`. | `go.mod` (hand-parsed `require` blocks) and `package.json` (`encoding/json`) first. | +| `codewiki` | `[path:start-end]()` citations resolve against the checkout, `Structure` agrees with the section delimiters, slugs unique and derived from headings, no section citing nothing. | Heaviest and last. | + +On `stack`: skip any ecosystem whose manifest can't be parsed confidently without a +dependency — an unparsed manifest produces no findings, which is the correct +behavior under §5's first rule. TOML ecosystems wait for a safe parse, not for a +naive one. + +### Phase 10 — `scc validate` (1 PR) + +Run every applicable validator, merge findings into one sorted set, exit `2` if any, +and emit one JSON document. Skip validators whose subject is absent — a workspace +with no `docs/codewiki/` is not a workspace with findings. + +This is also where the aggregate output has to stay readable: §5's third rule ("few +findings, each fixable") is easiest to violate here, so the human output groups by +file and leads with counts. + +### Phase 11 — `scc update` (2 PRs) + +**11.1 `internal/merge`.** Line-level diff (Myers) plus diff3, emitting standard git +conflict markers on collision. In-tree rather than shelling out to `git merge-file`: +`update` must work in a directory that isn't a git repository and on a machine +without git, and the algorithm is small, deterministic, and fully unit-testable. +This is the largest single piece of new logic in the plan and it is pure — no +filesystem, no workspace — so it gets the heaviest test suite: identical inputs, +one-sided edits both ways, adjacent non-overlapping edits, true conflicts, empty +files, no-trailing-newline, and CRLF input. + +**11.2 The command.** Per managed entry: re-render the template **as the recorded +version produced it**, re-render the current version, diff those two, and apply that +diff to the working tree as a three-way merge (§12, borrowed from Copier). Pristine +files are replaced outright. Edited files are merged, and collisions surface as +conflict markers — the same idiom §9 already uses for branches, so there is one +conflict mechanism in the product instead of two. + +**Where the old text comes from.** §12 says the merge needs the old rendered *text*, +not just its hash. Because Phase 1.2 made templates data-free, the pristine content +of a managed file is fully determined by its template version — so `assets` carries a +content-addressed store of historical renderings, keyed by the very hash the manifest +already records. The manifest needs no new field, and the version it records stays +useful as the diagnostic that explains *why* a file looks the way it does. A template +version that changed nothing adds nothing to the store. + +Files excluded from the merge (`CLAUDE.md`, `project.md`) are reported as +"yours — check the new template if you want the change", never merged badly. + +## 5 · Decisions this plan makes, and gaps it can't close + +Four of these are decisions taken here because the code needs them and the design is +silent; each should be folded back into `orchestration.md` when it lands. Two are +genuine gaps that block a specific check. + +**Decided: the autonomy and CI answers live in frontmatter.** §2 and §9 both say the +answer is "recorded on the artifact" without saying where. Frontmatter on the spec's +`requirements.md` and on the plan (`autonomy: auto|gated`, `ci: wait|no-wait`), set by +`spec new`/`plan new` flags. Frontmatter because it is the one part of a Markdown +artifact that is already machine-readable, and flags because the orchestrator asked +the question in conversation and needs a way to write the answer down. Validated only +if present, never required — a missing key means the run predates the convention, not +that the user did something wrong. + +**Decided: templates are data-free.** Argued in Phase 1.2; it is what makes Phase 11 +possible without storing rendered text in the manifest. + +**Decided: user artifacts are not in the manifest.** Argued in Phase 4. + +**Decided: task-to-requirement citation syntax.** Traceability (§5) is unbuildable +without one. Proposal, added to §4's grammar and to both templates: + +``` +- **R1.2** When the manifest is missing, the CLI shall exit 1 with a message. +- [ ] 1.1 (Unit) Parse the manifest file — R1.2, R1.4 +``` + +Requirement IDs are `R.`; a task cites them after an em dash. It greps +cleanly, it never collides with the task's own number, and it survives a reader who +has never seen this document. Requires one line in §4 and one in §10. + +**Gap — where does a delta live?** §10 says a change to an existing spec is written +as a delta with each requirement marked `ADDED`/`MODIFIED`/`REMOVED`, and that it is +folded into the spec once it lands. It does not say whether the delta is a separate +file (`specs//deltas/.md`) or inline markers on requirement lines +awaiting fold-in. The validator's "delta markers well-formed" check can't be written +until this is answered; the rest of Phase 7 doesn't depend on it. **One paragraph in +§10 unblocks it.** + +**Gap — what is a codewiki, on disk?** §5 specifies the codewiki validator precisely +but the design never states the artifact's path or whether it is one file or a tree. +Phase 2 has to name a segment; Phase 9 has to walk it. Currently assuming +`docs/codewiki/`. **One line in the design confirms or corrects it.** + +The design's own three open questions map onto phases as follows: **plan coverage +validation** gates a check inside Phase 8 and nothing else; **what records the +routing decision** would add a frontmatter key in Phase 4 if the answer is +"something written"; **what happens to a spec after its feature merges** affects the +wording of `specs.md` in Phase 3 and adds no code either way. None of them block the +start of any phase. + +## 6 · Milestones + +| Release | Phases | What the user has | +|---|---|---| +| **v0.1.0** | 1–4 | A template system: `init` scaffolds the rules and templates, `spec new`/`plan new` create work. No validation yet. Useful on its own — this is csdd's value without its ceremony. | +| **v0.2.0** | 5–8 | The enforcement half: skill, spec, and plan validation, exiting `2` on findings. This is the version a CI job can gate on. | +| **v0.3.0** | 9–10 | The knowledge base validated, and one `scc validate` over everything. | +| **v0.4.0** | 11 | `scc update` — three-way merge onto a new template version. | + +Between v0.1.0 and v0.4.0 the upgrade story is "re-run `init`; it never overwrites +what you edited". That is honest and it works, because `init` is idempotent and +additive by construction — it just doesn't deliver improved *templates* to files that +already exist. Say so in the README rather than letting a user discover it. + +## 7 · Risks, largest first + +**Authoring the rules is the long pole, not the code.** Phase 3 is prose that has to +be right — it is the product's actual behavior, and every word of it is read by an +agent that will follow it literally. Budget review time accordingly, and resist +padding: §6's context-rot evidence means a longer rule set is measurably worse, not +just more expensive. + +**diff3 is the one genuinely hard algorithm.** Mitigated by keeping `internal/merge` +pure and testing it exhaustively before `update` ever touches a workspace, and by +scheduling it after there is real version history to test against. + +**Eight validators can destroy their own value.** §5 quantifies it: 35–91% of +warnings non-actionable in studied deployments, false positives the most common +reason warnings get suppressed. The per-check question in §0 is the mitigation, and +the ordering in §3 (most mechanical first) means the trust-building checks ship +before the risky ones. + +**EARS parsing is the most likely place to be over-eager.** Restricting the parser to +lines in the numbered-requirement position is the main defense; the second is that an +unparseable line reports *that it could not be parsed as EARS*, which is actionable, +rather than guessing which clause is missing. + +**Windows.** The race detector doesn't run on the Windows job, so anything +concurrent gets its coverage on Linux. Slash-separated manifest paths (Phase 1.1), +delete-pending behavior in `spec delete` tests, and `.gitattributes` keeping the tree +LF are the three recurring traps. + +## 8 · Checklist + +Grammar per §4, since this file is itself a plan. + +``` +Phase 1 — foundations +- [x] 1.1 (Unit) internal/manifest: entries, deterministic (de)serialization, slash paths +- [x] 1.2 (TDD) internal/manifest: Hash over normalized content, Status pristine/edited/missing +- [x] 1.3 (Unit) internal/assets: embed the template tree, Render, LF-clean golden test +- [x] 1.4 (Unit) internal/finding: finding type, stable sort, frozen JSON shape, ExitCode + +Phase 2 — init +- [x] 2.1 (Unit) paths: rules segment and every docs/ segment, in one commit +- [x] 2.2 (Unit) internal/scaffold: write missing files, record the manifest, report actions +- [x] 2.3 (TDD) scaffold: idempotence and never-overwrite, including --force reporting +- [x] 2.4 (Unit) cli: scc init with --root/--json/--force + +Phase 3 — the template set +- [x] 3.1 (Unit) .claude/rules/: the nine rule files +- [x] 3.2 (Unit) CLAUDE.md and the two review agents +- [ ] 3.3 (Unit) shipped skills — none ship; the design never says which would (§9) +- [x] 3.4 (Unit) spec templates (optional sections marked optional) and the plan template + +Phase 4 — creating work +- [x] 4.1 (TDD) cli: spec new/list/show/delete, SafeName + KebabCheck on every positional +- [x] 4.2 (Unit) cli: plan new/list/delete +- [x] 4.3 (Unit) frontmatter: --autonomy and --ci recorded on the artifact + +Phase 5 — markdown +- [x] 5.1 (TDD) internal/mdscan: fence-aware headings, checkboxes, links, wikilinks, slugs +- [x] 5.2 (Unit) internal/mdscan: the small frontmatter parser, rejecting what it can't read +- [x] 5.3 (Unit) internal/mdscan: Body — comment- and fence-stripped lines for every validator + +Phase 6 — skill +- [x] 6.1 (TDD) validate: Agent Skills conformance against the published spec +- [x] 6.2 (Unit) cli: scc skill validate, exit 2 on findings +- [x] 6.3 (Unit) the shipped skills pass their own validator (vacuously true; see 3.3) + +Phase 7 — spec +- [x] 7.1 (TDD) internal/ears: all five patterns plus complex, clause order enforced +- [x] 7.2 (TDD) validate: numbering, one methodology per task, traceability both ways +- [x] 7.3 (Unit) cli: scc spec validate [] +- [x] 7.4 (Unit) validate: delta markers — unblocked by §9's decision on where a delta lives + +Phase 8 — plan +- [x] 8.1 (TDD) validate: checklist grammar, references resolve, one source of truth +- [x] 8.2 (Unit) cli: scc plan validate + +Phase 9 — knowledge base +- [x] 9.1 (TDD) validate: wiki — broken links, orphans, changelog desync, unprocessed raw +- [x] 9.2 (Unit) validate: adr — contiguous numbering, superseded marked, citations resolve +- [x] 9.3 (TDD) validate: glossary — canonical terms, whole-token synonym detection +- [x] 9.4 (TDD) validate: stack — go.mod and package.json against docs/stack.md +- [x] 9.5 (TDD) validate: codewiki — citations resolve, slugs unique, no section citing nothing + +Phase 10 — aggregate +- [x] 10.1 (Unit) cli: scc validate over every applicable validator, one JSON document + +Phase 11 — update +- [ ] 11.1 (TDD) internal/merge: line diff +- [ ] 11.2 (TDD) internal/merge: diff3 with git conflict markers +- [ ] 11.3 (Unit) assets: content-addressed store of historical renderings +- [ ] 11.4 (TDD) cli: scc update — re-render old, re-render new, three-way merge +- [ ] 11.5 (Unit) update: excluded files reported rather than merged +``` + +## 9 · What the build settled, and where it deviated + +Everything §5 listed as decided is now in `orchestration.md`. Both gaps are closed, and +four things came out differently from the plan. Each deviation is here rather than +silently in the code, because each is a decision somebody could reasonably reverse. + +**Closed: where a delta lives.** Inline markers on the requirement line — +`- **R2.3** (MODIFIED) When …` — not a `specs//deltas/` tree. The reason is +§10's own second argument: deltas exist partly so two sessions can change one spec as +long as they touch *different requirements*, and a marker per requirement gives that +directly while a file per change does not. `(REMOVED)` requirements are exempt from EARS +parsing and from traceability, because there is nothing left to hold to a grammar and no +task has to reach one. Phase 7.4 is unblocked and done. + +**Closed: what a codewiki is on disk.** `docs/codewiki/`, one Markdown page per area, +every `##` section carrying at least one `[path:start-end]()` citation. The `Structure` +tree check §5 listed is **dropped**, not deferred: the design never said what that tree +looks like, and a check against a guess is precisely the false positive that costs trust +in the three checks that are unambiguous. + +**Deviation: requirement numbering is not checked for contiguity.** The plan's 7.2 asked +for "unique and contiguous". Contiguity is wrong here — a requirement removed through a +delta legitimately leaves a hole, so the check would fire on the mechanism the design +asks people to use. Uniqueness is checked; gaps are not. ADR numbering *is* contiguous, +because an ADR is never removed, so there a gap means a record went missing. + +**Deviation: `internal/mdscan` was split across phases 4 and 5.** The frontmatter parser +(5.2) landed early, because `spec show` reads the kickoff answers back and the +alternative was a second frontmatter parser in the CLI. It also grew one level of nested +scalars, which the plan did not anticipate: the Agent Skills spec's optional `metadata` +field is a mapping, and refusing it would mean reporting a finding on a skill that is +valid by the standard the validator exists to enforce. Nothing deeper is accepted. + +**Deviation: `assets` has `Content` plus `Artifact`, not one `Render(name, data)`.** The +plan's own next paragraph explains why: workspace templates are data-free, so passing +data to them is meaningless, while artifact templates *do* take data because they are +authored from birth and never tracked in the manifest. Two functions make the difference +impossible to get wrong at a call site. + +**Deviation: no skills ship, so 3.3 and 6.3 are hollow.** The design lists skills among +what the binary scaffolds but never says which. Nothing was invented to fill the gap. +`skill validate` is complete and tested against fixtures, and the test asserting that +shipped skills pass it will start meaning something the moment one ships. **One line in +the design saying which skills ship, if any, closes this.** + +**Also settled along the way**, all of it now in the rules the binary scaffolds, because +a validator needs a written convention to check against: ADR frontmatter (`status`, +`superseded-by`), the ADR filename shape (`NNNN-kebab-slug.md`), the glossary entry +format (`- **term** — definition. Avoid: synonym`), the wiki's `index.md`/`changelog.md` +pair, and the codewiki citation form. + +**Phase 11 is deliberately not started.** §3's reason now literally applies: only one +template version exists, so `scc update` could only be tested against synthetic history. +It becomes real work the moment a second template version ships. Until then the honest +upgrade story is the one §6 already states — re-run `init`, which never overwrites what +you edited. + +One thing the build found that no phase predicted, worth recording because it is the +class of bug this whole design is about: the first end-to-end run of +`scc spec new` + `scc validate` **reported a finding on the file scc had just +generated** — the shipped `design.md` template cited its requirements only inside an +HTML comment, so the traceability check saw a design tracing to nothing. A tool whose +first act is to flag its own output teaches the user to ignore all eight validators. +There is now a test asserting that fresh artifacts pass every validator, and it should +be treated as the gate on any future template change. diff --git a/design/research.md b/design/research.md new file mode 100644 index 0000000..8293129 --- /dev/null +++ b/design/research.md @@ -0,0 +1,462 @@ +# Research — where scc's design sits in the field + +State of the art as of July 2026, gathered to check `orchestration.md` against what the +community and the literature actually converged on. Sources at the bottom. + +Read this as a checklist against our design, not as a survey. Three groups: standards we +should conform to, findings that **challenge** a decision we made, and findings that +confirm one. + +--- + +## 1 · Standards that actually exist + +### Agent Skills / `SKILL.md` — a real open standard, and we can validate against it + +Anthropic published the Agent Skills spec on 2025-12-18; by March 2026 **32 tools** from +competing vendors read the same `SKILL.md` from the same directory layout (Gemini CLI, +JetBrains Junie, AWS Kiro, Block Goose, Cursor, Codex CLI…). The authoritative spec lives +at `agentskills.io/specification`, outside Anthropic. + +Hard constraints, all mechanically checkable: + +| Field | Rule | +|---|---| +| `name` | required · 1–64 chars · lowercase `a-z0-9` and `-` only · no leading/trailing hyphen · **no consecutive hyphens** · **must match the parent directory name** | +| `description` | required · 1–1024 chars · should say *what* and *when* | +| `license`, `compatibility` (≤500), `metadata`, `allowed-tools` | optional | + +Layout is `skill-name/SKILL.md` plus optional `scripts/`, `references/`, `assets/`. +Progressive disclosure has three tiers: metadata (~100 tokens, loaded at startup for +*every* skill), the body (loaded on activation, **<5000 tokens recommended, keep under 500 +lines**), and resources (loaded only when needed). References should stay **one level deep**. + +**Consequences for scc.** This is the ideal shape of an scc check: a published spec, all of +it artifact-level, none of it requiring us to parse source (§5). A `skill validate` command +has an external contract to hold skills to rather than an opinion. Note also that +`skills-ref validate` already exists as a reference validator — worth reading before +writing ours, and worth deciding whether we duplicate it or defer to it. + +### EARS — our design doc states it wrong + +EARS (Alistair Mavin et al., Rolls-Royce, IEEE RE 2009) has a ruleset: **zero or many +preconditions · zero or one trigger · one system name · one or many responses**, with the +clauses always in that order. It produces **five** patterns plus combinations: + +| Pattern | Shape | +|---|---| +| Ubiquitous | `The shall ` | +| State-driven | `While , the shall ` | +| Event-driven | `When , the shall ` | +| Optional feature | `Where , the shall ` | +| Unwanted behavior | `If , then the shall ` | +| Complex | more than one keyword, e.g. `While … When …, the shall …` | + +**`orchestration.md` §10 currently describes EARS as `WHEN THE SYSTEM SHALL +`** — that is only the event-driven pattern. A validator built on that sentence +would reject four valid patterns, and would push authors to fake a trigger for +requirements that are simply always true. **Fix §10 before anything validates +requirements.** + +### AGENTS.md — the cross-tool standard Claude Code doesn't read + +`AGENTS.md` became the de facto standard for giving coding agents project context across +2025–2026. **Claude Code does not read it natively** — only `CLAUDE.md`. The usual bridge +is to keep the portable content in `AGENTS.md` and import it from `CLAUDE.md`. + +**Open decision for scc:** scaffold `AGENTS.md` + a `CLAUDE.md` that imports it (portable +across the 30+ tools, one more file), or `CLAUDE.md` only (simpler, Claude-Code-only — +which is what the product name commits to anyway). Not decided. + +--- + +## 2 · Findings that challenge our design + +### 2.1 TDD instructions without test context made things *worse* — arXiv 2603.17973 + +TDAD (Test-Driven Agentic Development) measured code regression in agents. The headline is +not that TDD helps; it is the paradox: **adding generic TDD procedural instructions, +disconnected from which tests actually exercise the code being changed, increased +regressions above the no-intervention baseline.** What worked was **graph-based impact +analysis** — a dependency graph identifying the tests that genuinely cover the modified +paths, so the agent gets only relevant test context. + +Reported effect of the working version: regressions cut roughly 70% (≈6.1% → ≈1.8%); the +instructions-only variant rose to ≈9.9%, worse than doing nothing. + +This lands directly on two of our decisions: + +- **§3/§6 put "TDD for money, complex algorithms, hypotheses, high risk" in + `.claude/rules/` as a procedural rule.** That is exactly the intervention shape the paper + says backfires on its own. +- **We trimmed the graph to Markdown artifacts only** (no source extractors), and the + paper's working mechanism *is* a code-dependency graph. + +So there is a real tension between "scc never parses code" (§5) and the one intervention +with measured evidence behind it. Options, none free: accept the risk and keep the rule +textual; have the *orchestrator* do impact analysis ad hoc (it can read the code — scc +still doesn't); or let a project-configured hook supply the relevant-test set. **This +deserves an explicit decision rather than silence.** + +Caveat worth keeping: the fetched PDF text did not expose the tables, so the exact +percentages above come from secondary reporting of the paper. The qualitative finding — +instructions-only was worse than baseline — is stated in the paper itself. + +### 2.2 "Plan" means the opposite thing in the dominant tool + +In GitHub Spec Kit, `/speckit.plan` produces `plan.md` **plus** `data-model.md`, +`contracts/`, `research.md`, `quickstart.md` — i.e. spec-kit's *plan* is our *design +phase*, sitting **inside** one feature. Our plan is the **decomposition above** specs. + +Anyone arriving from spec-kit (the most widely integrated toolkit — 29+ agent +integrations) will read `plans/checkout-revamp.md` and expect architecture. This is a +naming cost, not a design flaw, but it is worth paying attention to before the vocabulary +sets. + +### 2.3 We removed `[P]`; the field standardized on it + +Spec Kit marks parallel-safe tasks `[P]` in `tasks.md` and documents safe parallelization +groups. csdd had the same marker. We removed it in §8 along with dispatched parallelism. + +Our reasoning stands on its own (file-disjointness ≠ semantic independence; the merge +doesn't catch duplicate invention), and Anthropic's own guidance agrees — see 3.1 — but we +should know we are diverging from the field's default on this, deliberately. + +### 2.4 Spec Kit mandates TDD for everything; we default to Unit + +Spec Kit's constitution, Article III: "All implementation MUST follow strict Test-Driven +Development." Our §3 makes Unit the default and TDD risk-triggered. Given 2.1, the +gap between these two positions is where most of our correctness risk lives. + +### 2.5 The community has a name for our unanswered question — and we're in the weakest tier + +Maturity taxonomy in circulation: + +| Tier | Meaning | +|---|---| +| **Spec-First** | specs precede code, then are discarded | +| **Spec-Anchored** | specs persist, evolve with the code, and code validates against them | +| **Spec-as-Source** | only specs are edited; code is generated (Tessl) | + +"The Spec Growth Engine" (arXiv 2606.27045) argues for **spec-anchored** with explicit +**drift detection** — spec drift being the divergence of documentation from +implementation. Our open question "what happens to a spec after its feature merges" is +precisely the spec-first/spec-anchored choice, and our current design leans spec-first, +the tier the literature treats as weakest. + +### 2.6 OpenSpec's delta format solves a brownfield problem we don't address + +OpenSpec is brownfield-first: changes are written as **deltas** against existing specs, +with `ADDED` / `MODIFIED` / `REMOVED` markers, so you specify the change rather than the +whole system. Two properties we'd want: + +- **Deltas scoped to individual requirements let two in-flight changes edit the same + `spec.md` without conflicting** — directly relevant to §9's multiple parallel sessions. +- Specs **grow one change at a time**, filling in around the work actually done, instead + of demanding an up-front full-system spec. + +scc currently assumes a spec describes a feature from scratch. Most real use is brownfield. + +### 2.7 Context rot argues *for* delegation — the one place §8 is uncomfortable + +Chroma's study across 18 frontier models (GPT-4.1, Claude 4 family, Gemini 2.5, Qwen3): +accuracy degrades **non-uniformly** as input grows, **30–50% before the documented limit**, +across varied task types and *sooner* on complex tasks. Above ~50% context fill, attention +favors recency over position. + +§8 rejected the implementation subagent partly because "the orchestrator's accumulated +context is the asset." Context rot says that asset decays, and decays faster on the hard +tasks. This does not overturn §8 — our objection was about *partial* context producing +duplicate inventions, and about model tiering — but it does mean a long single-session +orchestrator has a ceiling. It is an argument for bounding a spec's size, and for treating +compaction as expected rather than exceptional. + +--- + +## 3 · Findings that confirm our design + +### 3.1 Anthropic's own guidance matches §8 + +"Subagents work best for **read-heavy research and exploration, not parallel coding**." +That is §8's conclusion and §7's split (review agents read; nobody delegates authorship), +arriving from the vendor rather than from our reasoning. Cost data in the same direction: +multi-agent workflows run ~4–7× the tokens of a single-agent session, agent teams ~15×. + +### 3.2 Worktree-per-session is the community pattern, caveats included + +The published playbooks say what §9 says: one worktree per parallel session, each running +its own tests, diffs reviewed and merged in dependency order. They also independently hit +our §9 resource-collision caveat — recommending `.env.local` per worktree (gitignored) and +explicit port/database isolation, because a committed shared `.env` is read by every +worktree. Our "worktrees isolate files, not the world outside them" is the same lesson. + +### 3.3 Agent context files do improve efficiency — but the popular number is folklore + +The measured study (arXiv 2601.20404): median wall-clock **98.57s → 70.34s (−28.6%)**, +median output tokens **2,925 → 2,440 (−16.6%)**; input tokens essentially flat. + +**Important correction.** Blog posts widely cite this work for "best results came from +files with just three sections" and "aim for under 150 lines." **The paper contains no such +finding.** It measures operational efficiency only, explicitly states that output-quality +evaluation is out of scope (a sanity check on 50 sampled tasks confirmed non-empty +changes), and reports nothing about optimal size or structure. Do not cite it for a length +prescription. + +The lean-`CLAUDE.md` instinct is still well founded — just cite **context rot** (2.7) for +it, which does measure degradation with length, rather than a study that didn't look. + +### 3.4 The problem statement is the field's problem statement + +The framing that motivated this project is the field's: the 2026 bottleneck is not +generation speed but **drift** — plausible code that confidently solves the wrong problem +because nothing grounded the work. Every major tool shipped an SDD flavor (Spec Kit, Kiro, +Cursor, OpenSpec, BMAD, Tessl, Antigravity), and skills-based installation is now a +first-class mode for Claude Code and Codex CLI rather than slash commands only. + +--- + +## 4 · What was changed as a result + +All seven items are resolved in `orchestration.md`. Recorded here so the reasoning stays +attached to the evidence: + +| # | Finding | Resolution | +|---|---|---| +| 1.2 | EARS is five patterns, not one | **§10 rewritten** — all five plus complex; the validator must accept all five. Was a latent bug: the old text would have rejected valid requirements and pushed authors to fake triggers. | +| 2.1 | TDD procedure without test context measured *worse than nothing* | **New §3 step, before both cycles**: identify and run the tests that already cover the paths being changed. The orchestrator does the impact analysis (it reads code); scc still doesn't. | +| 2.5 | Spec-first is the weakest tier | **New §11 — spec-anchored.** Work touching a covered area updates that spec in the same PR. Explicitly *without* mechanical drift detection, which would need code parsing (§5); that downgrade is deliberate and stated. | +| 2.6 | Brownfield needs deltas | **§10 gained a delta section** — `ADDED`/`MODIFIED`/`REMOVED` per requirement. Decisive reason: deltas scoped per requirement let §9's concurrent sessions edit one spec without colliding. | +| 1.1 | Agent Skills is a published external standard | **§5 gained a validator catalogue** with `skill` conforming to the spec. Implemented in Go, not delegated to `skills-ref` — scc is one binary and shelling to Node for a few regexes would break that. | +| 1.3 | AGENTS.md is the cross-tool standard Claude Code can't read | **Declined, in §6.** Shipping it means shipping an import too — two files and an indirection for portability to tools this product isn't for. | +| 2.2 | "plan" means the opposite in spec-kit | **Kept `plans/`**, with a vocabulary warning in §1 so it isn't "fixed" later by someone who only knows spec-kit. | + +Two further changes the research prompted that weren't on the original list: + +- **§6 now cites context rot** for the lean `CLAUDE.md`, replacing the `AGENTS.md` + efficiency study — because that study measured tokens and wall-clock, not quality, and the + popular length prescription isn't in it (3.3). The instinct was right; the citation was + wrong. +- **`stack` became a real gate** rather than a principle: dependency manifests (`go.mod`, + `package.json`) are structured data, so diffing them against `docs/stack.md` catches a + silently adopted dependency without reading any source. + +Nothing here overturned the two-vehicle model, sequential implementation, or review-only +agents — and 3.1 independently confirmed the last two. Every gap was in the *artifact +contracts*, which is where scc's whole value is. + +Still open after this pass: **context rot vs. the long-lived orchestrator context** (2.7). +It doesn't overturn §8, but it means a spec's size has a ceiling and compaction is expected +rather than exceptional. No decision recorded yet. + +--- + +--- + +## 5 · Second pass — the gaps we'd left + +The first pass checked the orchestration decisions. This one covers what it skipped: our own +default methodology, the autonomy call, the product's core premise, the knowledge base, and +workspace mechanics. + +### 5.1 Tests-after is defensible — but only *iterative*, and agents fail it differently + +The classical empirical record on TDD vs test-last is far more mixed than its reputation. +Controlled experiments with professionals: TDD's benefits over **iterative** test-last are +"small and thus in practice relatively unimportant, although effects are positive." TDD +programmers passed ~18% more black-box tests but took ~16% longer; the branch-coverage +advantage has a small effect size; in at least one experiment the test-last team was *more* +productive and wrote *more* tests. Participants found test-last easier and preferred it. + +**This supports our Unit default — with a condition that must be written into the rule.** +The literature's test-last comparator is *iterative*: tests written per unit, immediately +after it. Nothing supports "tests at the end of the feature." Our §3 Unit says "a unit test +for each function," which is the right shape; it must say *immediately*, or it drifts into +the variant nobody has evidence for. + +**And the human record does not transfer whole.** Those studies measured humans, whose +failure mode with test-after is skipping tests. An agent's failure mode is different and +worse: it writes tests that assert **what the code does** rather than what it should do — +green tests that encode the bug. That is the specific risk our default carries, and it needs +its own mitigation: the test must be derived from the **requirement**, not read off the +implementation. + +### 5.2 The premise holds — and over-specification is a real failure mode + +Structured specifications do help LLMs, measurably: SSDE (arXiv 2605.02455) found "adding +any type of structured specifications significantly improves output quality compared to the +baseline where only the natural language specification is provided"; SpecSyn reports a 21% +accuracy gain on contract generation. The product's premise is sound. + +But the relationship is not monotonic. "When Prompt Under-Specification Improves Code +Correctness" (arXiv 2604.24712) found over-specification can **constrain the model's +reasoning or introduce conflicting requirements**, degrading correctness — there is a sweet +spot rather than a "more detail is better" gradient. + +That is §10's "omit, don't fill" rule arriving from outside, and it extends further than we +applied it: we scoped it to `design.md`, and it applies to **requirements** too. EARS +structure yes; exhaustive enumeration no. A requirements document that specifies more than +the feature decides is not merely long — it can produce worse code. + +### 5.3 Autonomy: §2 is too binary + +The practice literature converges on three patterns: **pre-execution approval** (halt before +each consequential action), **post-execution review** (act, then surface for inspection), and +**escalation triggers** (run autonomously, halt on specific risk signals — irreversible +operations, sensitive data, low confidence). "Governed AI-Assisted Engineering" (arXiv +2606.22484) proposes graduated tiers — automated / standard review / expert review / +management approval / compliance check — selected by risk, scope, domain sensitivity, and +code patterns. The recurring principle: **autonomy is earned through demonstrated +performance, not assumed at deployment**, and approval mode is useful as trust calibration +early on. + +Our §2 asks once at kickoff and then runs to completion. That is *one* of the three patterns, +and it has no escalation path: once "automatic" is chosen, a task touching money is treated +like a task renaming a variable. + +**The fix is already in the design.** §3's TDD trigger list — money, complex algorithms, +hypothesis validation, high-risk complexity — is a risk classifier we already require the +orchestrator to apply per task. The same list can gate. A task annotated `(TDD)` because it +touches money is exactly the task worth surfacing even in autonomous mode. One classifier, +two consumers. + +### 5.4 ADR: adopt an existing format, keep our own trigger + +There are established formats and we should not invent one. **Nygard** (2011): title, status, +context, decision, consequences. **MADR** (Markdown ADR) is the most widely used, extending +Nygard with considered options, pros and cons per option, and links to related decisions. +Templates are curated at `adr.github.io`. + +One detail from the Nygard format is worth preserving deliberately: **negative consequences +go in the same section as positive ones.** That is what stops an ADR from becoming a sales +pitch for the decision that was already made — a real risk when the author is an agent that +just made it. + +csdd's "triple gate" (hard to reverse · surprising without context · a real trade-off) is not +a competing format — it is a **trigger** deciding *when* an ADR is warranted, which the +standard templates don't address. Keep the gate, adopt MADR's shape. + +### 5.5 Glossary: the machine-checkable half is exactly the half machines are good at + +"AI coding agents amplify whatever vocabulary you give them" — precise vocabulary becomes +correct class names, lifecycle states, and boundaries. And the limit is documented: LLMs are +**competent at DDD's mechanical layers** (lexical consistency, structural alignment, +pattern matching) and **unreliable on the parts requiring real domain knowledge**. + +That is a clean division of labour and it maps onto our design exactly: the human decides +which term is canonical (domain knowledge); the orchestrator uses it consistently (lexical +consistency, where it is strong); `scc` lints the avoided synonyms (mechanical). Keeping the +glossary is well founded, and the lint is the part that carries the value. + +### 5.6 `update` should use a three-way merge, not backup files + +Copier solved the problem csdd solved with numbered `.old` backups, and solved it better. On +update it: checks out the **old** template version, renders it with the saved answers to +reconstruct "old generated", renders the **new** version the same way, diffs those two, and +applies that diff to the working tree as a **three-way merge**. Local edits survive unless +they collide on the same lines; collisions surface as **standard git conflict markers**. +Files meant to be generated once (local config) are excluded from the merge entirely. + +This is better than a `.old` file for one reason: a backup makes the user re-merge by hand, +while conflict markers put the resolution where they already know how to do it — and it is +the same mechanism §9 already relies on for branches. + +**It has a concrete consequence for our manifest.** A three-way merge needs the *old rendered +text*, not just its hash. scc embeds its templates, so it can re-render the old version — but +only if it knows **which version generated each file**. So `.claude/scc-manifest.json` must +record a template/scc version per entry, not only a content hash. Hashes answer "did the user +edit this?"; versions answer "what did it look like before?" We need both. + +### 5.7 Task granularity: the rule is "independently verifiable" + +Decomposition measurably helps: subtask success rates run substantially higher than +whole-task success rates, and agents that execute individual steps correctly still fail +end-to-end workflows. Runtime-structured decomposition cut **retry cost by 73.2%** by +retrying only the failed subtask instead of the whole plan. The design principle the +literature keeps landing on: subtasks should be **independently verifiable**. + +We have no granularity guidance at all in `tasks.md`. "Independently verifiable" is the rule +to adopt, and it is not arbitrary — it is exactly what makes §7's per-task loop (scoped tests ++ lint) possible, and what makes a failure cost one task instead of a feature. + +### 5.8 Lint fatigue is the biggest threat to a validator catalogue + +This is the finding most likely to decide whether scc gets used. **Non-actionable warnings +run 35–91% of total warnings** in studied static-analysis deployments. False positives are +the single most common reason developers suppress warnings (34.4% of suppressions). Alert +fatigue means valid findings get ignored along with the noise, and the two main barriers to +adopting such tools are **false positives and cumbersome configuration**. + +We just wrote a catalogue of eight validators (§5). Three rules follow, and they are +non-negotiable if the catalogue is to be worth having: + +1. **A false positive costs more than a miss.** One wrong finding teaches the user to + disbelieve all eight. This is the same trade §5 already made about not parsing code — now + with numbers behind it. +2. **Zero configuration to get value.** Configuration is the *other* documented adoption + barrier; a validator that must be tuned before it is useful will not be. +3. **Findings must be few and each must be fixable.** A finding the user cannot act on is + noise wearing a useful shape. + +There is an encouraging corollary: **structural checks are the class with the lowest false +positive rate.** A `SKILL.md` whose `name` doesn't match its directory is not a heuristic +judgment — it is either true or false. Our §5 boundary turns out to protect trust in the +tool, not just its scope. + +### 5.9 One claim we cannot support: codewiki as an "interchange format" + +csdd's README frames its repo-derived document as "an interchange shape, not a private one — +a document you compile locally and one produced by an external repo-wiki generator are the +same artifact." I could not find a published standard for this. Repo-wiki generators exist +(DeepWiki and its open-source variants produce cited documentation), but no common citation +format is documented that an external generator and scc would both target. + +So if we carry that idea into `knowledge-base.md`, it has to be stated honestly: **scc +defines this format**; conformance by external tools is a hope, not an existing fact. The +`codewiki lint` checks remain valuable either way — they verify citations against a checkout, +which is true regardless of who wrote the document. + +--- + +## 6 · Second-pass changes + +| # | Finding | Resolution | +|---|---|---| +| 5.1 | Test-last is only supported *iteratively*; agents fail it by encoding the bug | **§3 Unit tightened**: immediately per function, and the test derives from the requirement, never read off the implementation | +| 5.2 | Over-specification can reduce correctness | **§10** — "omit, don't fill" extended from design to requirements | +| 5.3 | Autonomy needs escalation, not a single up-front switch | **§2 gained escalation triggers**, reusing §3's risk list — one classifier, two consumers | +| 5.4 | Established ADR formats exist | Recorded for `knowledge-base.md`: adopt MADR's shape, keep csdd's triple gate as the *trigger*, preserve Nygard's same-section consequences | +| 5.5 | LLMs are strong on lexical consistency, weak on domain knowledge | Confirms the glossary and locates its value in the lint | +| 5.6 | Three-way merge beats backup files | **New §12** — and the manifest must record a version per file, not just a hash | +| 5.7 | Independently-verifiable subtasks; 73.2% cheaper retries | **§4 gained a granularity rule** | +| 5.8 | 35–91% of warnings are non-actionable; false positives drive suppression | **§5 gained validator discipline**: a false positive costs more than a miss, zero config, few and fixable findings | +| 5.9 | No interchange standard for repo-derived docs exists | Flagged for `knowledge-base.md` — we define the format and must say so | + +## Sources + +Standards and primary specs: +- [Agent Skills specification](https://agentskills.io/specification) +- [Agent Skills open standard — adoption across 32 tools](https://www.paperclipped.de/en/blog/agent-skills-open-standard-interoperability/) +- [EARS — official guide, Alistair Mavin](https://alistairmavin.com/ears/) +- [Easy Approach to Requirements Syntax (Wikipedia)](https://en.wikipedia.org/wiki/Easy_Approach_to_Requirements_Syntax) +- [Adopting the EARS notation — Jama Software](https://www.jamasoftware.com/requirements-management-guide/writing-requirements/adopting-the-ears-notation-to-improve-requirements-engineering/) +- [github/spec-kit](https://github.com/github/spec-kit) · [spec-driven.md](https://github.com/github/spec-kit/blob/main/spec-driven.md) +- [OpenSpec concepts](https://github.com/Fission-AI/OpenSpec/blob/main/docs/concepts.md) · [OpenSpec comparison](https://openspec.pro/comparison/) + +Papers: +- [TDAD: Test-Driven Agentic Development — regression via graph-based impact analysis](https://arxiv.org/pdf/2603.17973) +- [The Spec Growth Engine: Spec-Anchored, Code-Coupled, Drift-Enforced Architecture](https://arxiv.org/pdf/2606.27045) +- [From Prompt to Process: a Process Taxonomy of Frameworks Supporting AI Software Development Agents](https://arxiv.org/pdf/2606.04967) +- [SkillJuror: Measuring How Agent Skill Organization Changes Runtime Behavior](https://arxiv.org/pdf/2606.11543) +- [AGENTS.md efficiency measurement](https://arxiv.org/html/2601.20404v2) +- [Context Rot: How Increasing Input Tokens Impacts LLM Performance — Chroma](https://www.trychroma.com/research/context-rot) + +Vendor guidance and practice: +- [How and when to use subagents in Claude Code — Anthropic](https://claude.com/blog/subagents-in-claude-code) +- [Steering Claude Code: CLAUDE.md, skills, hooks, subagents — Anthropic](https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more) +- [Claude Code best practices](https://code.claude.com/docs/en/best-practices) +- [Claude Code agents in 2026 — what parallel sessions cost](https://www.cloudzero.com/blog/claude-code-agents/) +- [Git worktrees + Claude Code playbook](https://www.developersdigest.tech/blog/git-worktrees-claude-code-parallel-agents-guide) +- [Parallel agentic development with git worktrees](https://www.mindstudio.ai/blog/parallel-agentic-development-git-worktrees) +- [spec-compare — 6 SDD tools, incl. worktree analysis](https://github.com/cameronsjo/spec-compare) +- [Spec-Driven Development in 2026 — tooling survey](https://dev.to/krlz/spec-driven-development-in-2026-what-it-is-the-tooling-and-how-teams-actually-use-it-2fk2) +- [Codex AGENTS.md vs Claude Code CLAUDE.md](https://www.mindstudio.ai/blog/codex-agents-md-vs-claude-code-claude-md-comparison) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7a5e29f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/protonspy/spec-claude-code + +go 1.25.0 diff --git a/internal/assets/assets.go b/internal/assets/assets.go new file mode 100644 index 0000000..4108038 --- /dev/null +++ b/internal/assets/assets.go @@ -0,0 +1,216 @@ +// Package assets is the template set compiled into the binary: the rules, the +// review agents, and the artifact templates, all of it plain Markdown embedded +// with //go:embed. +// +// There are two kinds of template here and the difference matters: +// +// - **Workspace files** — what `scc init` writes and the manifest tracks. These +// are DATA-FREE: no project name, no date, no path is interpolated into any of +// them. That is what makes an upgrade possible without storing rendered text +// anywhere. A given template version renders byte-identically in every +// workspace on earth, so its content hash identifies it globally and the +// three-way merge can reconstruct the old side from the version alone. The two +// files that genuinely want per-project content — CLAUDE.md and the project +// rule — are exactly the files an upgrade excludes because the user owns them, +// so the two rules agree instead of fighting. +// +// - **Artifact templates** — what `spec new` and `plan new` render. These take +// data, because they are authored from birth: the user owns the result +// immediately, nothing tracks them in the manifest, and no upgrade ever touches +// them. +package assets + +import ( + "bytes" + "embed" + "fmt" + "io/fs" + "path" + "sort" + "strings" + "text/template" + + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/textutil" +) + +// Version is the template-set version, recorded per entry in the manifest and +// bumped whenever any workspace template's content changes. +// +// It is not the binary's version. Two scc releases that ship identical templates +// share a template version, which keeps `scc update` a no-op across them instead +// of touching every managed file to record a number nobody read. +const Version = "1" + +// The embedded tree. "all:" so nothing is silently dropped for having a name the +// default embed pattern skips. +// +//go:embed all:templates +var files embed.FS + +const ( + root = "templates" + artifactsDir = "artifacts" +) + +// File is one workspace file scc scaffolds and then tracks. +type File struct { + // Name is the path inside the embedded tree, e.g. "claude/rules/routing.md". + Name string + + // Rel is where it goes: slash-separated and relative to the workspace root. + // This is also verbatim what the manifest records, so the layout crosses + // machines as slashes and never as the host's separator. + Rel string + + // Owned marks a file the user owns from their first edit. scc writes it once + // and records it, then leaves it alone: an upgrade reports that a new version + // exists rather than merging into it. CLAUDE.md and the project rule are the + // two files whose whole purpose is to be edited, and merging a new template + // into someone's own prose produces a mess no one asked for. + Owned bool +} + +// Workspace returns every file `scc init` writes, sorted by destination so init's +// output and the manifest are in the same order on every run. +func Workspace() []File { + claude := func(seg ...string) string { + return path.Join(append([]string{paths.ClaudeDir}, seg...)...) + } + set := []File{ + {Name: "CLAUDE.md", Rel: paths.EntryFile, Owned: true}, + {Name: "claude/rules/project.md", Rel: claude(paths.RulesSeg, "project.md"), Owned: true}, + } + // The methodology. Every one of these is scc's own content: an upgrade should + // deliver improvements to them, so none is Owned. + for _, rule := range []string{ + "routing.md", + "autonomy.md", + "methodology.md", + "tasks.md", + "verification.md", + "delivery.md", + "specs.md", + "knowledge-base.md", + } { + set = append(set, File{ + Name: "claude/rules/" + rule, + Rel: claude(paths.RulesSeg, rule), + }) + } + for _, agent := range []string{"code-review.md", "security-review.md"} { + set = append(set, File{ + Name: "claude/agents/" + agent, + Rel: claude(paths.AgentsSeg, agent), + }) + } + sort.Slice(set, func(i, j int) bool { return set[i].Rel < set[j].Rel }) + return set +} + +// Dirs returns the directories `scc init` creates even when it has no file to put +// in them. An agent that can see specs/, plans/, and docs/wiki/ knows where its +// output goes; one that has to infer the layout from a rule file guesses. +func Dirs() []string { + return []string{ + path.Join(paths.ClaudeDir, paths.RulesSeg), + path.Join(paths.ClaudeDir, paths.AgentsSeg), + paths.SpecsSeg, + paths.PlansSeg, + path.Join(paths.DocsSeg, paths.WikiSeg), + path.Join(paths.DocsSeg, paths.RawSeg), + path.Join(paths.DocsSeg, paths.ADRSeg), + path.Join(paths.DocsSeg, paths.CodewikiSeg), + } +} + +// Content returns an embedded template verbatim, normalized to LF. +// +// Normalizing here rather than at each call site is what makes a manifest hash +// portable: the same template must hash identically no matter how the scc source +// tree was checked out on the machine that built the binary. +func Content(name string) (string, error) { + b, err := files.ReadFile(path.Join(root, name)) + if err != nil { + return "", fmt.Errorf("no embedded template %q: %w", name, err) + } + return textutil.NormalizeNewlines(string(b)), nil +} + +// ArtifactData is what an artifact template can interpolate. Deliberately tiny: +// every field is something the user supplied on the command line, so a template +// cannot come to depend on the machine it was rendered on. +type ArtifactData struct { + // Name is the kebab-case name as given, e.g. "user-auth". + Name string + // Title is Name rendered for a heading, e.g. "User auth". + Title string + // Autonomy is "auto" or "gated" — the kickoff answer, recorded so nobody is + // asked twice and the run stays reproducible from the file. + Autonomy string + // CI is "wait" or "no-wait", recorded for the same reason. + CI string +} + +// Artifact renders one of the artifact templates ("requirements.md", "design.md", +// "tasks.md", "plan.md") with data. The result is LF-normalized and ends in +// exactly one newline. +func Artifact(name string, data ArtifactData) (string, error) { + raw, err := Content(path.Join(artifactsDir, name)) + if err != nil { + return "", err + } + // Option("missingkey=error") makes a typo'd field a build-time-ish failure — + // caught by this package's tests — instead of the string "" landing in + // the user's requirements document. + tpl, err := template.New(name).Option("missingkey=error").Parse(raw) + if err != nil { + return "", fmt.Errorf("template %q: %w", name, err) + } + var buf bytes.Buffer + if err := tpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("template %q: %w", name, err) + } + out := textutil.NormalizeNewlines(buf.String()) + return strings.TrimRight(out, "\n") + "\n", nil +} + +// Title turns a kebab-case name into heading text: "user-auth" -> "User auth". +func Title(name string) string { + s := strings.ReplaceAll(name, "-", " ") + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// walk lists every embedded template path, relative to the tree root. Used by the +// tests that assert the set and the tree agree — an embedded file no File points +// at ships in the binary and reaches no workspace. +func walk() ([]string, error) { + var out []string + err := fs.WalkDir(files, root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepathRel(p) + if err != nil { + return err + } + out = append(out, rel) + return nil + }) + sort.Strings(out) + return out, err +} + +func filepathRel(p string) (string, error) { + trimmed := strings.TrimPrefix(p, root+"/") + if trimmed == p { + return "", fmt.Errorf("embedded path %q is not under %q", p, root) + } + return trimmed, nil +} diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go new file mode 100644 index 0000000..04e0c18 --- /dev/null +++ b/internal/assets/assets_test.go @@ -0,0 +1,270 @@ +package assets + +import ( + "strings" + "testing" +) + +// Every embedded template is checked for the properties a manifest hash depends +// on. A CRLF template would hash differently on a Windows build machine than on a +// Linux one, and the same workspace would then report every managed file as edited +// depending on who built the binary. +func TestEveryTemplateIsLFCleanAndNonEmpty(t *testing.T) { + names, err := walk() + if err != nil { + t.Fatalf("walk: %v", err) + } + if len(names) == 0 { + t.Fatal("no templates are embedded") + } + for _, name := range names { + raw, err := Content(name) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + if strings.TrimSpace(raw) == "" { + t.Errorf("%s: empty", name) + } + if !strings.HasSuffix(raw, "\n") { + t.Errorf("%s: does not end in a newline", name) + } + if strings.HasSuffix(raw, "\n\n") { + t.Errorf("%s: ends in more than one newline", name) + } + } +} + +// Content normalizes, so an embedded file that happened to be checked out CRLF is +// still delivered as LF. Assert on the raw bytes to prove the guarantee is in the +// code and not in the checkout. +func TestContentIsNormalized(t *testing.T) { + raw, err := Content("CLAUDE.md") + if err != nil { + t.Fatalf("Content: %v", err) + } + if strings.Contains(raw, "\r") { + t.Error("CLAUDE.md carries CR after Content()") + } +} + +func TestContentRejectsUnknownTemplate(t *testing.T) { + if _, err := Content("nope.md"); err == nil { + t.Error("Content of a missing template returned no error") + } +} + +// The workspace set and the embedded tree must agree in both directions: a File +// pointing at nothing breaks init, and an embedded file no File points at ships in +// the binary and reaches no workspace. +func TestWorkspaceSetAndTreeAgree(t *testing.T) { + embedded, err := walk() + if err != nil { + t.Fatalf("walk: %v", err) + } + inTree := map[string]bool{} + for _, name := range embedded { + inTree[name] = true + } + + referenced := map[string]bool{} + for _, f := range Workspace() { + if _, err := Content(f.Name); err != nil { + t.Errorf("Workspace() references %q which is not embedded", f.Name) + } + referenced[f.Name] = true + } + for _, name := range []string{"requirements.md", "design.md", "tasks.md", "plan.md"} { + referenced["artifacts/"+name] = true + } + for name := range inTree { + if !referenced[name] { + t.Errorf("embedded template %q is not in Workspace() and is not an artifact template", name) + } + } +} + +// Destinations must be unique and slash-separated: they are written verbatim into +// the manifest, and two Files claiming one path means one silently wins. +func TestWorkspaceDestinationsAreUniqueAndSlashed(t *testing.T) { + seen := map[string]bool{} + for _, f := range Workspace() { + if seen[f.Rel] { + t.Errorf("duplicate destination %q", f.Rel) + } + seen[f.Rel] = true + if strings.Contains(f.Rel, `\`) { + t.Errorf("destination %q is not slash-separated", f.Rel) + } + if strings.HasPrefix(f.Rel, "/") || strings.Contains(f.Rel, "..") { + t.Errorf("destination %q escapes the workspace root", f.Rel) + } + } +} + +// The set is sorted by destination so init's report and the manifest are in the +// same order on every run and on every platform. +func TestWorkspaceIsSortedByDestination(t *testing.T) { + set := Workspace() + for i := 1; i < len(set); i++ { + if set[i-1].Rel >= set[i].Rel { + t.Fatalf("not sorted: %q before %q", set[i-1].Rel, set[i].Rel) + } + } +} + +// The upgrade path depends on this: a data-free template renders identically in +// every workspace, so its hash identifies its content globally. A template with a +// placeholder in it could not be reconstructed from the manifest alone. +func TestWorkspaceTemplatesAreDataFree(t *testing.T) { + for _, f := range Workspace() { + raw, err := Content(f.Name) + if err != nil { + t.Fatalf("%s: %v", f.Name, err) + } + if i := strings.Index(raw, "{{"); i >= 0 { + t.Errorf("%s contains a template action at byte %d — workspace templates must be data-free", f.Name, i) + } + } +} + +// Exactly the two files whose purpose is to be edited are Owned. If a third +// appears, it is either a mistake or a decision that belongs in the design. +func TestOnlyTheUserOwnedFilesAreOwned(t *testing.T) { + var owned []string + for _, f := range Workspace() { + if f.Owned { + owned = append(owned, f.Rel) + } + } + want := []string{".claude/rules/project.md", "CLAUDE.md"} + if strings.Join(owned, ",") != strings.Join(want, ",") { + t.Errorf("owned files = %v, want %v", owned, want) + } +} + +// CLAUDE.md is paid for in context by every session, and accuracy degrades as +// context grows. The rules exist so it can stay short; this is the guard that keeps +// someone from "helpfully" inlining them back into it. +func TestScaffoldedEntryFileStaysShort(t *testing.T) { + raw, err := Content("CLAUDE.md") + if err != nil { + t.Fatalf("Content: %v", err) + } + if n := strings.Count(raw, "\n"); n > 60 { + t.Errorf("scaffolded CLAUDE.md is %d lines; keep it under 60 and link to .claude/rules/ instead", n) + } +} + +// Every rule the scaffolded CLAUDE.md points at has to exist, or the entry file +// sends the agent to a file that isn't there. +func TestEntryFileLinksResolve(t *testing.T) { + raw, err := Content("CLAUDE.md") + if err != nil { + t.Fatalf("Content: %v", err) + } + dests := map[string]bool{} + for _, f := range Workspace() { + dests[f.Rel] = true + } + for _, f := range Workspace() { + if !strings.HasPrefix(f.Rel, ".claude/rules/") { + continue + } + if !strings.Contains(raw, "("+f.Rel+")") { + t.Errorf("CLAUDE.md does not link to %s", f.Rel) + } + } +} + +func TestArtifactRendersItsData(t *testing.T) { + data := ArtifactData{Name: "user-auth", Title: "User auth", Autonomy: "auto", CI: "wait"} + for _, name := range []string{"requirements.md", "design.md", "tasks.md", "plan.md"} { + out, err := Artifact(name, data) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if strings.Contains(out, "{{") || strings.Contains(out, "") { + t.Errorf("%s: unrendered action or missing key:\n%s", name, out) + } + if !strings.Contains(out, "User auth") { + t.Errorf("%s: title not interpolated", name) + } + if !strings.HasSuffix(out, "\n") || strings.HasSuffix(out, "\n\n") { + t.Errorf("%s: does not end in exactly one newline", name) + } + } +} + +// The kickoff answers are recorded on the artifact so nobody is asked twice and the +// run stays reproducible from the file. +func TestArtifactRecordsTheKickoffAnswers(t *testing.T) { + data := ArtifactData{Name: "x", Title: "X", Autonomy: "gated", CI: "no-wait"} + for _, name := range []string{"requirements.md", "plan.md"} { + out, err := Artifact(name, data) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if !strings.HasPrefix(out, "---\n") { + t.Errorf("%s does not open with frontmatter:\n%s", name, out) + } + if !strings.Contains(out, "autonomy: gated") || !strings.Contains(out, "ci: no-wait") { + t.Errorf("%s did not record the kickoff answers:\n%s", name, out) + } + } +} + +func TestArtifactRejectsUnknownName(t *testing.T) { + if _, err := Artifact("nope.md", ArtifactData{}); err == nil { + t.Error("Artifact of a missing template returned no error") + } +} + +func TestTitle(t *testing.T) { + cases := map[string]string{ + "user-auth": "User auth", + "a": "A", + "oauth2-login-v2": "Oauth2 login v2", + "": "", + } + for in, want := range cases { + if got := Title(in); got != want { + t.Errorf("Title(%q) = %q, want %q", in, got, want) + } + } +} + +// The review agents are read by Claude Code's subagent loader, which needs the +// frontmatter contract: a name matching the file and a description saying when to +// use it. +func TestAgentsCarryTheirFrontmatter(t *testing.T) { + for _, f := range Workspace() { + if !strings.HasPrefix(f.Rel, ".claude/agents/") { + continue + } + raw, err := Content(f.Name) + if err != nil { + t.Fatalf("%s: %v", f.Name, err) + } + if !strings.HasPrefix(raw, "---\n") { + t.Errorf("%s does not open with frontmatter", f.Rel) + } + base := strings.TrimSuffix(strings.TrimPrefix(f.Rel, ".claude/agents/"), ".md") + if !strings.Contains(raw, "\nname: "+base+"\n") { + t.Errorf("%s: frontmatter name does not match the filename", f.Rel) + } + if !strings.Contains(raw, "\ndescription: ") { + t.Errorf("%s: no description in frontmatter", f.Rel) + } + } +} + +// Directories init creates on its own must be inside the workspace and +// slash-separated, for the same reason destinations must be. +func TestDirsAreRelativeAndSlashed(t *testing.T) { + for _, d := range Dirs() { + if strings.HasPrefix(d, "/") || strings.Contains(d, "..") || strings.Contains(d, `\`) { + t.Errorf("directory %q is not a slash-separated path inside the workspace", d) + } + } +} diff --git a/internal/assets/templates/CLAUDE.md b/internal/assets/templates/CLAUDE.md new file mode 100644 index 0000000..340efeb --- /dev/null +++ b/internal/assets/templates/CLAUDE.md @@ -0,0 +1,45 @@ +# CLAUDE.md + +This repo practises spec-driven development, scaffolded and checked by `scc`. + +**This file is deliberately short, and keeping it short is a requirement.** Every +session pays for it in context, and model accuracy degrades as context grows — +non-uniformly, well before any documented limit. The methodology lives in +`.claude/rules/`, one file per concern, read when the concern is live. Do not +inline those rules here. + +## The rules + +| Read | When | +|---|---| +| [routing.md](.claude/rules/routing.md) | Work arrives and needs a vehicle — a spec, or a plan. | +| [autonomy.md](.claude/rules/autonomy.md) | At kickoff, before writing anything. | +| [methodology.md](.claude/rules/methodology.md) | Starting any task: which cycle, and what to run first. | +| [tasks.md](.claude/rules/tasks.md) | Writing or reading a task list. | +| [verification.md](.claude/rules/verification.md) | A task's code is written and you think it is done. | +| [delivery.md](.claude/rules/delivery.md) | The last task is done: branch, review, PR. | +| [specs.md](.claude/rules/specs.md) | Writing or changing anything under `specs/`. | +| [knowledge-base.md](.claude/rules/knowledge-base.md) | Something learned is worth keeping, or a decision was made. | +| [project.md](.claude/rules/project.md) | You need this project's build, test, or lint commands. | + +## The layout + +``` +specs// requirements.md · design.md · tasks.md +plans/.md one file: structure, plus a checklist and/or spec references +docs/ the knowledge base — wiki, adr, codewiki, glossary, stack +.claude/rules/ the methodology above +``` + +## Checking your work + +``` +scc validate # every applicable validator; exit 2 means findings +``` + +Exit codes are the contract: `0` ok, `1` the command could not run, `2` it ran and +found something. A finding is an answer, not a crash. + +`scc` checks the *shape* of these artifacts — that a decision was made and written +down. It never reads your source, so it cannot tell you the code honors what the +artifact says. That part is on you. diff --git a/internal/assets/templates/artifacts/design.md b/internal/assets/templates/artifacts/design.md new file mode 100644 index 0000000..854a22f --- /dev/null +++ b/internal/assets/templates/artifacts/design.md @@ -0,0 +1,40 @@ +# {{.Title}} — design + + + +## What changes + +Serves R1.1. + + + +## Boundaries and contracts + + + +## Data + + + +## Alternatives considered + + + +## Risks + + diff --git a/internal/assets/templates/artifacts/plan.md b/internal/assets/templates/artifacts/plan.md new file mode 100644 index 0000000..6736c7d --- /dev/null +++ b/internal/assets/templates/artifacts/plan.md @@ -0,0 +1,42 @@ +--- +autonomy: {{.Autonomy}} +ci: {{.CI}} +--- + +# {{.Title}} + + + +## Why + + + +## Decomposition + + + +- `specs//` — + +## Tasks + + + +- [ ] 1.1 (Unit) +- [ ] 1.2 (TDD) + +## Notes + + diff --git a/internal/assets/templates/artifacts/requirements.md b/internal/assets/templates/artifacts/requirements.md new file mode 100644 index 0000000..80f310d --- /dev/null +++ b/internal/assets/templates/artifacts/requirements.md @@ -0,0 +1,35 @@ +--- +autonomy: {{.Autonomy}} +ci: {{.CI}} +--- + +# {{.Title}} — requirements + + + +## Purpose + + + +## R1 · + +- **R1.1** The shall +- **R1.2** When , the shall +- **R1.3** If , then the shall + +## Out of scope + + diff --git a/internal/assets/templates/artifacts/tasks.md b/internal/assets/templates/artifacts/tasks.md new file mode 100644 index 0000000..7a86c7d --- /dev/null +++ b/internal/assets/templates/artifacts/tasks.md @@ -0,0 +1,25 @@ +# {{.Title}} — tasks + + + +## 1 · + +- [ ] 1.1 (Unit) — R1.1 +- [ ] 1.2 (TDD) — R1.2, R1.3 + +## 2 · + +- [ ] 2.1 (Unit) — R1.1 diff --git a/internal/assets/templates/claude/agents/code-review.md b/internal/assets/templates/claude/agents/code-review.md new file mode 100644 index 0000000..ed470de --- /dev/null +++ b/internal/assets/templates/claude/agents/code-review.md @@ -0,0 +1,57 @@ +--- +name: code-review +description: Reviews a diff for correctness and quality. Use it after the last task of a spec or plan is verified and before opening the PR — never on your own work as the author. +tools: Read, Grep, Glob, Bash +--- + +You review a diff. You do not write code, and you do not fix what you find — you +report it. + +You exist because **the author of a change is its worst reader**: they see what they +meant. A cold context is the entire value you add, so do not ask the author what they +intended. Read the diff and the code around it. + +## Scope + +The changed lines, plus enough of the surrounding code to judge them. Start with: + +```bash +git diff main...HEAD # or the base branch the work targets +git diff --stat main...HEAD +``` + +If the work has a spec, read `specs//requirements.md` and `tasks.md`. The +requirement is the standard the code is held to — not the implementation's own +apparent intent. + +## What to look for, roughly in order of what actually bites + +1. **Does it do what the requirement says?** Trace each changed behavior back to a + requirement or a task. Code doing something nobody asked for is a finding. +2. **Tests that assert the implementation instead of the requirement.** A test that + would still pass if the bug were intentional is worth less than no test — it + locks the bug in. This is the most common defect in agent-written tests: look for + assertions that read like a transcript of the code. +3. **Missing tests.** Unit tasks owe a test per function; TDD tasks owe a test that + was seen to fail first. Untested new functions are a finding. +4. **Correctness at the edges** — empty, zero, nil, one, many, concurrent, the error + path. Errors dropped, wrapped without context, or returned but not handled. +5. **Behavior changed by accident.** A modified function whose existing callers were + not looked at. +6. **Consistency with the codebase.** A new way of doing something the project + already does one way is a cost paid by everyone who reads it next. +7. **Complexity that is not paying for itself** — an abstraction with one caller, a + layer that only forwards, a config knob nobody asked for. + +Security is a different lens and has its own reviewer. Note anything alarming, but +do not try to be that reviewer. + +## How to report + +Findings only, most severe first. For each one: the file and line, what is wrong, and +what specifically makes it wrong — the input, the state, the caller that breaks. + +**A finding the author cannot act on is noise.** If you are not sure something is a +defect, say what would make it one rather than reporting it as one. Ending with "no +findings" is a legitimate and useful answer; padding a review with style preferences +is how a reviewer stops being read. diff --git a/internal/assets/templates/claude/agents/security-review.md b/internal/assets/templates/claude/agents/security-review.md new file mode 100644 index 0000000..07d16eb --- /dev/null +++ b/internal/assets/templates/claude/agents/security-review.md @@ -0,0 +1,54 @@ +--- +name: security-review +description: Reviews a diff for security defects specifically. Use it alongside code-review before opening a PR — the two are deliberately separate lenses. +tools: Read, Grep, Glob, Bash +--- + +You review a diff for security defects, and for nothing else. + +This is a separate agent from `code-review` on purpose. **A single reviewer asked for +"everything" reliably under-weights security**, because correctness findings are +easier to produce and crowd it out. Your narrow scope is the point: do not report +style, naming, or design taste, and do not re-report what a correctness reviewer +would obviously catch. + +## Scope + +```bash +git diff main...HEAD # or the base branch the work targets +``` + +Judge what the change makes possible, not what the codebase already was. Pre-existing +issues in untouched code are worth one line at the end, not the body of the review. + +## What to look for + +- **Untrusted input reaching something that acts on it.** Trace it: request bodies, + CLI arguments, filenames, environment, file contents, anything over the network. + Interpolation into SQL, a shell, a template, a path, a URL, or a deserializer. +- **Path traversal.** A caller-supplied name that becomes a path segment without + being validated first — `..`, absolute paths, separators, Windows device names. + This is the one that turns a delete command into deleting the project. +- **Authentication and authorization.** A new endpoint, command, or branch that skips + the check its neighbors make. Missing authorization is far more common than broken + authentication. +- **Secrets.** Credentials, tokens, or keys added to source, to a config file, to a + test fixture, to a log line, or to an error message. +- **Crypto and randomness.** `math/rand` where unpredictability matters, a hand-rolled + comparison of secrets, a hash chosen for speed where it needed to be slow. +- **Resource exhaustion** reachable from input: unbounded reads, unbounded + allocations, decompression, regexes over attacker-controlled strings. +- **Dependencies added in this diff.** A new dependency is new attack surface and a + new maintainer to trust. Say whether it earned that. +- **What the change loosens** — a widened permission, a disabled check, a `#nosec`, a + TLS verification skipped "for now". + +## How to report + +Findings only, most severe first. For each one: the file and line, the concrete path +from an attacker-controlled input to the effect, and what to do instead. A finding +with no reachable path is a hypothesis — say so, or leave it out. + +**Do not inflate severity to be heard.** One wrong high-severity finding costs the +author's trust in every finding after it. "No security findings in this diff" is a +real result; report it plainly when it is true. diff --git a/internal/assets/templates/claude/rules/autonomy.md b/internal/assets/templates/claude/rules/autonomy.md new file mode 100644 index 0000000..953d646 --- /dev/null +++ b/internal/assets/templates/claude/rules/autonomy.md @@ -0,0 +1,49 @@ +# Autonomy — ask once, at kickoff + +The spec phases are **autonomous by default**: write requirements, design, and +tasks, then start implementing. Do not stop for approval at each phase. + +But autonomy is the user's call, so **ask, once, before writing anything** — two +questions, together, in the same breath: + +1. **Run automatically, or gate each phase for review?** +2. **When the PR is open, wait for CI, or finish there?** + +Record both answers in the artifact's frontmatter, then never ask again for this +piece of work: + +```yaml +--- +autonomy: auto # or: gated +ci: wait # or: no-wait +--- +``` + +`scc spec new --autonomy=auto --ci=wait` writes them for you. + +Recording them is what makes the run reproducible from the file and what stops a +second session from re-asking. Ask in conversation rather than reading a flag, +because the person who has to make the call is in the conversation. + +## Asking at kickoff, not later + +The CI question especially: by the time the PR is open the work is done and the +user may be gone — which is exactly the situation "don't wait" exists for, and +exactly when a blocking question costs the most. + +## `auto` is not "never stop" + +Automatic keeps one exception. **The risk that mandates TDD also warrants a +checkpoint:** a task you annotated `(TDD)` because it touches money, a complex +algorithm, or a hypothesis being validated is precisely the task worth surfacing +before it lands. Surface it, briefly, even in an automatic run. + +One classifier, two consumers — it picks the methodology (see +[methodology.md](methodology.md)) and it picks what deserves a human glance. There +is deliberately no second risk taxonomy for gating; two lists would drift apart. + +## `gated` + +Stop after `requirements.md`, after `design.md`, and after `tasks.md`. Present what +you wrote and wait. Do not start implementing until the phase you are on is +approved. diff --git a/internal/assets/templates/claude/rules/delivery.md b/internal/assets/templates/claude/rules/delivery.md new file mode 100644 index 0000000..ac22503 --- /dev/null +++ b/internal/assets/templates/claude/rules/delivery.md @@ -0,0 +1,79 @@ +# Delivery — branch, worktree, PR + +Work does not happen on `main` and does not end with a green test run. It ends with +a pull request. + +## One branch per unit of work, in its own worktree + +Each unit of work — a spec, or a plan's leaf — gets its own branch, developed in its +own **git worktree**: + +``` +git worktree add ../- -b / +``` + +The reason is that the user may run several Claude Code sessions at once, one per +feature, and merge them into `main` as they land. A worktree is what makes that +possible: each session gets its own directory, none disturbs the others, and none +touches the checkout the user is sitting in. A shared tree with `git switch` cannot +do this — two sessions would fight over one working directory. + +## Implementation is sequential — you write the code + +**There is no implementation subagent and no parallel task dispatch.** This was +designed the other way first and rejected: + +- Delegating implementation puts the cheaper model on the hardest work while the + orchestrator keeps the part that needs the least capability. That is backwards. +- Every fresh agent re-pays for discovery. Within one spec your accumulated context + is the asset: you use the right parser in task 1.2 because you wrote task 1.1. +- **File-disjointness is not independence, and a clean merge hides the difference.** + Two tasks touching no common file both need a `Money` type that does not exist + yet. Each creates its own, with different semantics. The merge is clean and + nothing signals that anything went wrong. Sequential execution cannot produce + this, because the later task sees the earlier task's code. + +Feature-level parallelism does not have that problem and is supported: the *human* +picks the split, each session has a full context, and two features a person +deliberately separated are unlikely to collide. + +## What running several sessions still costs + +Worktrees isolate files, not the world outside them. Say these out loud rather than +discovering them: + +- **Shared external resources.** Two suites running at once fight over a fixed port, + one test database, a shared temp path. Either the suite namespaces them per + worktree, or the runs are serialized. +- **Cross-feature breakage.** Two features green on their own branches can be broken + together. Only CI on `main` after the merge sees that. + +## The delivery sequence + +Once the last task is done: + +1. **Full suite + lint** on the integrated branch. The per-task scoped runs cannot + see breakage between tasks. +2. **`scc validate`** — the artifacts have to be in shape too, and exit `2` is not + done. +3. **`code-review` and `security-review`** subagents on the diff, and fix what they + find. The PR should arrive already reviewed: a PR is for the human, and spending + their attention on findings a subagent would have caught is pure waste. +4. **Commit and push.** Conventional Commits, written from the diff and the spec. +5. **Open the PR.** Body: what changed, which spec or plan, how it was verified. + +## Then, CI — the answer you already have + +Use the `ci:` answer recorded at kickoff ([autonomy.md](autonomy.md)); do not ask now. + +- **`wait`** — watch the PR's checks until they settle. Red means fix, push, keep + watching. The work is not finished while CI is failing. +- **`no-wait`** — opening the PR is the finish line. + +## Degrading + +- **No remote, or no `gh`** — commit on the branch and stop there, saying so. A + branch the user can push themselves is a real deliverable; silently skipping the + PR is not. +- **Worktrees accumulate.** Remove a worktree once its branch is merged; keep it if + it still holds unmerged work, and say which you did. diff --git a/internal/assets/templates/claude/rules/knowledge-base.md b/internal/assets/templates/claude/rules/knowledge-base.md new file mode 100644 index 0000000..1e7aeb5 --- /dev/null +++ b/internal/assets/templates/claude/rules/knowledge-base.md @@ -0,0 +1,104 @@ +# The knowledge base — `docs/` + +A spec answers *what this feature does now*. `docs/` answers *why* — the durable +reasoning, the decisions, the material read from outside. Neither replaces the other, +and keeping them separate is what lets a spec stay anchored to one feature. + +``` +docs/ + wiki/ index.md, changelog.md, and one page per concept + raw/ sources dropped in to be processed — a file here is unfinished work + adr/ numbered decision records + codewiki/ narrated code, citing exact line ranges + glossary.md one canonical term per concept + stack.md adopted technology +``` + +`scc validate` checks all of it structurally: broken wikilinks, orphan pages, +index/changelog desync, unprocessed `raw/`, ADR numbering, citation resolution, +synonyms used where a canonical term belongs, and dependencies missing from +`stack.md`. + +## wiki/ + +One page per concept, linked with `[[wikilinks]]`. Every page is reachable from +`index.md` — an unreachable page is an orphan, and an orphan is a page nobody will +ever find again. Record what changed in `changelog.md` when you change the wiki. + +**`raw/` is a drop box, not storage.** Material collected from outside goes there to +be read, distilled into a wiki page, and then removed. A file still sitting in +`raw/` is a finding: it was collected and never processed. + +## adr/ + +One record per decision that is **hard to reverse**. Numbered contiguously from +`0001`, one file per record, cited from anywhere as +`adr:0007-use-sqlite-for-the-cache`: + +```markdown +--- +status: accepted # proposed | accepted | rejected | superseded +--- + +# 0007 · Use SQLite for the cache + +## Context +## Decision +## Consequences +``` + +**A superseded record is marked, never edited.** The point of an ADR is that it +records what was believed at the time; rewriting one destroys exactly the thing it +exists to preserve. Add the new record, and in the old one set: + +```yaml +status: superseded +superseded-by: 0012-move-the-cache-to-redis +``` + +Not every design decision is an ADR. If it is cheap to change, `design.md` is where +it belongs. + +## codewiki/ + +Prose that explains code, one page per area, with every section citing the exact +lines it is about: + +```markdown +## How the dispatcher routes + +[internal/cli/cli.go:48-64]() + +One switch, no registration... +``` + +A citation that no longer resolves is a finding, so this is the one part of `docs/` +that goes stale loudly rather than quietly. **Every section cites something** — a +section that cites nothing is prose that has drifted free of the code it describes. + +Write it for the parts where reading the code does not tell you why it is like that. +Do not narrate what a reader can see. + +## glossary.md + +One canonical term per concept, and the synonyms to avoid. One entry per line: + +```markdown +- **order total** — the amount charged, in minor units. Avoid: grand total, sum +- **workspace** — a directory holding .claude/scc-manifest.json. Avoid: project root +``` + +Domain vocabulary drifts by default — three names for one thing appear within a week +of two people working in parallel. Pick one, list the others after `Avoid:`, and use +the canonical term everywhere: in code, in requirements, in the wiki. An avoided +synonym used as a whole word in `docs/` is a finding. + +## stack.md + +Every adopted technology, with one line on why. **Technology not listed here is an +open decision, never something adopted silently** — and because dependency manifests +are structured data, this is checkable: a dependency in `go.mod` or `package.json` +that is absent from `stack.md` is a finding. + +So adding a dependency is a two-step act: add it, and say here why it earned its +place. diff --git a/internal/assets/templates/claude/rules/methodology.md b/internal/assets/templates/claude/rules/methodology.md new file mode 100644 index 0000000..e9b31b2 --- /dev/null +++ b/internal/assets/templates/claude/rules/methodology.md @@ -0,0 +1,73 @@ +# Methodology — per task, Unit by default + +Every task is built one of two ways, and the annotation on the task line says +which. The choice is per task, not per project: a feature routinely has both. + +## Before either cycle — find the tests that already cover this + +**Identify the existing tests that exercise the paths this task will change, and +run them, before writing anything.** Name them, in one line, before you start. + +This is not part of Unit or TDD. It precedes both, because it is about not breaking +what works rather than about proving what is new. + +It is here because of a measured result. Giving an agent a test-first *procedure* +while leaving it ignorant of which tests actually cover the code being modified +**increased regressions above doing nothing at all** — roughly 9.9% against ~6.1%. +Identifying the covering tests first brought it to ~1.8%. Procedure without context +was worse than no procedure. + +The failure it prevents is specific: a task adds a case to a function whose +existing tests nobody looked at, the new test passes, and the old behavior silently +changed. + +## Unit — the default + +Write the code, then write a unit test for **each function** in it. That is the +whole cycle. **There is no RED/GREEN here** — there is no failing-test step to +observe, because the code already exists. + +Two conditions make this legitimate rather than a shortcut, and both are +load-bearing: + +- **Immediately, per function — never at the end.** Finish a function, test that + function, move on. The evidence supports test-last only in this iterative form; + nothing supports saving the tests for the end of the feature. +- **The test comes from the requirement, not from the code.** This is where an + agent fails differently from a human: a human writing tests late writes too few, + an agent writes tests that assert *what the code does* — green tests that + faithfully encode the bug. Read the requirement the task cites and assert that. + Reading your own implementation to decide what to assert is the failure mode, not + the method. + +The default because most code is plumbing: the shape is not in doubt, and tests +written straight after are just as binding. Those two conditions are the entire risk +of writing code first, and you are accountable for them — `scc` cannot check either. + +## TDD — RED/GREEN required + +Write the failing test first, **watch it fail**, then make it pass, then refactor. +Skipping RED is not TDD: a test that has never failed has not been shown to test +anything. Say, in the task's notes, that you observed the red. + +RED/GREEN belongs to TDD and only to TDD. Unit is not a lazier TDD and TDD is not a +stricter Unit — they are two different cycles. + +**Mandatory when the cost of being wrong is high:** + +- **money** — any calculation, rounding, split, or conversion involving currency +- **complex algorithms** — anything whose correctness is not obvious by reading it +- **hypothesis / thesis validation** — code written to prove something holds +- **anything else** where the complexity is real and the chance of being wrong is + high + +The trigger is risk, not size. A three-line rounding helper that touches money is +TDD; a two-hundred-line CRUD handler is Unit. + +A task annotated `(TDD)` is also a task to surface before it lands, even in an +automatic run — see [autonomy.md](autonomy.md). + +## After the code + +Neither cycle ends at a passing test. Run the scoped tests **and** the lint before +you call the task done: [verification.md](verification.md). diff --git a/internal/assets/templates/claude/rules/project.md b/internal/assets/templates/claude/rules/project.md new file mode 100644 index 0000000..0bafa55 --- /dev/null +++ b/internal/assets/templates/claude/rules/project.md @@ -0,0 +1,39 @@ +# This project + +**This file is yours.** `scc` ships it as a stub, records that it did, and never +touches it again — an upgrade will leave whatever you write here alone. It is also +the reason `scc` needs no configuration file: the commands below are Markdown, which +the orchestrator already reads, and `scc` itself runs neither of them. + +Fill it in. An empty answer here means every session re-derives your build commands +by guessing. + +## Commands + +```bash +# Build + + +# Test — the whole suite + + +# Test — one package or one file (used after every task; scope, not suite) + + +# Lint — the best-practices layer that finds what tests do not + + +# Format / format check + +``` + +## Conventions + +- **Branch names:** e.g. `feat/`, `fix/` +- **Commits:** e.g. Conventional Commits, scoped by package +- **Anything a new contributor gets wrong on their first try:** … + +## Boundaries + +Things that are *not* to be changed without asking, and why. Generated files, +vendored trees, public API surfaces, migration files that have already run. diff --git a/internal/assets/templates/claude/rules/routing.md b/internal/assets/templates/claude/rules/routing.md new file mode 100644 index 0000000..e72b6e1 --- /dev/null +++ b/internal/assets/templates/claude/rules/routing.md @@ -0,0 +1,55 @@ +# Routing — which vehicle + +Work does not walk one fixed path. You pick the vehicle to match it, and the +choice has to be recorded, because the artifact is the only thing anyone else — +the reviewer, the next session, you after a compaction — will ever see. + +Ask one question: + +> Does this need requirements and a design settled before any code? + +**Yes → a spec.** No → **a plan.** + +| Vehicle | When | Path | Contents | +|---|---|---|---| +| **Spec** | The *what* and the *how* need settling before code. | `specs//` | `requirements.md` · `design.md` · `tasks.md` | +| **Plan** | Everything else. | `plans/.md` | structure, plus a checklist of tasks and/or references to specs | + +``` +scc spec new +scc plan new +``` + +Both write a file. Neither is session-only, and that is not bookkeeping: you write +the requirements and then proceed without anyone approving them, so the file is the +only channel through which the decision survives. A session's context dies. + +## A plan covers both ends of "not a spec" + +- **An initiative too large for one spec** — decomposed into specs it references. +- **A change small enough that the *what* was never in doubt** — a checklist and + nothing more. +- Or both at once: a decomposition with a handful of items it just does itself. + +The weight of the record matches the weight of the work. A one-line change gets one +checklist item, not three ceremonial files under `specs/`. + +## Rules that make a plan checkable + +- **A plan is one file**, not a directory. It holds no state beyond its checklist. +- **One source of truth per item.** Where an item *is* a task, its checkbox is the + state. Where an item *references a spec*, the state is derived from that spec and + never copied — an item must not do both. Two records of one fact disagree, and + the copy is the one that goes stale. +- **A plan's leaves are ordinary specs.** `plans/checkout-revamp.md` references + `specs/cart-totals/`; that spec is not nested under the plan and is built by + exactly the same rules as one a human asked for directly. +- **A plan is work, not knowledge**, so it lives in `plans/` — never in `docs/`, + which is the knowledge base, and never in `specs/`, which holds features. + +Whichever vehicle you picked, every task in it carries a methodology annotation. +See [tasks.md](tasks.md) and [methodology.md](methodology.md). + +> **If you know GitHub Spec Kit:** "plan" there means the opposite of this — the +> architecture *inside* one feature, which here is `design.md`. A plan here is the +> decomposition *above* specs, or a bare checklist. diff --git a/internal/assets/templates/claude/rules/specs.md b/internal/assets/templates/claude/rules/specs.md new file mode 100644 index 0000000..6dc543e --- /dev/null +++ b/internal/assets/templates/claude/rules/specs.md @@ -0,0 +1,104 @@ +# Specs — the three artifacts + +`specs//` holds exactly three files. `scc spec new ` creates them +from the templates; this is what goes in them. + +## requirements.md — EARS, all five patterns + +Requirements are written in EARS and numbered `R.`, so the later phases +can cite them. This is not ceremony: prose has nothing to validate, an EARS clause +has named parts, and a missing part is a finding. + +The ruleset: **zero or many preconditions · zero or one trigger · one system name · +one or many responses**, always in that order. + +| Pattern | Shape | +|---|---| +| Ubiquitous | `The shall ` | +| State-driven | `While , the shall ` | +| Event-driven | `When , the shall ` | +| Optional feature | `Where , the shall ` | +| Unwanted behavior | `If , then the shall ` | +| Complex | more than one keyword, in clause order | + +**All five are valid.** Do not force everything into `When …`: inventing a trigger +for a requirement that is simply always true makes it worse. `If … then …` — +unwanted behavior — is the pattern most often missing and the one that most often +matters. + +**Omit, don't fill.** Structured requirements measurably improve generated code, but +the curve is not monotonic: over-specification constrains reasoning and introduces +requirements that conflict with each other, and correctness drops. Specify what the +feature actually decides. A requirements document longer than the decision it records +can produce worse code than a shorter one. + +## design.md — scaled by complexity + +**The design must fit the decision being made.** The common failure is inventing +architecture for a change that had no architectural question in it — a component +diagram for a two-function addition, a data-model section for something that touches +no data. + +That is worse than verbose. **Invented architecture constrains:** the next session +reads it as a decision somebody made, and honors it. Filler becomes binding. + +So the sections are conditional. **Omit, don't fill:** + +| When the change… | design.md carries | +|---|---| +| decides nothing structural | what changes, where, and why — a few paragraphs. No components, no diagram. | +| moves a boundary, a data shape, or an external contract | those sections only, for the parts that actually change | +| has real alternatives with trade-offs | the alternatives and why one won — plus an ADR if the decision is hard to reverse | + +A heading filled with "N/A", or with prose written to satisfy the heading, is worse +than an absent heading: it reads as a decision and nobody can tell it apart from +one. Delete the heading instead. + +`scc` checks that the design exists and traces to its requirements. It never checks +that a particular section is present — a required heading is a request for filler. + +## tasks.md — the grammar + +See [tasks.md](tasks.md). Exactly one `(Unit)` or `(TDD)` per task, and every task +cites the requirements it satisfies. Traceability runs both ways: every requirement +reaches at least one task, every task cites a requirement that exists. + +## Changing a spec that already exists — deltas, not rewrites + +Write the change as a **delta** against the spec, marking each affected requirement: + +``` +- **R2.3** (MODIFIED) When the cart is empty, the checkout shall … +- **R2.7** (ADDED) If the coupon has expired, then the checkout shall … +- **R1.4** (REMOVED) +``` + +Three reasons: + +1. **You specify the change, not the system.** Adopting this practice on an existing + codebase must not require writing the spec for everything that already works. + Specs grow one change at a time. +2. **Deltas scoped to individual requirements make concurrent edits safe.** Two + sessions can change one spec as long as they touch different requirements. + Whole-file rewrites collide on contact. +3. **A reviewer reads intent instead of reconstructing it** from a diff. + +The delta is how a change is proposed and reviewed. Once it lands, fold it into the +spec: the spec stays the current statement of the feature, never an append-only log. + +## The spec is anchored, not disposable + +A spec does not stop being true when its feature merges. **Work that touches an area +a spec covers updates that spec as part of the delivery** — as a delta, in the same +branch, in the same PR as the code. + +Under autonomy the file is the only record of intent, and a record that stops being +maintained stops being a record. A stale requirement read as current is worse than an +absent one, because it is believed. + +Nothing detects this drift mechanically — verifying that a spec matches the code +means understanding the code, which `scc` deliberately does not do. Keeping the spec +current is your obligation, and the reviewer's to check. + +Anchored means *maintained while the code exists*, not *never removed*. A feature +genuinely deleted takes its spec with it. diff --git a/internal/assets/templates/claude/rules/tasks.md b/internal/assets/templates/claude/rules/tasks.md new file mode 100644 index 0000000..571dca2 --- /dev/null +++ b/internal/assets/templates/claude/rules/tasks.md @@ -0,0 +1,56 @@ +# Task grammar + +One grammar governs every task line, whether it sits in a spec's `tasks.md` or in a +plan's checklist. The methodology is a property of the task, not of the vehicle that +carried it. + +``` +- [ ] 1.1 (Unit) Parse the manifest file — R1.2, R1.4 +- [ ] 1.2 (TDD) Calculate the pro-rata split across accounts — R2.1 +- [ ] 1.3 (Unit) Render the summary table — R3.1 +``` + +- `- [ ]` / `- [x]` — the checkbox is the state. +- `1.1` — a unique number, `.`. +- `(Unit)` or `(TDD)` — **required, exactly one.** A task with no methodology is a + task where nobody decided, which is the failure this whole practice exists to + prevent. `scc` exits `2` on a task that is missing it. +- The description, in the imperative. +- `— R1.2, R1.4` — the requirements this task satisfies, after an em dash. Required + in a spec's `tasks.md`; that citation is what makes traceability checkable. + +There is no parallel-dispatch marker. Implementation is sequential — see +[delivery.md](delivery.md) for why, and for the parallelism that *is* supported. + +## Requirement IDs + +Requirements are numbered `R.` in `requirements.md` and cited by that +ID. It greps cleanly, it never collides with a task's own number, and a reader who +has never seen this document can follow it. + +## How big is a task + +**A task is the right size when it can be verified on its own.** Not "one file", not +"an hour" — verifiable alone, which is what makes the per-task loop in +[verification.md](verification.md) possible at all. + +Granularity is not tidiness; it decides what a failure costs. Agents complete +individual steps far more reliably than whole workflows, and structuring work so a +failure can be retried at the subtask level cut retry cost by ~73% against retrying +a whole plan. + +- **Too coarse** and a red result tells you only that a feature is broken. +- **Too fine** and the checklist becomes bookkeeping about work smaller than the act + of recording it. + +## Two checklists, one truth + +Claude Code's own todo list is where you track the task you are on right now. The +file — `specs//tasks.md`, or the checklist in `plans/.md` — is the +durable record: it survives the session, it gets reviewed, it gets committed, and it +is what `scc` validates. + +**Checking an item off in the session means checking the `- [ ]` box in the file +too.** A session that ends with its todo list complete and the file untouched has +lost everything except the code: the next session does not know which tasks were +done, and neither does the reviewer. diff --git a/internal/assets/templates/claude/rules/verification.md b/internal/assets/templates/claude/rules/verification.md new file mode 100644 index 0000000..3cff135 --- /dev/null +++ b/internal/assets/templates/claude/rules/verification.md @@ -0,0 +1,39 @@ +# Verification — closing the loop on every task + +A task is not done when the code is written. You built it, so you finish it, in this +order: + +1. **Build the task** — Unit or TDD, per the annotation on the task line + ([methodology.md](methodology.md)). +2. **Run the tests in its scope** — the tests covering what you just built, plus the + ones you identified as already covering these paths. Not the whole suite. +3. **Run the lint** — the project's linter (see [project.md](project.md)). +4. **Fix, or move on.** A red task is not finished. Then tick the box in the file. + +## Scope, not suite + +Per-task feedback has to be fast and attributable. The failure a full run catches +and a scoped run misses is breakage *between* tasks, and that is worth looking for +once the work is integrated — not N times along the way. + +The full suite runs at the end of the spec, or of each of a plan's leaves. See +[delivery.md](delivery.md). + +## Tests and lint both, because they answer different questions + +- The **tests** say the code does what the task asked. +- The **lint** says the code is written the way this project writes code — unused + code, unchecked errors, shadowed variables, unsafe conversions. These are + programming defects tests do not find. + +A task that passes its tests with an unchecked error in it is not finished. Neither +check substitutes for the other. + +The linter is whatever this project already uses. `scc` does not own it and does not +run it, for the same reason it does not read your source. + +## And the artifact + +If the work changed what a spec says — and work that touches an area a spec covers +usually does — update that spec in the same branch, as a delta. See +[specs.md](specs.md). diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..ea61759 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,121 @@ +// Package cli is the headless command surface — the whole product. Everything +// scc can do is reachable from here through flags, with machine-readable output, +// so an agent or a CI job drives it exactly as well as a human does. +// +// Run is the entire dispatcher: it switches on args[0] and hands off to a +// run function living in a file named for that resource (spec.go, +// wiki.go, …). Each handler owns its own flag.FlagSet. Adding a subcommand means +// adding a case here plus one file — nothing is registered dynamically, so the +// set of commands is readable in one place. +package cli + +import ( + "flag" + "fmt" + "os" + "runtime" + "strings" + + "github.com/protonspy/spec-claude-code/internal/render" +) + +// version is stamped at build time via +// -ldflags "-X github.com/protonspy/spec-claude-code/internal/cli.version=vX.Y.Z". +// A source build reports "dev". +var version = "dev" + +// Exit codes are the contract every caller branches on. Keep them distinct: a +// validation *finding* is a legitimate answer to a lint question, not a failure +// of the tool, and CI needs to tell the two apart. +const ( + ExitOK = 0 // the command did what was asked + ExitError = 1 // usage error, or the command could not run + ExitFindings = 2 // the command ran and reported validation findings +) + +// prog is the name to print in usage text. The npm launcher sets SCC_PROG when +// invoked through `npx`, so help output echoes the spelling the user actually +// typed instead of a bare "scc" they may not have on PATH. +func prog() string { + if p := strings.TrimSpace(os.Getenv("SCC_PROG")); p != "" { + return p + } + return "scc" +} + +// Run dispatches args (os.Args[1:]) and returns the process exit code. It never +// calls os.Exit, so tests can drive the whole surface in-process. +func Run(args []string) int { + if len(args) == 0 { + usage() + return ExitError + } + switch args[0] { + case "help", "-h", "--help": + usage() + return ExitOK + case "version", "-v", "--version": + return runVersion(args[1:]) + case "init": + return runInit(args[1:]) + case "spec": + return runSpec(args[1:]) + case "plan": + return runPlan(args[1:]) + case "skill": + return runSkill(args[1:]) + case "validate": + return runValidateAll(args[1:]) + default: + render.Err(fmt.Sprintf("unknown command %q", args[0])) + fmt.Fprintf(os.Stderr, "run `%s help` for the available commands\n", prog()) + return ExitError + } +} + +// runVersion prints the stamped version, plus the build's Go toolchain and +// platform under --json so a bug report carries them without a second command. +func runVersion(args []string) int { + fs := flag.NewFlagSet("version", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + jsonOut := addJSON(fs) + if err := fs.Parse(args); err != nil { + return ExitError + } + if *jsonOut { + return emitJSON(struct { + Version string `json:"version"` + Go string `json:"go"` + Platform string `json:"platform"` + }{version, runtime.Version(), runtime.GOOS + "/" + runtime.GOARCH}) + } + fmt.Println(version) + return ExitOK +} + +func usage() { + fmt.Fprintf(os.Stderr, `%s — spec-driven development for Claude Code + +Usage: + %s [flags] + +Commands: + init Scaffold a workspace: rules, review agents, layout, manifest + spec Create and inspect specs — new | list | show | delete | validate + plan Create and inspect plans — new | list | delete | validate + skill Agent Skills conformance — validate + validate Run every applicable validator; exit 2 on findings + version Print the version + help Show this help + +Run "%s help" for a command's own flags. + +Every command accepts --json for machine-readable output on stdout, and --root to +act on a workspace other than the enclosing one. + +Exit codes: + 0 ok + 1 usage or runtime error + 2 validation findings +`, render.Bold(prog()), prog(), prog()) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..5aa09ef --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,160 @@ +package cli + +import ( + "encoding/json" + "io" + "os" + "strings" + "testing" +) + +// capture swaps os.Stdout/os.Stderr for pipes around f and returns what was +// written to each. It is the only safe way to assert on CLI output, because +// package render writes to the real files rather than to an injected writer. +func capture(t *testing.T, f func() int) (stdout, stderr string, code int) { + t.Helper() + origOut, origErr := os.Stdout, os.Stderr + outR, outW, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + errR, errW, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout, os.Stderr = outW, errW + defer func() { os.Stdout, os.Stderr = origOut, origErr }() + + // Drain concurrently: a command that writes more than the pipe buffer would + // otherwise block forever on the write instead of failing the test. + outC, errC := make(chan string, 1), make(chan string, 1) + go func() { b, _ := io.ReadAll(outR); outC <- string(b) }() + go func() { b, _ := io.ReadAll(errR); errC <- string(b) }() + + code = f() + + _ = outW.Close() + _ = errW.Close() + return <-outC, <-errC, code +} + +// run wraps capture around Run for the common case. +func run(t *testing.T, args ...string) (stdout, stderr string, code int) { + t.Helper() + return capture(t, func() int { return Run(args) }) +} + +func TestVersionPrintsStampedVersion(t *testing.T) { + stdout, _, code := run(t, "version") + if code != ExitOK { + t.Errorf("exit = %d, want %d", code, ExitOK) + } + if strings.TrimSpace(stdout) != version { + t.Errorf("stdout = %q, want %q", strings.TrimSpace(stdout), version) + } +} + +// --json output must be a clean document on stdout with nothing else mixed in — +// callers pipe it straight into jq. +func TestVersionJSON(t *testing.T) { + stdout, stderr, code := run(t, "version", "--json") + if code != ExitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, ExitOK, stderr) + } + var got struct { + Version string `json:"version"` + Go string `json:"go"` + Platform string `json:"platform"` + } + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("stdout is not valid JSON (%v): %q", err, stdout) + } + if got.Version != version { + t.Errorf("version = %q, want %q", got.Version, version) + } + if got.Go == "" || got.Platform == "" { + t.Errorf("go/platform not reported: %+v", got) + } + if stderr != "" { + t.Errorf("stderr = %q, want empty", stderr) + } +} + +func TestVersionAliases(t *testing.T) { + for _, arg := range []string{"-v", "--version"} { + if _, _, code := run(t, arg); code != ExitOK { + t.Errorf("%s: exit = %d, want %d", arg, code, ExitOK) + } + } +} + +// Usage goes to stderr so it never pollutes a stdout a caller is parsing. +func TestHelpGoesToStderr(t *testing.T) { + for _, arg := range []string{"help", "-h", "--help"} { + stdout, stderr, code := run(t, arg) + if code != ExitOK { + t.Errorf("%s: exit = %d, want %d", arg, code, ExitOK) + } + if stdout != "" { + t.Errorf("%s: stdout = %q, want empty", arg, stdout) + } + if !strings.Contains(stderr, "Usage:") { + t.Errorf("%s: stderr missing usage: %q", arg, stderr) + } + } +} + +func TestNoArgsIsAUsageError(t *testing.T) { + _, stderr, code := run(t) + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, "Usage:") { + t.Errorf("stderr missing usage: %q", stderr) + } +} + +func TestUnknownCommand(t *testing.T) { + _, stderr, code := run(t, "nope") + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, `unknown command "nope"`) { + t.Errorf("stderr = %q, want it to name the unknown command", stderr) + } +} + +// An unparseable flag is a usage error, not a crash and not a silent success. +func TestUnknownFlagIsAUsageError(t *testing.T) { + if _, _, code := run(t, "version", "--nope"); code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } +} + +// SCC_PROG lets the npm launcher make help text echo the spelling the user typed +// (`npx @protonspy/scc`) instead of a bare binary name they may not have. +func TestProgHonorsEnvOverride(t *testing.T) { + t.Setenv("SCC_PROG", "npx @protonspy/scc") + if got := prog(); got != "npx @protonspy/scc" { + t.Errorf("prog() = %q, want the override", got) + } + _, stderr, _ := run(t, "help") + if !strings.Contains(stderr, "npx @protonspy/scc") { + t.Errorf("usage did not use SCC_PROG: %q", stderr) + } +} + +func TestProgFallsBackToBinaryName(t *testing.T) { + t.Setenv("SCC_PROG", " ") + if got := prog(); got != "scc" { + t.Errorf("prog() = %q, want %q", got, "scc") + } +} + +// The three exit codes are a published contract; collapsing 2 into 1 would break +// every CI job and agent that branches on "ran but found problems". +func TestExitCodesAreDistinct(t *testing.T) { + if ExitOK != 0 || ExitError != 1 || ExitFindings != 2 { + t.Errorf("exit codes drifted: ok=%d error=%d findings=%d", ExitOK, ExitError, ExitFindings) + } +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go new file mode 100644 index 0000000..c3bee85 --- /dev/null +++ b/internal/cli/flags.go @@ -0,0 +1,151 @@ +package cli + +import ( + "flag" + "fmt" + "path/filepath" + "strings" + + "github.com/protonspy/spec-claude-code/internal/render" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +// parseFlags parses args and returns the positionals, allowing them to appear +// before the flags as well as after. +// +// Go's flag package stops parsing at the first non-flag argument, so +// `scc spec new user-auth --json` would otherwise read "--json" as a second +// positional and silently ignore the flag. Every user and every agent writes the +// name first, so this is not a preference: leading positionals are collected, the +// remainder is parsed, and anything left over is appended. +func parseFlags(fs *flag.FlagSet, args []string) ([]string, error) { + var positionals []string + i := 0 + for i < len(args) && !strings.HasPrefix(args[i], "-") { + positionals = append(positionals, args[i]) + i++ + } + if err := fs.Parse(args[i:]); err != nil { + return nil, err + } + return append(positionals, fs.Args()...), nil +} + +// rootFlagHelp is the single wording every command shows for --root, for the same +// reason addJSON exists: the flag has to read identically across the surface. +const rootFlagHelp = "workspace root (default: the enclosing workspace, else the working directory)" + +// addRoot binds --root on fs. +func addRoot(fs *flag.FlagSet) *string { + return fs.String("root", "", rootFlagHelp) +} + +// resolveRoot turns the --root flag into an absolute path, reporting the failure +// on stderr so every command words it the same way. +func resolveRoot(arg string) (string, bool) { + root, err := workspace.Resolve(arg) + if err != nil { + render.Err(err.Error()) + return "", false + } + return root, true +} + +// requireWorkspace is the guard on every command that reads or writes artifacts. +// Without it those commands would operate on whatever directory the walk fell back +// to, which for a command run outside a workspace is the user's own filesystem. +func requireWorkspace(root string) bool { + if workspace.IsWorkspace(root) { + return true + } + render.Err(fmt.Sprintf("%s is not an scc workspace", root)) + render.Detail(fmt.Sprintf(" run `%s init` there first", prog())) + return false +} + +// The kickoff answers, recorded on the artifact so the run is reproducible from +// the file and nobody is asked twice. The orchestrator asks the user in +// conversation — that is where the person who has to decide actually is — and +// passes the answer here. +const ( + autonomyAuto = "auto" + autonomyGated = "gated" + ciWait = "wait" + ciNoWait = "no-wait" +) + +type kickoff struct { + autonomy *string + ci *string +} + +// addKickoff binds --autonomy and --ci. +// +// The defaults match the methodology rather than being neutral: the spec phases +// are autonomous by default, and work is not finished while CI is failing. A +// neutral default would leave the most consequential question in the process +// unanswered in the file, which is the one outcome recording it was meant to +// prevent. +func addKickoff(fs *flag.FlagSet) kickoff { + return kickoff{ + autonomy: fs.String("autonomy", autonomyAuto, + "`auto` to run the phases without stopping, or `gated` to review each one"), + ci: fs.String("ci", ciWait, + "`wait` to watch CI after the PR opens, or `no-wait` to finish at the PR"), + } +} + +// validate rejects a value outside the two the rule defines. A typo'd +// "--autonomy=automatic" recorded verbatim would read as an answer nobody gave. +func (k kickoff) validate() bool { + if *k.autonomy != autonomyAuto && *k.autonomy != autonomyGated { + render.Err(fmt.Sprintf("--autonomy must be %q or %q, got %q", autonomyAuto, autonomyGated, *k.autonomy)) + return false + } + if *k.ci != ciWait && *k.ci != ciNoWait { + render.Err(fmt.Sprintf("--ci must be %q or %q, got %q", ciWait, ciNoWait, *k.ci)) + return false + } + return true +} + +// artifactName validates a positional that is about to become a path segment. +// +// Order matters: SafeName first, because it is the check that stands between a CLI +// argument and filepath.Join. Without it `scc spec delete ..` resolves to the +// workspace root. KebabCheck second, because it is a convention rather than a +// safety property. +func artifactName(positionals []string, kind string) (string, bool) { + if len(positionals) != 1 { + render.Err(fmt.Sprintf("expected exactly one %s name, got %d", kind, len(positionals))) + return "", false + } + name := positionals[0] + if err := workspace.SafeName(name, kind); err != nil { + render.Err(err.Error()) + return "", false + } + if err := workspace.KebabCheck(name, kind); err != nil { + render.Err(err.Error()) + return "", false + } + return name, true +} + +// noPositionals rejects leftover arguments. A command that silently ignores them +// does the wrong thing while reporting success — usually because the user typed a +// name a subcommand expected and this one does not take. +func noPositionals(positionals []string, command string) bool { + if len(positionals) == 0 { + return true + } + render.Err(fmt.Sprintf("%s takes no arguments, got %q", command, positionals[0])) + return false +} + +// relPath renders a path for output: relative to the workspace root and +// slash-separated, so the same artifact reads the same way on every platform and a +// --json consumer can match it against the manifest. +func relPath(root, target string) string { + return filepath.ToSlash(workspace.Relative(root, target)) +} diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..cfd1f37 --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,79 @@ +package cli + +import ( + "flag" + "fmt" + "os" + + "github.com/protonspy/spec-claude-code/internal/render" + "github.com/protonspy/spec-claude-code/internal/scaffold" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +// runInit scaffolds a workspace: the rules, the review agents, the directory +// layout, and the manifest that makes the directory findable as a workspace. +// +// It is safe to re-run. Without --force it never overwrites anything, so "run init +// again" is the honest upgrade story until `scc update` exists — additive by +// construction, and it simply does not deliver improved templates to files that +// already exist. +func runInit(args []string) int { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + force := fs.Bool("force", false, "overwrite existing files, naming every edited file it clobbers") + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + if !noPositionals(rest, "init") { + return ExitError + } + + target, ok := resolveRoot(*root) + if !ok { + return ExitError + } + + res, err := scaffold.Apply(target, scaffold.Options{SCCVersion: version, Force: *force}) + if err != nil { + render.Err(fmt.Sprintf("init failed: %v", err)) + return ExitError + } + + if *jsonOut { + return emitJSON(res) + } + + // Clobbered files come first: it is the only outcome that destroyed something, + // so it must not be buried under a list of successes. + for _, path := range res.Clobbered { + render.Warn(fmt.Sprintf("overwrote your edited %s", path)) + } + for _, c := range res.Changes { + if c.Action == scaffold.Created { + render.OK(c.Path) + } + } + switch { + case res.Created == 0 && res.Replaced == 0: + render.Info(fmt.Sprintf("already up to date: %d files, nothing to write", res.Skipped)) + case res.AlreadyPresent: + render.Info(fmt.Sprintf("%d created, %d replaced, %d left alone", res.Created, res.Replaced, res.Skipped)) + default: + render.OK(fmt.Sprintf("workspace ready in %s", workspace.Relative(mustCwd(), target))) + render.Info("read CLAUDE.md, then fill in .claude/rules/project.md with this project's commands") + } + return ExitOK +} + +// mustCwd is only ever used to make a path friendlier to print, so a failure +// degrades to the absolute path rather than to an error the user cannot act on. +func mustCwd() string { + cwd, err := os.Getwd() + if err != nil { + return "" + } + return cwd +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..c7224c2 --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,189 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/assets" + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +// initWorkspace scaffolds a throwaway workspace and returns its root. Every test +// passes --root explicitly rather than changing directory: os.Chdir is process-wide +// and would make these tests unsafe to run alongside anything else. +func initWorkspace(t *testing.T) string { + t.Helper() + root := t.TempDir() + if _, stderr, code := run(t, "init", "--root", root); code != ExitOK { + t.Fatalf("init: exit = %d (stderr: %s)", code, stderr) + } + return root +} + +func TestInitCreatesAWorkspace(t *testing.T) { + root := initWorkspace(t) + if !workspace.IsWorkspace(root) { + t.Fatal("init did not leave the workspace marker") + } + for _, f := range assets.Workspace() { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(f.Rel))); err != nil { + t.Errorf("%s missing after init: %v", f.Rel, err) + } + } + for _, dir := range []string{paths.Specs(root), paths.Plans(root), paths.Wiki(root), paths.ADR(root)} { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + t.Errorf("%s was not created: %v", dir, err) + } + } +} + +func TestInitJSONReportsEveryAction(t *testing.T) { + root := t.TempDir() + stdout, stderr, code := run(t, "init", "--root", root, "--json") + if code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + var got struct { + Root string `json:"root"` + Created int `json:"created"` + Changes []struct { + Path string `json:"path"` + Action string `json:"action"` + } `json:"changes"` + } + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("stdout is not valid JSON (%v): %q", err, stdout) + } + if got.Created != len(assets.Workspace()) || len(got.Changes) != len(assets.Workspace()) { + t.Errorf("created = %d, changes = %d, want %d of each", got.Created, len(got.Changes), len(assets.Workspace())) + } + if stderr != "" { + t.Errorf("stderr = %q, want empty so stdout can be piped", stderr) + } +} + +// Re-running init is the honest upgrade story until `scc update` exists, so it has +// to be free: nothing written, nothing reported as changed, exit 0. +func TestInitIsIdempotent(t *testing.T) { + root := initWorkspace(t) + before, err := os.ReadFile(paths.Manifest(root)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + stdout, stderr, code := run(t, "init", "--root", root, "--json") + if code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + var got struct { + Created int `json:"created"` + Skipped int `json:"skipped"` + ManifestWritten bool `json:"manifestWritten"` + } + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Created != 0 || got.ManifestWritten { + t.Errorf("second init wrote something: %+v", got) + } + after, err := os.ReadFile(paths.Manifest(root)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(before) != string(after) { + t.Error("the manifest changed across two identical inits") + } +} + +// Never author what the user owns. +func TestInitLeavesEditedFilesAlone(t *testing.T) { + root := initWorkspace(t) + rule := filepath.Join(root, filepath.FromSlash(".claude/rules/methodology.md")) + mine := "# my own methodology\n" + if err := workspace.AtomicWrite(rule, []byte(mine), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + if _, stderr, code := run(t, "init", "--root", root); code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + got, err := os.ReadFile(rule) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != mine { + t.Errorf("init overwrote an edited rule:\n%s", got) + } +} + +// --force overwrites, and says out loud what it destroyed. A silent clobber is the +// outcome that would make the tool untrustworthy. +func TestInitForceNamesWhatItClobbered(t *testing.T) { + root := initWorkspace(t) + rule := filepath.Join(root, filepath.FromSlash(".claude/rules/routing.md")) + if err := workspace.AtomicWrite(rule, []byte("# mine\n"), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + _, stderr, code := run(t, "init", "--root", root, "--force") + if code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + if !strings.Contains(stderr, ".claude/rules/routing.md") { + t.Errorf("stderr did not name the clobbered file: %q", stderr) + } + got, err := os.ReadFile(rule) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if strings.Contains(string(got), "# mine") { + t.Error("--force did not restore the template") + } +} + +func TestInitRejectsAMissingRoot(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nope") + _, stderr, code := run(t, "init", "--root", missing) + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, "does not exist") { + t.Errorf("stderr = %q, want it to say the root does not exist", stderr) + } +} + +// A stray positional is usually a name the user meant for another subcommand. +// Ignoring it would report success for something scc did not do. +func TestInitRejectsPositionals(t *testing.T) { + root := t.TempDir() + if _, _, code := run(t, "init", "--root", root, "user-auth"); code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } +} + +// The scaffolded workspace has to pass scc's own conventions: the manifest is a +// regular file (the marker contract), and every managed path is inside the root. +func TestScaffoldedWorkspaceIsSelfConsistent(t *testing.T) { + root := initWorkspace(t) + info, err := os.Stat(paths.Manifest(root)) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("the marker is not a regular file: %v", err) + } + for _, f := range assets.Workspace() { + if strings.HasPrefix(f.Rel, "/") || strings.Contains(f.Rel, "..") { + t.Errorf("%s escapes the workspace root", f.Rel) + } + } +} + +// finding duplicates the exit-code contract because the dependency runs the other +// way (cli imports finding). This is the guard that keeps the two from drifting. +func TestFindingExitCodesMatchTheCLIContract(t *testing.T) { + if finding.ExitOK != ExitOK || finding.ExitFindings != ExitFindings { + t.Errorf("finding exit codes (%d/%d) drifted from the CLI's (%d/%d)", + finding.ExitOK, finding.ExitFindings, ExitOK, ExitFindings) + } +} diff --git a/internal/cli/jsonout.go b/internal/cli/jsonout.go new file mode 100644 index 0000000..6c35c15 --- /dev/null +++ b/internal/cli/jsonout.go @@ -0,0 +1,36 @@ +package cli + +import ( + "encoding/json" + "flag" + "fmt" + + "github.com/protonspy/spec-claude-code/internal/render" +) + +// jsonFlagHelp is the single wording every command shows for --json, so the flag +// reads identically across the whole surface. +const jsonFlagHelp = "emit machine-readable JSON on stdout" + +// addJSON binds --json on fs. Every command that produces output binds it +// through this helper rather than declaring its own, so the flag name and help +// text can't drift apart between commands. +func addJSON(fs *flag.FlagSet) *bool { + return fs.Bool("json", false, jsonFlagHelp) +} + +// emitJSON writes v as indented JSON to stdout and returns the exit code. +// +// stdout carries nothing but the JSON document: diagnostics go to stderr (see +// package render), so a caller can pipe stdout straight into jq while still +// seeing warnings on the terminal. A marshal failure is the tool's own bug, so +// it reports on stderr and exits 1 rather than emitting half a document. +func emitJSON(v any) int { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + render.Err(fmt.Sprintf("could not encode JSON output: %v", err)) + return ExitError + } + fmt.Println(string(b)) + return ExitOK +} diff --git a/internal/cli/plan.go b/internal/cli/plan.go new file mode 100644 index 0000000..541a4a2 --- /dev/null +++ b/internal/cli/plan.go @@ -0,0 +1,242 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/protonspy/spec-claude-code/internal/assets" + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/mdscan" + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/render" + "github.com/protonspy/spec-claude-code/internal/validate" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +// runPlan dispatches `scc plan `. +// +// A plan is everything that is not worth a spec, and it covers both ends of that: +// an initiative too large for one spec, decomposed into specs it references, and a +// change small enough that the *what* was never in doubt, recorded as a checklist. +// It is one file, because it holds no state beyond its own checklist — where an item +// references a spec, the state lives in that spec and is read from there. +func runPlan(args []string) int { + if len(args) == 0 { + planUsage() + return ExitError + } + switch args[0] { + case "new": + return runPlanNew(args[1:]) + case "list": + return runPlanList(args[1:]) + case "delete": + return runPlanDelete(args[1:]) + case "validate": + return runPlanValidate(args[1:]) + case "help", "-h", "--help": + planUsage() + return ExitOK + default: + render.Err(fmt.Sprintf("unknown plan subcommand %q", args[0])) + planUsage() + return ExitError + } +} + +func runPlanNew(args []string) int { + fs := flag.NewFlagSet("plan new", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + kick := addKickoff(fs) + force := fs.Bool("force", false, "overwrite an existing plan") + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + if !kick.validate() { + return ExitError + } + name, ok := artifactName(rest, "plan") + if !ok { + return ExitError + } + target, ok := resolveRoot(*root) + if !ok || !requireWorkspace(target) { + return ExitError + } + + path := paths.Plan(target, name) + if _, err := os.Stat(path); err == nil && !*force { + render.Err(fmt.Sprintf("plan %q %v: %s", name, workspace.ErrAlreadyExists, relPath(target, path))) + render.Detail(" pass --force to overwrite it, or pick another name") + return ExitError + } + content, err := assets.Artifact("plan.md", assets.ArtifactData{ + Name: name, + Title: assets.Title(name), + Autonomy: *kick.autonomy, + CI: *kick.ci, + }) + if err != nil { + render.Err(err.Error()) + return ExitError + } + if err := workspace.WriteFile(path, content, *force); err != nil { + render.Err(err.Error()) + return ExitError + } + + if *jsonOut { + return emitJSON(struct { + Plan string `json:"plan"` + Path string `json:"path"` + Autonomy string `json:"autonomy"` + CI string `json:"ci"` + }{name, relPath(target, path), *kick.autonomy, *kick.ci}) + } + render.OK(relPath(target, path)) + render.Info(fmt.Sprintf("autonomy: %s · ci: %s — recorded in the frontmatter", *kick.autonomy, *kick.ci)) + render.Info("one source of truth per item: a checkbox, or a spec reference — never both") + return ExitOK +} + +// runPlanValidate checks one plan, or every plan when no name is given. +func runPlanValidate(args []string) int { + return runValidation("plan", args, func(root string, rest []string) (*finding.Set, error) { + switch len(rest) { + case 0: + return validate.Plans(root) + case 1: + name, ok := artifactName(rest, "plan") + if !ok { + return nil, errUsage + } + return validate.Plan(root, name) + default: + render.Err(fmt.Sprintf("plan validate takes at most one plan name, got %d", len(rest))) + return nil, errUsage + } + }) +} + +type planEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Autonomy string `json:"autonomy,omitempty"` + CI string `json:"ci,omitempty"` +} + +func runPlanList(args []string) int { + fs := flag.NewFlagSet("plan list", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + if !noPositionals(rest, "plan list") { + return ExitError + } + target, ok := resolveRoot(*root) + if !ok || !requireWorkspace(target) { + return ExitError + } + + entries, err := os.ReadDir(paths.Plans(target)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + render.Err(err.Error()) + return ExitError + } + plans := []planEntry{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + name := strings.TrimSuffix(e.Name(), ".md") + p := planEntry{Name: name, Path: relPath(target, filepath.Join(paths.Plans(target), e.Name()))} + if b, err := os.ReadFile(filepath.Join(paths.Plans(target), e.Name())); err == nil { + if fm, err := mdscan.ParseFrontmatter(string(b)); err == nil { + p.Autonomy, _ = fm.Get("autonomy") + p.CI, _ = fm.Get("ci") + } + } + plans = append(plans, p) + } + sort.Slice(plans, func(i, j int) bool { return plans[i].Name < plans[j].Name }) + + if *jsonOut { + return emitJSON(struct { + Plans []planEntry `json:"plans"` + Count int `json:"count"` + }{plans, len(plans)}) + } + if len(plans) == 0 { + render.Info(fmt.Sprintf("no plans yet — `%s plan new `", prog())) + return ExitOK + } + for _, p := range plans { + render.Info(fmt.Sprintf("%s %s", p.Name, p.Path)) + } + return ExitOK +} + +func runPlanDelete(args []string) int { + fs := flag.NewFlagSet("plan delete", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + force := fs.Bool("force", false, "required: deleting a plan discards its checklist and decomposition") + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + name, ok := artifactName(rest, "plan") + if !ok { + return ExitError + } + target, ok := resolveRoot(*root) + if !ok || !requireWorkspace(target) { + return ExitError + } + path := paths.Plan(target, name) + if !isFile(path) { + render.Err(fmt.Sprintf("no plan %q under %s", name, paths.PlansSeg)) + return ExitError + } + if !*force { + render.Err(fmt.Sprintf("refusing to delete plan %q without --force", name)) + return ExitError + } + if err := os.Remove(path); err != nil { + render.Err(err.Error()) + return ExitError + } + if *jsonOut { + return emitJSON(struct { + Deleted string `json:"deleted"` + Path string `json:"path"` + }{name, relPath(target, path)}) + } + render.OK(fmt.Sprintf("deleted %s", relPath(target, path))) + return ExitOK +} + +func planUsage() { + fmt.Fprintf(os.Stderr, `Usage: + %s plan new [--autonomy=auto|gated] [--ci=wait|no-wait] [--force] + %s plan list + %s plan delete --force + %s plan validate [] + +A plan is everything that is not worth a spec: plans/.md, holding a checklist +of tasks, references to the specs it decomposes into, or both. +`, prog(), prog(), prog(), prog()) +} diff --git a/internal/cli/plan_test.go b/internal/cli/plan_test.go new file mode 100644 index 0000000..6c5facd --- /dev/null +++ b/internal/cli/plan_test.go @@ -0,0 +1,191 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// A plan is one file, not a directory: it holds no state beyond its own checklist, +// and where an item references a spec the state lives in that spec. +func TestPlanNewWritesOneFile(t *testing.T) { + root := initWorkspace(t) + if _, stderr, code := run(t, "plan", "new", "checkout-revamp", "--root", root); code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + path := paths.Plan(root, "checkout-revamp") + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if !info.Mode().IsRegular() { + t.Error("a plan was created as something other than a regular file") + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !strings.Contains(string(b), "Checkout revamp") || strings.Contains(string(b), "{{") { + t.Errorf("plan was not rendered:\n%s", b) + } + // A plan is work, not knowledge, so it sits beside specs/ and never under docs/. + if _, err := os.Stat(filepath.Join(paths.Docs(root), "plans")); err == nil { + t.Error("a plans/ directory appeared under docs/") + } +} + +func TestPlanNewRecordsTheKickoffAnswers(t *testing.T) { + root := initWorkspace(t) + if _, stderr, code := run(t, "plan", "new", "tidy-up", "--root", root, + "--autonomy", "gated", "--ci", "no-wait"); code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + stdout, _, code := run(t, "plan", "list", "--root", root, "--json") + if code != ExitOK { + t.Fatalf("list: exit = %d", code) + } + var got struct { + Plans []struct { + Name string `json:"name"` + Path string `json:"path"` + Autonomy string `json:"autonomy"` + CI string `json:"ci"` + } `json:"plans"` + Count int `json:"count"` + } + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("stdout is not valid JSON (%v): %q", err, stdout) + } + if got.Count != 1 { + t.Fatalf("count = %d, want 1", got.Count) + } + p := got.Plans[0] + if p.Autonomy != "gated" || p.CI != "no-wait" { + t.Errorf("answers not read back: %+v", p) + } + // Paths in output are slash-separated so they match the manifest and read the + // same on every platform. + if p.Path != "plans/tidy-up.md" { + t.Errorf("path = %q, want %q", p.Path, "plans/tidy-up.md") + } +} + +func TestPlanRejectsHostileNames(t *testing.T) { + root := initWorkspace(t) + for _, name := range []string{"..", ".", "a/b", "/abs", "Not-Kebab", ""} { + for _, verb := range []string{"new", "delete"} { + args := []string{"plan", verb, name, "--root", root} + if verb == "delete" { + args = append(args, "--force") + } + if _, _, code := run(t, args...); code != ExitError { + t.Errorf("plan %s %q: exit = %d, want %d", verb, name, code, ExitError) + } + } + } + if _, err := os.Stat(paths.Plans(root)); err != nil { + t.Fatalf("plans/ was damaged by a rejected name: %v", err) + } +} + +func TestPlanNewRefusesToClobberWithoutForce(t *testing.T) { + root := initWorkspace(t) + if _, _, code := run(t, "plan", "new", "sweep", "--root", root); code != ExitOK { + t.Fatal("setup failed") + } + mine := "# my plan\n" + if err := os.WriteFile(paths.Plan(root, "sweep"), []byte(mine), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if _, _, code := run(t, "plan", "new", "sweep", "--root", root); code != ExitError { + t.Error("plan new over an existing plan did not fail") + } + if b, _ := os.ReadFile(paths.Plan(root, "sweep")); string(b) != mine { + t.Error("a refused `plan new` still overwrote the file") + } + if _, _, code := run(t, "plan", "new", "sweep", "--root", root, "--force"); code != ExitOK { + t.Error("plan new --force failed") + } +} + +func TestPlanDeleteRequiresForce(t *testing.T) { + root := initWorkspace(t) + if _, _, code := run(t, "plan", "new", "doomed", "--root", root); code != ExitOK { + t.Fatal("setup failed") + } + if _, _, code := run(t, "plan", "delete", "doomed", "--root", root); code != ExitError { + t.Error("delete without --force did not fail") + } + if _, err := os.Stat(paths.Plan(root, "doomed")); err != nil { + t.Fatalf("the plan was deleted without --force: %v", err) + } + if _, _, code := run(t, "plan", "delete", "doomed", "--root", root, "--force"); code != ExitOK { + t.Error("delete --force failed") + } + if _, err := os.Stat(paths.Plan(root, "doomed")); err == nil { + t.Error("delete --force left the plan behind") + } +} + +func TestPlanListOnAnEmptyWorkspace(t *testing.T) { + root := initWorkspace(t) + stdout, _, code := run(t, "plan", "list", "--root", root, "--json") + if code != ExitOK { + t.Fatalf("exit = %d", code) + } + if !strings.Contains(stdout, `"plans": []`) || !strings.Contains(stdout, `"count": 0`) { + t.Errorf("empty list = %s, want an empty array and a zero count", stdout) + } +} + +// A directory that happens to sit in plans/ is not a plan, and neither is a +// non-Markdown file. +func TestPlanListIgnoresNonPlans(t *testing.T) { + root := initWorkspace(t) + if err := os.MkdirAll(filepath.Join(paths.Plans(root), "not-a-plan.md"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(paths.Plans(root), "notes.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + stdout, _, code := run(t, "plan", "list", "--root", root, "--json") + if code != ExitOK { + t.Fatalf("exit = %d", code) + } + if !strings.Contains(stdout, `"count": 0`) { + t.Errorf("list picked up something that is not a plan: %s", stdout) + } +} + +func TestPlanCommandsRequireAWorkspace(t *testing.T) { + bare := t.TempDir() + for _, args := range [][]string{ + {"plan", "new", "x", "--root", bare}, + {"plan", "list", "--root", bare}, + {"plan", "delete", "x", "--root", bare, "--force"}, + } { + _, stderr, code := run(t, args...) + if code != ExitError { + t.Errorf("%v: exit = %d, want %d", args, code, ExitError) + } + if !strings.Contains(stderr, "not an scc workspace") { + t.Errorf("%v: stderr = %q, want it to say to run init", args, stderr) + } + } +} + +func TestPlanUsageAndUnknownSubcommand(t *testing.T) { + if _, stderr, code := run(t, "plan"); code != ExitError || !strings.Contains(stderr, "Usage:") { + t.Errorf("bare `plan` = (%d, %q), want a usage error", code, stderr) + } + if _, stderr, code := run(t, "plan", "nope"); code != ExitError || !strings.Contains(stderr, "unknown plan subcommand") { + t.Errorf("`plan nope` = (%d, %q), want an unknown-subcommand error", code, stderr) + } + if _, _, code := run(t, "plan", "help"); code != ExitOK { + t.Error("`plan help` did not exit 0") + } +} diff --git a/internal/cli/skill.go b/internal/cli/skill.go new file mode 100644 index 0000000..193f81c --- /dev/null +++ b/internal/cli/skill.go @@ -0,0 +1,52 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/render" + "github.com/protonspy/spec-claude-code/internal/validate" +) + +// runSkill dispatches `scc skill `. +// +// This validator is the only one in scc that enforces somebody else's standard: the +// Agent Skills spec is published and maintained outside this project, and tools +// across competing vendors read the same format. So a finding here is not scc's +// opinion about how a skill should look — it is non-conformance to a contract, which +// is the most defensible kind of check the tool can ship. +func runSkill(args []string) int { + if len(args) == 0 { + skillUsage() + return ExitError + } + switch args[0] { + case "validate": + return runValidation("skill", args[1:], func(root string, rest []string) (*finding.Set, error) { + if !noPositionals(rest, "skill validate") { + return nil, errUsage + } + return validate.Skills(root) + }) + case "help", "-h", "--help": + skillUsage() + return ExitOK + default: + render.Err(fmt.Sprintf("unknown skill subcommand %q", args[0])) + skillUsage() + return ExitError + } +} + +func skillUsage() { + fmt.Fprintf(os.Stderr, `Usage: + %s skill validate + +Checks every skill under .claude/skills/ against the published Agent Skills +specification: name charset and length, name matching its directory, description, +body budget, and references that resolve one level deep. + +Exits 2 when it finds something. +`, prog()) +} diff --git a/internal/cli/spec.go b/internal/cli/spec.go new file mode 100644 index 0000000..770931f --- /dev/null +++ b/internal/cli/spec.go @@ -0,0 +1,349 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/protonspy/spec-claude-code/internal/assets" + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/mdscan" + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/render" + "github.com/protonspy/spec-claude-code/internal/validate" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +// runSpec dispatches `scc spec `. A spec is the vehicle for work whose +// *what* and *how* need settling before code; everything else is a plan. +func runSpec(args []string) int { + if len(args) == 0 { + specUsage() + return ExitError + } + switch args[0] { + case "new": + return runSpecNew(args[1:]) + case "list": + return runSpecList(args[1:]) + case "show": + return runSpecShow(args[1:]) + case "delete": + return runSpecDelete(args[1:]) + case "validate": + return runSpecValidate(args[1:]) + case "help", "-h", "--help": + specUsage() + return ExitOK + default: + render.Err(fmt.Sprintf("unknown spec subcommand %q", args[0])) + specUsage() + return ExitError + } +} + +// specArtifacts pairs each of the three files with the template that renders it. +// Order is the order the phases happen in, which is also the order init reports. +var specArtifacts = []struct { + seg string + template string +}{ + {paths.RequirementsSeg, "requirements.md"}, + {paths.DesignSeg, "design.md"}, + {paths.TasksSeg, "tasks.md"}, +} + +func runSpecNew(args []string) int { + fs := flag.NewFlagSet("spec new", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + kick := addKickoff(fs) + force := fs.Bool("force", false, "overwrite an existing spec") + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + if !kick.validate() { + return ExitError + } + name, ok := artifactName(rest, "spec") + if !ok { + return ExitError + } + target, ok := resolveRoot(*root) + if !ok || !requireWorkspace(target) { + return ExitError + } + + dir := paths.Spec(target, name) + if _, err := os.Stat(dir); err == nil && !*force { + render.Err(fmt.Sprintf("spec %q %v: %s", name, workspace.ErrAlreadyExists, workspace.Relative(target, dir))) + render.Detail(" pass --force to overwrite it, or pick another name") + return ExitError + } + + data := assets.ArtifactData{ + Name: name, + Title: assets.Title(name), + Autonomy: *kick.autonomy, + CI: *kick.ci, + } + var written []string + for _, a := range specArtifacts { + content, err := assets.Artifact(a.template, data) + if err != nil { + render.Err(err.Error()) + return ExitError + } + path := filepath.Join(dir, a.seg) + if err := workspace.WriteFile(path, content, *force); err != nil { + render.Err(err.Error()) + return ExitError + } + written = append(written, relPath(target, path)) + } + + if *jsonOut { + return emitJSON(struct { + Spec string `json:"spec"` + Path string `json:"path"` + Files []string `json:"files"` + Autonomy string `json:"autonomy"` + CI string `json:"ci"` + }{name, relPath(target, dir), written, *kick.autonomy, *kick.ci}) + } + for _, w := range written { + render.OK(w) + } + render.Info(fmt.Sprintf("autonomy: %s · ci: %s — recorded in requirements.md", *kick.autonomy, *kick.ci)) + render.Info("write requirements first: EARS, numbered R1.1, all five patterns valid") + return ExitOK +} + +// runSpecValidate checks one spec, or every spec when no name is given. The default +// is every spec: a workspace-wide answer is what a CI job wants, and naming one +// feature is the exception rather than the common case. +func runSpecValidate(args []string) int { + return runValidation("spec", args, func(root string, rest []string) (*finding.Set, error) { + switch len(rest) { + case 0: + return validate.Specs(root) + case 1: + name, ok := artifactName(rest, "spec") + if !ok { + return nil, errUsage + } + return validate.Spec(root, name) + default: + render.Err(fmt.Sprintf("spec validate takes at most one feature name, got %d", len(rest))) + return nil, errUsage + } + }) +} + +// specEntry is what list and show report about one spec. The three booleans are the +// honest answer to "how far along is this?" without reading the files: a spec with +// tasks.md missing has not been decomposed yet. +type specEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Requirements bool `json:"requirements"` + Design bool `json:"design"` + Tasks bool `json:"tasks"` + Autonomy string `json:"autonomy,omitempty"` + CI string `json:"ci,omitempty"` +} + +func runSpecList(args []string) int { + fs := flag.NewFlagSet("spec list", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + if !noPositionals(rest, "spec list") { + return ExitError + } + target, ok := resolveRoot(*root) + if !ok || !requireWorkspace(target) { + return ExitError + } + + entries, err := os.ReadDir(paths.Specs(target)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + render.Err(err.Error()) + return ExitError + } + specs := []specEntry{} + for _, e := range entries { + if !e.IsDir() { + continue + } + specs = append(specs, describeSpec(target, e.Name())) + } + sort.Slice(specs, func(i, j int) bool { return specs[i].Name < specs[j].Name }) + + if *jsonOut { + return emitJSON(struct { + Specs []specEntry `json:"specs"` + Count int `json:"count"` + }{specs, len(specs)}) + } + if len(specs) == 0 { + render.Info(fmt.Sprintf("no specs yet — `%s spec new `", prog())) + return ExitOK + } + for _, s := range specs { + render.Info(fmt.Sprintf("%s %s", s.Name, phases(s))) + } + return ExitOK +} + +func runSpecShow(args []string) int { + fs := flag.NewFlagSet("spec show", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + name, ok := artifactName(rest, "spec") + if !ok { + return ExitError + } + target, ok := resolveRoot(*root) + if !ok || !requireWorkspace(target) { + return ExitError + } + if _, err := os.Stat(paths.Spec(target, name)); err != nil { + render.Err(fmt.Sprintf("no spec %q under %s", name, paths.SpecsSeg)) + return ExitError + } + + s := describeSpec(target, name) + if *jsonOut { + return emitJSON(s) + } + render.Info(render.Bold(s.Name) + " " + s.Path) + render.Info("phases: " + phases(s)) + if s.Autonomy != "" || s.CI != "" { + render.Info(fmt.Sprintf("kickoff: autonomy=%s ci=%s", orUnset(s.Autonomy), orUnset(s.CI))) + } else { + // A missing answer means the run predates the convention, not that the user + // did something wrong. Say what to do about it, not that it is a problem. + render.Info("kickoff: not recorded — ask once, then write it into the frontmatter") + } + return ExitOK +} + +func runSpecDelete(args []string) int { + fs := flag.NewFlagSet("spec delete", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + force := fs.Bool("force", false, "required: deleting a spec discards requirements, design, and tasks") + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + name, ok := artifactName(rest, "spec") + if !ok { + return ExitError + } + target, ok := resolveRoot(*root) + if !ok || !requireWorkspace(target) { + return ExitError + } + dir := paths.Spec(target, name) + if _, err := os.Stat(dir); err != nil { + render.Err(fmt.Sprintf("no spec %q under %s", name, paths.SpecsSeg)) + return ExitError + } + // --force is required rather than merely available. A spec is the record of what + // a feature was supposed to do; deleting one is a decision, and the flag is what + // makes it a deliberate one. + if !*force { + render.Err(fmt.Sprintf("refusing to delete spec %q without --force", name)) + return ExitError + } + if err := os.RemoveAll(dir); err != nil { + render.Err(err.Error()) + return ExitError + } + if *jsonOut { + return emitJSON(struct { + Deleted string `json:"deleted"` + Path string `json:"path"` + }{name, relPath(target, dir)}) + } + render.OK(fmt.Sprintf("deleted %s", relPath(target, dir))) + return ExitOK +} + +// describeSpec reports what is on disk for one spec, including the kickoff answers +// when requirements.md records them. A frontmatter block scc cannot parse is left +// empty rather than guessed at — `spec validate` is where a malformed artifact +// gets reported, not here. +func describeSpec(root, name string) specEntry { + s := specEntry{Name: name, Path: relPath(root, paths.Spec(root, name))} + s.Requirements = isFile(paths.Requirements(root, name)) + s.Design = isFile(paths.Design(root, name)) + s.Tasks = isFile(paths.Tasks(root, name)) + if b, err := os.ReadFile(paths.Requirements(root, name)); err == nil { + if fm, err := mdscan.ParseFrontmatter(string(b)); err == nil { + s.Autonomy, _ = fm.Get("autonomy") + s.CI, _ = fm.Get("ci") + } + } + return s +} + +func phases(s specEntry) string { + out := "" + for _, p := range []struct { + present bool + label string + }{{s.Requirements, "requirements"}, {s.Design, "design"}, {s.Tasks, "tasks"}} { + mark := render.Red("✗") + if p.present { + mark = render.Green("✓") + } + if out != "" { + out += " " + } + out += mark + " " + p.label + } + return out +} + +func isFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func orUnset(s string) string { + if s == "" { + return "unset" + } + return s +} + +func specUsage() { + fmt.Fprintf(os.Stderr, `Usage: + %s spec new [--autonomy=auto|gated] [--ci=wait|no-wait] [--force] + %s spec list + %s spec show + %s spec delete --force + %s spec validate [] + +A spec is for work whose *what* and *how* need settling before code: +specs//requirements.md, design.md, tasks.md. Everything else is a plan. +`, prog(), prog(), prog(), prog(), prog()) +} diff --git a/internal/cli/spec_test.go b/internal/cli/spec_test.go new file mode 100644 index 0000000..a948f69 --- /dev/null +++ b/internal/cli/spec_test.go @@ -0,0 +1,252 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/paths" +) + +func TestSpecNewWritesTheThreeArtifacts(t *testing.T) { + root := initWorkspace(t) + _, stderr, code := run(t, "spec", "new", "user-auth", "--root", root) + if code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + for _, path := range []string{ + paths.Requirements(root, "user-auth"), + paths.Design(root, "user-auth"), + paths.Tasks(root, "user-auth"), + } { + b, err := os.ReadFile(path) + if err != nil { + t.Errorf("%s: %v", path, err) + continue + } + if !strings.Contains(string(b), "User auth") { + t.Errorf("%s: the feature title was not rendered", path) + } + if strings.Contains(string(b), "{{") { + t.Errorf("%s: an unrendered template action shipped into the artifact", path) + } + } +} + +// The kickoff answers are recorded on the artifact so the run is reproducible from +// the file and nobody is asked twice. +func TestSpecNewRecordsTheKickoffAnswers(t *testing.T) { + root := initWorkspace(t) + if _, stderr, code := run(t, "spec", "new", "billing", "--root", root, + "--autonomy", "gated", "--ci", "no-wait"); code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + b, err := os.ReadFile(paths.Requirements(root, "billing")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !strings.Contains(string(b), "autonomy: gated") || !strings.Contains(string(b), "ci: no-wait") { + t.Errorf("frontmatter did not record the answers:\n%s", b) + } + + stdout, _, code := run(t, "spec", "show", "billing", "--root", root, "--json") + if code != ExitOK { + t.Fatalf("show: exit = %d", code) + } + var got struct { + Name string `json:"name"` + Autonomy string `json:"autonomy"` + CI string `json:"ci"` + Requirements bool `json:"requirements"` + Design bool `json:"design"` + Tasks bool `json:"tasks"` + } + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("stdout is not valid JSON (%v): %q", err, stdout) + } + if got.Autonomy != "gated" || got.CI != "no-wait" { + t.Errorf("show did not read the answers back: %+v", got) + } + if !got.Requirements || !got.Design || !got.Tasks { + t.Errorf("show did not see all three artifacts: %+v", got) + } +} + +// A typo'd answer recorded verbatim would read as an answer nobody gave. +func TestSpecNewRejectsAnUnknownKickoffAnswer(t *testing.T) { + root := initWorkspace(t) + for _, args := range [][]string{ + {"spec", "new", "a", "--root", root, "--autonomy", "automatic"}, + {"spec", "new", "a", "--root", root, "--ci", "later"}, + } { + _, stderr, code := run(t, args...) + if code != ExitError { + t.Errorf("%v: exit = %d, want %d", args, code, ExitError) + } + if !strings.Contains(stderr, "must be") { + t.Errorf("%v: stderr = %q, want it to name the valid values", args, stderr) + } + if _, err := os.Stat(paths.Spec(root, "a")); err == nil { + t.Errorf("%v: the spec was created despite the invalid flag", args) + } + } +} + +// SafeName stands between a CLI argument and filepath.Join. Without it +// `scc spec delete ..` resolves to the workspace root and takes the project with it. +func TestSpecRejectsHostileNames(t *testing.T) { + root := initWorkspace(t) + marker := filepath.Join(root, filepath.FromSlash(".claude/rules/routing.md")) + for _, name := range []string{"..", ".", "../escape", "a/b", "/abs", "Not-Kebab", "under_score", ""} { + for _, verb := range []string{"new", "show", "delete"} { + args := []string{"spec", verb, name, "--root", root} + if verb == "delete" { + args = append(args, "--force") + } + if _, _, code := run(t, args...); code != ExitError { + t.Errorf("spec %s %q: exit = %d, want %d", verb, name, code, ExitError) + } + } + } + // The workspace is still there: no traversal reached anything. + if _, err := os.Stat(marker); err != nil { + t.Fatalf("a rejected name still damaged the workspace: %v", err) + } + if _, err := os.Stat(paths.Specs(root)); err != nil { + t.Fatalf("specs/ was removed by a rejected name: %v", err) + } +} + +func TestSpecNewRefusesToClobberWithoutForce(t *testing.T) { + root := initWorkspace(t) + if _, _, code := run(t, "spec", "new", "cart", "--root", root); code != ExitOK { + t.Fatal("setup failed") + } + mine := "# my requirements\n" + if err := os.WriteFile(paths.Requirements(root, "cart"), []byte(mine), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, stderr, code := run(t, "spec", "new", "cart", "--root", root) + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, "already exists") { + t.Errorf("stderr = %q, want it to say the spec already exists", stderr) + } + b, _ := os.ReadFile(paths.Requirements(root, "cart")) + if string(b) != mine { + t.Error("a refused `spec new` still overwrote the file") + } + + if _, stderr, code := run(t, "spec", "new", "cart", "--root", root, "--force"); code != ExitOK { + t.Fatalf("--force: exit = %d (stderr: %s)", code, stderr) + } + b, _ = os.ReadFile(paths.Requirements(root, "cart")) + if string(b) == mine { + t.Error("--force did not overwrite") + } +} + +func TestSpecListIsSortedAndJSON(t *testing.T) { + root := initWorkspace(t) + for _, name := range []string{"zebra", "alpha", "middle"} { + if _, _, code := run(t, "spec", "new", name, "--root", root); code != ExitOK { + t.Fatalf("creating %s failed", name) + } + } + stdout, _, code := run(t, "spec", "list", "--root", root, "--json") + if code != ExitOK { + t.Fatalf("exit = %d", code) + } + var got struct { + Specs []struct{ Name string } `json:"specs"` + Count int `json:"count"` + } + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Count != 3 { + t.Fatalf("count = %d, want 3", got.Count) + } + if got.Specs[0].Name != "alpha" || got.Specs[2].Name != "zebra" { + t.Errorf("list is not sorted: %+v", got.Specs) + } +} + +// An empty workspace is not an error, and the empty JSON document still has both +// keys so a caller can index the array without a nil check. +func TestSpecListOnAnEmptyWorkspace(t *testing.T) { + root := initWorkspace(t) + stdout, _, code := run(t, "spec", "list", "--root", root, "--json") + if code != ExitOK { + t.Fatalf("exit = %d", code) + } + if !strings.Contains(stdout, `"specs": []`) || !strings.Contains(stdout, `"count": 0`) { + t.Errorf("empty list = %s, want an empty array and a zero count", stdout) + } +} + +func TestSpecDeleteRequiresForce(t *testing.T) { + root := initWorkspace(t) + if _, _, code := run(t, "spec", "new", "doomed", "--root", root); code != ExitOK { + t.Fatal("setup failed") + } + if _, _, code := run(t, "spec", "delete", "doomed", "--root", root); code != ExitError { + t.Error("delete without --force did not fail") + } + if _, err := os.Stat(paths.Spec(root, "doomed")); err != nil { + t.Fatalf("the spec was deleted without --force: %v", err) + } + if _, stderr, code := run(t, "spec", "delete", "doomed", "--root", root, "--force"); code != ExitOK { + t.Fatalf("delete --force: exit = %d (stderr: %s)", code, stderr) + } + if _, err := os.Stat(paths.Spec(root, "doomed")); err == nil { + t.Error("delete --force left the spec behind") + } +} + +func TestSpecDeleteUnknownFeature(t *testing.T) { + root := initWorkspace(t) + _, stderr, code := run(t, "spec", "delete", "ghost", "--root", root, "--force") + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, "ghost") { + t.Errorf("stderr = %q, want it to name the missing spec", stderr) + } +} + +// Outside a workspace these commands would otherwise act on whatever directory the +// upward walk fell back to — for a command run from $HOME, the user's own tree. +func TestSpecCommandsRequireAWorkspace(t *testing.T) { + bare := t.TempDir() + for _, args := range [][]string{ + {"spec", "new", "x", "--root", bare}, + {"spec", "list", "--root", bare}, + {"spec", "show", "x", "--root", bare}, + {"spec", "delete", "x", "--root", bare, "--force"}, + } { + _, stderr, code := run(t, args...) + if code != ExitError { + t.Errorf("%v: exit = %d, want %d", args, code, ExitError) + } + if !strings.Contains(stderr, "not an scc workspace") { + t.Errorf("%v: stderr = %q, want it to say to run init", args, stderr) + } + } +} + +func TestSpecUsageAndUnknownSubcommand(t *testing.T) { + if _, stderr, code := run(t, "spec"); code != ExitError || !strings.Contains(stderr, "Usage:") { + t.Errorf("bare `spec` = (%d, %q), want a usage error", code, stderr) + } + if _, stderr, code := run(t, "spec", "nope"); code != ExitError || !strings.Contains(stderr, "unknown spec subcommand") { + t.Errorf("`spec nope` = (%d, %q), want an unknown-subcommand error", code, stderr) + } + if _, _, code := run(t, "spec", "help"); code != ExitOK { + t.Error("`spec help` did not exit 0") + } +} diff --git a/internal/cli/validate.go b/internal/cli/validate.go new file mode 100644 index 0000000..b448b8b --- /dev/null +++ b/internal/cli/validate.go @@ -0,0 +1,55 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "os" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/render" +) + +// errUsage marks a failure the handler has already reported to the user, so +// runValidation returns the usage exit code without printing a second message. +var errUsage = errors.New("usage") + +// runValidation is the shape of every validate command: bind --root and --json, +// resolve the workspace, run the checks, report, and exit on the contract. +// +// One function rather than one per validator, because the part users depend on is +// identical across all of them — the flags, the stdout/stderr split, and above all +// the exit code. A finding is a legitimate answer to a lint question, so it exits +// `2` and not `1`: CI and agents branch on "ran and found problems" separately from +// "could not run", and collapsing the two would break every job that does. +func runValidation(subject string, args []string, fn func(root string, rest []string) (*finding.Set, error)) int { + fs := flag.NewFlagSet(subject+" validate", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + target, ok := resolveRoot(*root) + if !ok || !requireWorkspace(target) { + return ExitError + } + + set, err := fn(target, rest) + if err != nil { + if !errors.Is(err, errUsage) { + render.Err(fmt.Sprintf("%s validate: %v", subject, err)) + } + return ExitError + } + + if *jsonOut { + if code := emitJSON(set.Document()); code != ExitOK { + return code + } + return set.ExitCode() + } + set.Report(subject) + return set.ExitCode() +} diff --git a/internal/cli/validate_all.go b/internal/cli/validate_all.go new file mode 100644 index 0000000..8683137 --- /dev/null +++ b/internal/cli/validate_all.go @@ -0,0 +1,66 @@ +package cli + +import ( + "flag" + "fmt" + "os" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/render" + "github.com/protonspy/spec-claude-code/internal/validate" +) + +// runValidateAll is `scc validate`: every applicable validator, one exit code, one +// JSON document. +// +// It exists because a user who has to invoke eight validators separately has eight +// chances to skip one, and because CI wants a single gate. Validators whose subject is +// absent contribute nothing rather than complaining — a workspace with no skills is not +// a workspace with findings. +func runValidateAll(args []string) int { + fs := flag.NewFlagSet("validate", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := addRoot(fs) + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, args) + if err != nil { + return ExitError + } + if !noPositionals(rest, "validate") { + return ExitError + } + target, ok := resolveRoot(*root) + if !ok || !requireWorkspace(target) { + return ExitError + } + + set, results, err := validate.Everything(target) + if err != nil { + render.Err(fmt.Sprintf("validate: %v", err)) + return ExitError + } + + if *jsonOut { + if code := emitJSON(struct { + finding.Document + Validators []validate.Result `json:"validators"` + }{set.Document(), results}); code != ExitOK { + return code + } + return set.ExitCode() + } + + // Counts per validator first, then the findings themselves. "Few findings, each + // fixable" is easiest to violate right here, where every check reports at once — + // so the shape of the output has to carry the summary before the detail. + for _, r := range results { + line := fmt.Sprintf("%-9s %d", r.Name, r.Findings) + if r.Findings == 0 { + render.Info(line) + continue + } + render.Warn(line) + } + set.Report("validate") + return set.ExitCode() +} diff --git a/internal/cli/validate_test.go b/internal/cli/validate_test.go new file mode 100644 index 0000000..87aefc1 --- /dev/null +++ b/internal/cli/validate_test.go @@ -0,0 +1,180 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/validate" +) + +// The property that decides whether this tool is usable: what scc generates passes +// what scc checks. A validator that fires on the file the tool just wrote teaches the +// user to ignore all of them on their first run. +func TestFreshArtifactsPassTheirOwnValidators(t *testing.T) { + root := initWorkspace(t) + if _, stderr, code := run(t, "spec", "new", "user-auth", "--root", root); code != ExitOK { + t.Fatalf("spec new: %d (%s)", code, stderr) + } + if _, stderr, code := run(t, "plan", "new", "checkout-revamp", "--root", root); code != ExitOK { + t.Fatalf("plan new: %d (%s)", code, stderr) + } + stdout, stderr, code := run(t, "validate", "--root", root, "--json") + if code != ExitOK { + t.Errorf("validate on a freshly scaffolded workspace exited %d, want %d\nstdout: %s\nstderr: %s", + code, ExitOK, stdout, stderr) + } + var doc struct { + Findings []map[string]any `json:"findings"` + Count int `json:"count"` + Validators []struct { + Validator string `json:"validator"` + Findings int `json:"findings"` + } `json:"validators"` + } + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Fatalf("stdout is not valid JSON (%v): %q", err, stdout) + } + if doc.Count != 0 { + t.Errorf("findings on fresh artifacts: %v", doc.Findings) + } + // Every validator ran and reported, even the ones with nothing to look at — which + // is what lets `scc validate` be the single gate instead of a list the user has to + // keep in sync. + if len(doc.Validators) != len(validate.All()) { + t.Errorf("validators = %+v, want one entry per validator (%d)", doc.Validators, len(validate.All())) + } +} + +// Exit 2 is the contract: a finding is a legitimate answer to a lint question, not a +// failure of the tool, and CI branches on the difference. +func TestValidateExitsTwoOnFindings(t *testing.T) { + root := initWorkspace(t) + if _, _, code := run(t, "spec", "new", "billing", "--root", root); code != ExitOK { + t.Fatal("setup failed") + } + // One task, no methodology: the finding this whole practice exists to produce. + tasks := "# Billing — tasks\n\n- [ ] 1.1 Compute the total — R1.1\n" + if err := os.WriteFile(paths.Tasks(root, "billing"), []byte(tasks), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, stderr, code := run(t, "spec", "validate", "billing", "--root", root) + if code != ExitFindings { + t.Errorf("exit = %d, want %d", code, ExitFindings) + } + if !strings.Contains(stderr, "task.missing-methodology") { + t.Errorf("stderr = %q, want the rule slug so a CI job can filter on it", stderr) + } + + // The same run under --json: findings on stdout, exit still 2. + stdout, _, code := run(t, "spec", "validate", "billing", "--root", root, "--json") + if code != ExitFindings { + t.Errorf("--json exit = %d, want %d", code, ExitFindings) + } + var doc struct { + Count int `json:"count"` + } + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Fatalf("stdout is not valid JSON (%v): %q", err, stdout) + } + if doc.Count == 0 { + t.Error("--json reported no findings while the exit code said there were some") + } +} + +func TestSpecValidateDefaultsToEverySpec(t *testing.T) { + root := initWorkspace(t) + for _, name := range []string{"alpha", "beta"} { + if _, _, code := run(t, "spec", "new", name, "--root", root); code != ExitOK { + t.Fatal("setup failed") + } + } + if err := os.WriteFile(paths.Tasks(root, "beta"), []byte("# tasks\n\n- [ ] 1.1 No methodology — R1.1\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if _, _, code := run(t, "spec", "validate", "--root", root); code != ExitFindings { + t.Errorf("bare `spec validate` exited %d, want it to have checked every spec", code) + } +} + +func TestValidateRejectsHostileAndExtraArguments(t *testing.T) { + root := initWorkspace(t) + for _, args := range [][]string{ + {"spec", "validate", "..", "--root", root}, + {"spec", "validate", "a", "b", "--root", root}, + {"plan", "validate", "..", "--root", root}, + {"validate", "extra", "--root", root}, + } { + if _, _, code := run(t, args...); code != ExitError { + t.Errorf("%v: exit = %d, want %d", args, code, ExitError) + } + } +} + +func TestSkillValidate(t *testing.T) { + root := initWorkspace(t) + // No skills ship, so there is nothing to find — and that is exit 0, not a + // complaint about an absent directory. + if _, _, code := run(t, "skill", "validate", "--root", root); code != ExitOK { + t.Errorf("skill validate on a workspace with no skills exited %d, want %d", code, ExitOK) + } + + dir := filepath.Join(paths.Skills(root), "pdf-processing") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + body := "---\nname: pdf-tools\ndescription: Does things with PDFs. Use when handling PDFs.\n---\n\nBody.\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + _, stderr, code := run(t, "skill", "validate", "--root", root) + if code != ExitFindings { + t.Errorf("exit = %d, want %d", code, ExitFindings) + } + if !strings.Contains(stderr, "skill.name-mismatch") { + t.Errorf("stderr = %q, want the name-mismatch finding", stderr) + } +} + +// Whatever skills scc ships must pass scc's own validator. A tool that ships +// non-conforming skills has no standing to check anyone else's. +func TestShippedSkillsPassTheirOwnValidator(t *testing.T) { + root := initWorkspace(t) + if _, stderr, code := run(t, "skill", "validate", "--root", root); code != ExitOK { + t.Errorf("the scaffolded workspace's skills do not pass skill validate: exit %d (%s)", code, stderr) + } +} + +func TestValidateRequiresAWorkspace(t *testing.T) { + bare := t.TempDir() + for _, args := range [][]string{ + {"validate", "--root", bare}, + {"spec", "validate", "--root", bare}, + {"plan", "validate", "--root", bare}, + {"skill", "validate", "--root", bare}, + } { + _, stderr, code := run(t, args...) + if code != ExitError { + t.Errorf("%v: exit = %d, want %d", args, code, ExitError) + } + if !strings.Contains(stderr, "not an scc workspace") { + t.Errorf("%v: stderr = %q", args, stderr) + } + } +} + +func TestSkillUsageAndUnknownSubcommand(t *testing.T) { + if _, stderr, code := run(t, "skill"); code != ExitError || !strings.Contains(stderr, "Usage:") { + t.Errorf("bare `skill` = (%d, %q), want a usage error", code, stderr) + } + if _, stderr, code := run(t, "skill", "nope"); code != ExitError || !strings.Contains(stderr, "unknown skill subcommand") { + t.Errorf("`skill nope` = (%d, %q)", code, stderr) + } + if _, _, code := run(t, "skill", "help"); code != ExitOK { + t.Error("`skill help` did not exit 0") + } +} diff --git a/internal/ears/ears.go b/internal/ears/ears.go new file mode 100644 index 0000000..f7fa31e --- /dev/null +++ b/internal/ears/ears.go @@ -0,0 +1,285 @@ +// Package ears parses a requirement written in EARS — the Easy Approach to +// Requirements Syntax (Mavin et al., Rolls-Royce, IEEE RE 2009). +// +// EARS is why scc can check a requirement at all. Prose has nothing to validate; an +// EARS clause has named parts, and a missing part is a finding rather than an +// opinion. +// +// The ruleset: zero or many preconditions · zero or one trigger · one system name · +// one or many responses, and the clauses always appear in that order. That yields +// five patterns plus their combinations, and **all five are valid**: +// +// The shall ubiquitous +// While , the shall state-driven +// Where , the shall optional feature +// When , the shall event-driven +// If , then the shall unwanted behavior +// +// A parser built on the event-driven pattern alone — the shape most people picture +// when they hear EARS — would reject four legitimate patterns and, worse, push +// authors to invent a trigger for a requirement that is simply always true. Unwanted +// behavior (`If … then …`) is the pattern most often missing from generated +// requirements and the one that most often matters. +// +// # How it parses, and why in that order +// +// The response is found first, then the system, and only then the leading clauses. +// The obvious order — read clauses left to right, splitting on commas — breaks on a +// clause that contains a comma ("When the cart is empty, or has one item, the +// checkout shall …"), which is ordinary English and not a defect. Anchoring on +// `shall` and on the last `the` before it makes the clause region unambiguous, so an +// internal comma costs at most the exact clause text and never a spurious finding. +package ears + +import ( + "fmt" + "strings" +) + +// Pattern names the EARS shape a requirement turned out to be. +type Pattern string + +const ( + Ubiquitous Pattern = "ubiquitous" + StateDriven Pattern = "state-driven" + OptionalFeature Pattern = "optional-feature" + EventDriven Pattern = "event-driven" + UnwantedBehavior Pattern = "unwanted-behavior" + Complex Pattern = "complex" +) + +// Requirement is one parsed EARS clause. +type Requirement struct { + Pattern Pattern + + // Preconditions are the `While` and `Where` clauses, in the order written. + Preconditions []Precondition + + // Trigger is the `When` or `If` clause, empty when there is none. + Trigger string + // TriggerKeyword is "When" or "If" — which one decides whether this describes an + // event or unwanted behavior, and they are not interchangeable. + TriggerKeyword string + + // System is the name between `the` and `shall`. + System string + + // Response is everything after `shall`. + // + // EARS allows many responses, and this is deliberately one string rather than a + // list: splitting on "and" would invent structure the author did not write, and + // an invented clause boundary is a finding waiting to be wrong. What is + // checkable is that there is a response at all. + Response string +} + +// Precondition is one leading clause and the keyword that introduced it. +type Precondition struct { + Keyword string // "While" or "Where" + Text string +} + +// The keywords, matched case-insensitively. EARS capitalizes them, but a requirement +// written in lowercase is a style slip and not a different requirement — reporting it +// as unparseable would be a false positive. +const ( + kwWhile = "While" + kwWhere = "Where" + kwWhen = "When" + kwIf = "If" + kwThen = "then" + kwThe = "the" + kwShall = "shall" +) + +var leadingKeywords = []string{kwWhile, kwWhere, kwWhen, kwIf} + +// Parse reads one requirement. +// +// The error names what is missing rather than guessing at intent: "no `shall`" is +// actionable, and "this is not EARS" is not. Every message is written to be a finding +// a reader can act on without knowing this package exists. +func Parse(text string) (Requirement, error) { + norm := strings.Join(strings.Fields(text), " ") + if norm == "" { + return Requirement{}, fmt.Errorf("the requirement is empty") + } + + head, response, err := splitShall(norm) + if err != nil { + return Requirement{}, err + } + if response == "" { + return Requirement{}, fmt.Errorf("nothing follows `shall`: a requirement needs at least one response") + } + + // The system is what follows the last `the` before `shall`. Searching from the + // right is what keeps a precondition that contains the word "the" from being + // mistaken for the system clause. + at := lastWordIndex(head+" ", kwThe) + if at < 0 { + return Requirement{}, fmt.Errorf("expected `the shall `: no `the` before `shall` in %q", clip(head)) + } + system := strings.TrimSpace(head[min(at+len(kwThe)+1, len(head)):]) + if system == "" { + return Requirement{}, fmt.Errorf("no system name between `the` and `shall`") + } + + req := Requirement{System: system, Response: response} + if err := parseClauses(&req, strings.TrimSpace(head[:at])); err != nil { + return req, err + } + req.Pattern = classify(req) + return req, nil +} + +// parseClauses reads the leading clause region — everything before `the `. +// +// Splitting on commas and then reattaching a part that does not open with a keyword +// is what makes an internal comma harmless: "the cart is empty, or has one item" +// stays one trigger instead of becoming a second clause nobody wrote. +func parseClauses(req *Requirement, region string) error { + if region == "" { + return nil + } + var current *string // the clause text being accumulated + var currentKeyword string // the keyword that opened it + thenSeen := false + + parts := strings.Split(region, ",") + // A trailing empty part means the region ended with the comma that closes the + // last clause. `If , then` closes it too: the comma is before the + // `then`, which belongs to the pattern rather than to the clause. + last := strings.TrimSpace(parts[len(parts)-1]) + closed := last == "" + if !closed && strings.EqualFold(last, kwThen) { + thenSeen, closed = true, true + parts = parts[:len(parts)-1] + } + + for _, raw := range parts { + part := strings.TrimSpace(raw) + if part == "" { + continue + } + if strings.EqualFold(part, kwThen) { + if !strings.EqualFold(currentKeyword, kwIf) { + return fmt.Errorf("`then` belongs to the unwanted-behavior pattern (`If , then …`) and there is no `If` here") + } + thenSeen = true + continue + } + kw, rest, ok := openingKeyword(part) + if !ok { + if current == nil { + return fmt.Errorf("%q is not an EARS keyword: a requirement opens with While, Where, When, If, or `the shall`", + firstWord(part)) + } + // A comma inside a clause. Put it back rather than inventing a boundary. + *current += ", " + part + continue + } + switch kw { + case kwWhile, kwWhere: + if req.Trigger != "" { + return fmt.Errorf("`%s` appears after the trigger; EARS clause order is preconditions, then the trigger, then the system response", kw) + } + req.Preconditions = append(req.Preconditions, Precondition{Keyword: kw, Text: rest}) + current = &req.Preconditions[len(req.Preconditions)-1].Text + case kwWhen, kwIf: + if req.Trigger != "" { + return fmt.Errorf("a requirement takes at most one trigger, and this one has two (`%s` and `%s`); split it in two", req.TriggerKeyword, kw) + } + req.Trigger, req.TriggerKeyword = rest, kw + current = &req.Trigger + } + currentKeyword = kw + } + + if strings.EqualFold(req.TriggerKeyword, kwIf) && !thenSeen { + return fmt.Errorf("the unwanted-behavior pattern is `If , then the shall ` — the `then` is missing") + } + if !closed { + return fmt.Errorf("the `%s` clause is not closed by a comma before `the %s shall`", currentKeyword, req.System) + } + return nil +} + +// classify names the pattern. A combination is `complex`, which is a legitimate EARS +// pattern and not a fallback for "did not fit". +func classify(req Requirement) Pattern { + switch { + case len(req.Preconditions) > 1, len(req.Preconditions) > 0 && req.Trigger != "": + return Complex + case len(req.Preconditions) == 1: + if req.Preconditions[0].Keyword == kwWhere { + return OptionalFeature + } + return StateDriven + case req.TriggerKeyword == kwIf: + return UnwantedBehavior + case req.Trigger != "": + return EventDriven + default: + return Ubiquitous + } +} + +// splitShall divides a requirement at `shall`, returning what precedes it and the +// response. A `shall` at the very start of the clause still splits, so the error the +// caller reports is the missing system name rather than a missing keyword. +func splitShall(s string) (head, response string, err error) { + lower := strings.ToLower(s) + if i := strings.Index(lower, " "+kwShall+" "); i >= 0 { + return s[:i], strings.TrimSpace(s[i+len(kwShall)+2:]), nil + } + if strings.HasPrefix(lower, kwShall+" ") { + return "", strings.TrimSpace(s[len(kwShall):]), nil + } + // `shall` with nothing after it: the response is what is missing, and saying so + // is more useful than reporting the keyword absent. + if strings.HasSuffix(lower, " "+kwShall) || lower == kwShall { + return strings.TrimSuffix(s, s[len(s)-len(kwShall):]), "", nil + } + return "", "", fmt.Errorf("no `shall` clause: an EARS requirement says what the system shall do") +} + +// openingKeyword reports the EARS keyword a clause opens with. +func openingKeyword(part string) (keyword, rest string, ok bool) { + for _, kw := range leadingKeywords { + if len(part) > len(kw) && strings.EqualFold(part[:len(kw)], kw) && part[len(kw)] == ' ' { + return kw, strings.TrimSpace(part[len(kw)+1:]), true + } + } + return "", "", false +} + +// lastWordIndex returns the index of the last case-insensitive occurrence of word +// that sits on a word boundary, or -1. The boundary check is what keeps "breathe " +// from matching "the ". +func lastWordIndex(s, word string) int { + lower, target := strings.ToLower(s), strings.ToLower(word)+" " + for i := strings.LastIndex(lower, target); i >= 0; i = strings.LastIndex(lower[:i], target) { + if i == 0 || lower[i-1] == ' ' || lower[i-1] == ',' { + return i + } + } + return -1 +} + +func firstWord(s string) string { + if i := strings.IndexByte(s, ' '); i > 0 { + return s[:i] + } + return s +} + +// clip shortens text for an error message. A finding that quotes an entire paragraph +// back at the user is a finding they will not read. +func clip(s string) string { + const max = 40 + if len(s) <= max { + return s + } + return s[:max] + "…" +} diff --git a/internal/ears/ears_test.go b/internal/ears/ears_test.go new file mode 100644 index 0000000..95e91a1 --- /dev/null +++ b/internal/ears/ears_test.go @@ -0,0 +1,191 @@ +package ears + +import ( + "strings" + "testing" +) + +// All five patterns are valid, and this is the test that says so. A parser built on +// the event-driven pattern alone would reject four legitimate shapes and push authors +// to invent triggers for requirements that are simply always true. +func TestAllFivePatterns(t *testing.T) { + cases := []struct { + text string + want Pattern + system string + trigger string + }{ + { + text: "The manifest writer shall record a content hash per managed file", + want: Ubiquitous, + system: "manifest writer", + }, + { + text: "While a merge is in progress, the CLI shall refuse to write the manifest", + want: StateDriven, + system: "CLI", + }, + { + text: "Where the workspace has a codewiki, the validator shall resolve every citation", + want: OptionalFeature, + system: "validator", + }, + { + text: "When a task is missing its methodology, the validator shall exit 2", + want: EventDriven, + system: "validator", + trigger: "a task is missing its methodology", + }, + { + text: "If the manifest is unreadable, then the CLI shall report the path and exit 1", + want: UnwantedBehavior, + system: "CLI", + trigger: "the manifest is unreadable", + }, + } + for _, c := range cases { + req, err := Parse(c.text) + if err != nil { + t.Errorf("%q: %v", c.text, err) + continue + } + if req.Pattern != c.want { + t.Errorf("%q: pattern = %q, want %q", c.text, req.Pattern, c.want) + } + if req.System != c.system { + t.Errorf("%q: system = %q, want %q", c.text, req.System, c.system) + } + if req.Trigger != c.trigger { + t.Errorf("%q: trigger = %q, want %q", c.text, req.Trigger, c.trigger) + } + if req.Response == "" { + t.Errorf("%q: no response captured", c.text) + } + } +} + +// More than one keyword is the complex pattern — a legitimate EARS shape, not a +// fallback for "did not fit". +func TestComplexPattern(t *testing.T) { + req, err := Parse("While the session is gated, when a phase completes, the orchestrator shall stop and present it") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if req.Pattern != Complex { + t.Errorf("pattern = %q, want %q", req.Pattern, Complex) + } + if len(req.Preconditions) != 1 || req.Preconditions[0].Keyword != "While" { + t.Errorf("preconditions = %+v", req.Preconditions) + } + if req.Trigger != "a phase completes" { + t.Errorf("trigger = %q", req.Trigger) + } + if req.System != "orchestrator" { + t.Errorf("system = %q", req.System) + } +} + +// Zero or many preconditions: two of them is still valid, and still complex. +func TestManyPreconditions(t *testing.T) { + req, err := Parse("While the branch is clean, where a remote exists, the CLI shall open a pull request") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(req.Preconditions) != 2 { + t.Fatalf("preconditions = %+v, want 2", req.Preconditions) + } + if req.Pattern != Complex { + t.Errorf("pattern = %q, want %q", req.Pattern, Complex) + } +} + +// The response is captured whole. EARS allows many responses, and splitting on "and" +// would invent a clause boundary the author did not write. +func TestResponseIsNotSplit(t *testing.T) { + req, err := Parse("The CLI shall write the file and record its hash") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if req.Response != "write the file and record its hash" { + t.Errorf("response = %q, want the whole clause", req.Response) + } +} + +// A style slip in capitalization is not a different requirement, and reporting it as +// unparseable would be a false positive. +func TestKeywordsAreCaseInsensitive(t *testing.T) { + for _, text := range []string{ + "WHEN a task lands, THE validator SHALL run", + "when a task lands, the validator shall run", + "When a task lands, The Validator shall run", + } { + if _, err := Parse(text); err != nil { + t.Errorf("%q: %v", text, err) + } + } +} + +// Extra whitespace, including a line wrapped by an editor, is not a defect. +func TestWhitespaceIsNormalized(t *testing.T) { + req, err := Parse(" When a task lands,\n the validator shall run ") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if req.System != "validator" || req.Response != "run" { + t.Errorf("req = %+v", req) + } +} + +// Each error names what is missing. "This is not EARS" is not actionable; "no shall +// clause" is. +func TestErrorsNameWhatIsMissing(t *testing.T) { + cases := map[string]string{ + "The system does nothing": "shall", + "The shall run": "system name", + "The validator shall": "follows `shall`", + "When a task lands the validator shall run": "not closed by a comma", + "If the manifest is unreadable, the CLI shall exit 1": "then", + "When a task lands, when another lands, the CLI shall exit": "at most one trigger", + "When a task lands, while gated, the CLI shall exit": "clause order", + "Once a task lands, the CLI shall exit": "not an EARS keyword", + "": "empty", + } + for text, want := range cases { + _, err := Parse(text) + if err == nil { + t.Errorf("%q parsed without error, want one mentioning %q", text, want) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("%q: error = %q, want it to mention %q", text, err, want) + } + } +} + +// A word that merely starts with a keyword is not that keyword. Without the trailing +// space, a requirement about iframe rendering would parse as an `If`. +func TestKeywordsRequireAWordBoundary(t *testing.T) { + req, err := Parse("The iframe renderer shall sandbox every frame") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if req.Pattern != Ubiquitous { + t.Errorf("pattern = %q, want %q", req.Pattern, Ubiquitous) + } + if req.System != "iframe renderer" { + t.Errorf("system = %q", req.System) + } +} + +// A clause carrying an internal comma still parses as a well-formed requirement. The +// clause text may be split in a place the author did not intend, which is why nothing +// reports on clause text — only on whether the requirement is well formed. +func TestInternalCommaDoesNotBreakWellFormedness(t *testing.T) { + req, err := Parse("When the cart is empty, or has one item, the checkout shall skip the summary") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if req.System != "checkout" || req.Response == "" { + t.Errorf("req = %+v, want the system and response intact", req) + } +} diff --git a/internal/finding/finding.go b/internal/finding/finding.go new file mode 100644 index 0000000..5708e2e --- /dev/null +++ b/internal/finding/finding.go @@ -0,0 +1,179 @@ +// Package finding is the one finding type and the one output shape shared by +// every scc validator. +// +// It exists so eight validators cannot drift apart. A user who has learned to +// read one validator's output has learned to read all of them, a CI job that +// filters on one rule slug filters on any of them, and the JSON shape is frozen +// in a single place instead of re-invented per command. +// +// The discipline the package is built for: a finding the user cannot act on is +// noise wearing a useful shape, and one false positive teaches them to disbelieve +// all eight validators at once. So a finding always names a file, usually names a +// line, and always carries a stable rule slug — everything a reader needs to go +// fix it or to suppress it deliberately. +package finding + +import ( + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/protonspy/spec-claude-code/internal/render" +) + +// Exit codes, duplicated from the CLI's contract because the dependency runs the +// other way (cli imports finding, never the reverse). A test in package cli +// asserts these still equal cli.ExitOK / cli.ExitFindings, so the duplication +// cannot drift silently. +// +// A finding is a legitimate answer to a lint question, not a failure of the tool. +// That is why it has its own code: CI and agents branch on "ran and found +// problems" separately from "could not run". +const ( + ExitOK = 0 + ExitFindings = 2 +) + +// Finding is one structural problem in one artifact. +// +// File is slash-separated and relative to the workspace root, so output is +// identical on every platform and a rule slug plus a path is a stable identity +// for a finding across machines. +// +// Rule is a stable slug — "ears.missing-shall", "skill.name-mismatch" — so a CI +// job can filter without matching prose. Renaming one is a breaking change to +// that contract; rewording Message is not. +type Finding struct { + File string `json:"file"` + Line int `json:"line,omitempty"` // 1-based; omitted when the finding is about the file as a whole + Rule string `json:"rule"` + Message string `json:"message"` +} + +// Set accumulates findings and hands them back in a stable order. +type Set struct { + findings []Finding +} + +// Document is the frozen JSON shape every validator emits under --json. Count is +// redundant with len(findings) and included anyway: it is what a shell one-liner +// reads, and it survives a caller that never looks at the array. +type Document struct { + Findings []Finding `json:"findings"` + Count int `json:"count"` +} + +// Add records a finding. +func (s *Set) Add(f Finding) { s.findings = append(s.findings, f) } + +// Addf records a finding with a formatted message — the form nearly every +// validator uses. +func (s *Set) Addf(file string, line int, rule, format string, args ...any) { + s.Add(Finding{File: Rel(file), Line: line, Rule: rule, Message: fmt.Sprintf(format, args...)}) +} + +// Extend merges another set's findings into this one, which is how `scc validate` +// collects eight validators into one exit code and one document. +func (s *Set) Extend(other *Set) { + if other == nil { + return + } + s.findings = append(s.findings, other.findings...) +} + +// Len is the number of findings. +func (s *Set) Len() int { return len(s.findings) } + +// Empty reports whether the run found nothing. +func (s *Set) Empty() bool { return len(s.findings) == 0 } + +// Sorted returns the findings ordered by (file, line, rule, message), which is +// the order both output paths use. Sorting on the way out rather than on insert +// lets a validator emit findings in whatever order it walks the artifact. +func (s *Set) Sorted() []Finding { + out := make([]Finding, len(s.findings)) + copy(out, s.findings) + sort.SliceStable(out, func(i, j int) bool { + a, b := out[i], out[j] + switch { + case a.File != b.File: + return a.File < b.File + case a.Line != b.Line: + return a.Line < b.Line + case a.Rule != b.Rule: + return a.Rule < b.Rule + default: + return a.Message < b.Message + } + }) + return out +} + +// Document returns the frozen JSON shape. Findings is always an array, never +// null, so a caller can index it without a nil check. +func (s *Set) Document() Document { + f := s.Sorted() + if f == nil { + f = []Finding{} + } + return Document{Findings: f, Count: len(f)} +} + +// ExitCode is the process exit code this set implies: 0 when nothing was found, +// 2 when something was. +func (s *Set) ExitCode() int { + if s.Empty() { + return ExitOK + } + return ExitFindings +} + +// Report prints the findings for a human, grouped by file and led with a count. +// +// Grouping is not cosmetic. The documented way a catalogue of validators destroys +// its own value is volume — 35–91% of warnings go unread in studied static-analysis +// deployments — and a flat list of forty lines is the shape that gets skipped. +// A count first, then one block per file, keeps "few findings, each fixable" true +// of the output even when the input is not. +// +// Findings themselves go to stderr, following package render's split: they are +// diagnostics, and a caller running with --json must be able to pipe stdout into +// jq while still reading them. +func (s *Set) Report(subject string) { + if s.Empty() { + render.OK(subject + ": no findings") + return + } + sorted := s.Sorted() + render.Err(fmt.Sprintf("%s: %s", subject, plural(len(sorted)))) + var file string + for _, f := range sorted { + if f.File != file { + file = f.File + render.Warn(render.Bold(file)) + } + render.Detail(" " + location(f) + " " + f.Message + " " + render.Cyan("["+f.Rule+"]")) + } +} + +func location(f Finding) string { + if f.Line <= 0 { + return "-" + } + return strconv.Itoa(f.Line) +} + +func plural(n int) string { + if n == 1 { + return "1 finding" + } + return strconv.Itoa(n) + " findings" +} + +// Rel normalizes a path for a finding: slash-separated, so the same artifact +// reports the same location on Windows and on Linux. +func Rel(p string) string { + return strings.ReplaceAll(filepath.ToSlash(p), `\`, "/") +} diff --git a/internal/finding/finding_test.go b/internal/finding/finding_test.go new file mode 100644 index 0000000..bf671dd --- /dev/null +++ b/internal/finding/finding_test.go @@ -0,0 +1,195 @@ +package finding + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// captureStderr swaps os.Stderr for a pipe around f. Package render writes to the +// real file, so this is the only way to assert on report output. +func captureStderr(t *testing.T, f func()) string { + t.Helper() + orig := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stderr = w + defer func() { os.Stderr = orig }() + done := make(chan string, 1) + go func() { b, _ := io.ReadAll(r); done <- string(b) }() + f() + _ = w.Close() + return <-done +} + +// A flat list of forty lines is the shape that gets skipped, and alert fatigue does +// not discriminate between the noise and the real findings. So the report leads with +// a count and groups by file. +func TestReportGroupsByFileAndLeadsWithACount(t *testing.T) { + var s Set + s.Addf("b.md", 4, "wiki.broken-link", "[[nowhere]] does not resolve") + s.Addf("a.md", 2, "ears.missing-shall", "no `shall` clause") + s.Addf("a.md", 9, "spec.orphan-requirement", "R1.9 reaches no task") + + out := captureStderr(t, func() { s.Report("spec") }) + if !strings.Contains(out, "3 findings") { + t.Errorf("report does not lead with a count:\n%s", out) + } + // Each file heading appears once, and a.md sorts before b.md. + if strings.Count(out, "a.md") != 1 || strings.Count(out, "b.md") != 1 { + t.Errorf("files are not grouped:\n%s", out) + } + if strings.Index(out, "a.md") > strings.Index(out, "b.md") { + t.Errorf("files are not sorted:\n%s", out) + } + // A CI job filters on the rule slug, so it has to be in the human output too. + if !strings.Contains(out, "ears.missing-shall") { + t.Errorf("rule slug missing from the report:\n%s", out) + } +} + +func TestReportSaysNothingWhenThereIsNothing(t *testing.T) { + var s Set + out := captureStderr(t, func() { s.Report("spec") }) + if strings.Contains(out, "finding") { + t.Errorf("an empty set reported findings on stderr: %q", out) + } +} + +// A file-level finding has no line to print, and printing "0" would read as one. +func TestReportRendersAFileLevelFinding(t *testing.T) { + var s Set + s.Addf("docs/wiki/orphan.md", 0, "wiki.orphan-page", "not reachable from the index") + out := captureStderr(t, func() { s.Report("wiki") }) + if strings.Contains(out, " 0 ") { + t.Errorf("line 0 was printed as a location:\n%s", out) + } + if !strings.Contains(out, "not reachable") { + t.Errorf("the message is missing:\n%s", out) + } +} + +// A validator emits findings in whatever order it walks the artifact; the output +// order is fixed here so two runs over the same workspace produce the same report. +func TestSortedIsStableAndTotal(t *testing.T) { + var s Set + s.Add(Finding{File: "b.md", Line: 2, Rule: "z.rule", Message: "second"}) + s.Add(Finding{File: "a.md", Line: 10, Rule: "a.rule", Message: "tenth"}) + s.Add(Finding{File: "a.md", Line: 2, Rule: "b.rule", Message: "b at 2"}) + s.Add(Finding{File: "a.md", Line: 2, Rule: "a.rule", Message: "a at 2"}) + s.Add(Finding{File: "a.md", Rule: "a.rule", Message: "whole file"}) + + var got []string + for _, f := range s.Sorted() { + got = append(got, f.Message) + } + want := []string{"whole file", "a at 2", "b at 2", "tenth", "second"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Errorf("order = %v, want %v", got, want) + } +} + +// Line 10 must not sort before line 2. Sorting the numbers as strings is the +// classic way this goes wrong. +func TestSortedComparesLinesNumerically(t *testing.T) { + var s Set + s.Add(Finding{File: "a.md", Line: 10, Rule: "r", Message: "ten"}) + s.Add(Finding{File: "a.md", Line: 9, Rule: "r", Message: "nine"}) + if got := s.Sorted()[0].Message; got != "nine" { + t.Errorf("first = %q, want %q", got, "nine") + } +} + +// The JSON shape is a contract with CI jobs and agents. Both keys, and findings +// as an array even when empty so a caller can index it without a nil check. +func TestDocumentShapeIsFrozen(t *testing.T) { + var empty Set + b, err := json.Marshal(empty.Document()) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if got := string(b); got != `{"findings":[],"count":0}` { + t.Errorf("empty document = %s, want {\"findings\":[],\"count\":0}", got) + } + + var s Set + s.Addf("a.md", 3, "ears.missing-shall", "requirement %s has no `shall`", "R1.2") + b, err = json.Marshal(s.Document()) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var doc struct { + Findings []map[string]any `json:"findings"` + Count int `json:"count"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if doc.Count != 1 || len(doc.Findings) != 1 { + t.Fatalf("document = %s, want one finding and count 1", b) + } + for _, key := range []string{"file", "line", "rule", "message"} { + if _, ok := doc.Findings[0][key]; !ok { + t.Errorf("finding is missing key %q: %s", key, b) + } + } + if doc.Findings[0]["message"] != "requirement R1.2 has no `shall`" { + t.Errorf("Addf did not format the message: %s", b) + } +} + +// A file-level finding carries no line, and the key is omitted rather than +// reported as line 0 — which would read as a real location. +func TestLineIsOmittedWhenAbsent(t *testing.T) { + var s Set + s.Addf("a.md", 0, "wiki.orphan-page", "not reachable from the index") + b, _ := json.Marshal(s.Document()) + if strings.Contains(string(b), `"line"`) { + t.Errorf("line 0 was serialized: %s", b) + } +} + +// The exit-code contract: a finding is a legitimate answer, not a tool failure. +func TestExitCode(t *testing.T) { + var s Set + if got := s.ExitCode(); got != ExitOK { + t.Errorf("empty set exit = %d, want %d", got, ExitOK) + } + s.Addf("a.md", 1, "r", "x") + if got := s.ExitCode(); got != ExitFindings { + t.Errorf("non-empty set exit = %d, want %d", got, ExitFindings) + } + if ExitOK != 0 || ExitFindings != 2 { + t.Errorf("exit codes drifted: ok=%d findings=%d", ExitOK, ExitFindings) + } +} + +// `scc validate` is eight validators reporting through one exit code and one +// document, so merging has to be a first-class operation. +func TestExtend(t *testing.T) { + var a, b Set + a.Addf("a.md", 1, "r1", "one") + b.Addf("b.md", 1, "r2", "two") + a.Extend(&b) + a.Extend(nil) // a validator with nothing to say may return nil + if a.Len() != 2 { + t.Errorf("Len = %d, want 2", a.Len()) + } +} + +// The same artifact must report the same location on Windows and Linux, or a +// finding's identity depends on who ran the validator. +func TestRelIsSlashSeparated(t *testing.T) { + got := Rel(filepath.Join("specs", "user-auth", "tasks.md")) + if got != "specs/user-auth/tasks.md" { + t.Errorf("Rel = %q, want slash-separated", got) + } + if Rel(`specs\user-auth\tasks.md`) != "specs/user-auth/tasks.md" { + t.Errorf("Rel did not normalize backslashes: %q", Rel(`specs\user-auth\tasks.md`)) + } +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..4f53b5c --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,317 @@ +// Package manifest reads and writes .claude/scc-manifest.json — scc's only file +// and, by existing at all, the workspace marker. +// +// It records one entry per scc-managed file: a content hash and the template +// version that produced it. The two answer different questions and an upgrade +// needs both. The hash answers "did the user edit this?", which is what keeps an +// upgrade from overwriting authored work. The version answers "what did this look +// like before?", which is the base revision of the three-way merge that brings a +// workspace onto new templates without handing the user a merge as homework. +// +// Everything here is deterministic on purpose. The same workspace on two machines +// must produce a byte-identical manifest — entries sorted by path, LF endings, a +// trailing newline, and slash-separated relative paths — or every upgrade opens +// with a spurious diff and the file stops being reviewable. +package manifest + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/textutil" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +// Status is what a managed file on disk turned out to be. +type Status string + +const ( + // Pristine means the file's content still hashes to what the recorded + // template version rendered — scc may replace it outright. + Pristine Status = "pristine" + // Edited means the user changed it. An upgrade merges rather than writes. + Edited Status = "edited" + // Missing means the file is gone. An upgrade restores it; nothing is lost. + Missing Status = "missing" +) + +// Entry is one scc-managed file. +// +// Path is slash-separated and relative to the workspace root — never a +// filepath.Join result. A manifest written on Windows is read on Linux (a +// committed workspace is a shared workspace), and this is the one place in scc +// where the on-disk layout crosses machines, so the separator is fixed here +// rather than left to the host. +type Entry struct { + Path string // slash-separated, relative to the workspace root + Hash string // SHA-256 (lowercase hex) of the pristine render, LF-normalized + Version string // the template-set version that rendered it + + // extra carries keys a newer scc wrote that this build does not understand, + // so an older binary reading a newer manifest does not silently delete them. + extra map[string]json.RawMessage +} + +// Manifest is the file's whole content: a version stamp for diagnostics and the +// managed-file entries. +type Manifest struct { + // SCC is the scc version that last wrote this file. It is a diagnostic — no + // code branches on it — but it is the first thing worth knowing when a + // workspace behaves like a different version than the user expects. + SCC string + + // Files are the managed entries, kept sorted by Path by every operation that + // mutates them. + Files []Entry + + extra map[string]json.RawMessage +} + +// Field names in the JSON document. Changing one is a format change: an older +// binary would read the new key as unknown and preserve it while treating the +// entry as absent, which is the failure this constant exists to make visible. +const ( + keySCC = "scc" + keyFiles = "files" + keyPath = "path" + keyHash = "hash" + keyVersion = "version" +) + +// Hash is the content hash recorded for a managed file: SHA-256 over the +// LF-normalized text, lowercase hex. +// +// Normalizing first is what makes the hash portable. The same template checked +// out with CRLF on Windows must hash identically to the LF copy on Linux, or +// every managed file reads as edited on one of the two platforms. +func Hash(content string) string { + sum := sha256.Sum256([]byte(textutil.NormalizeNewlines(content))) + return hex.EncodeToString(sum[:]) +} + +// HashFile returns the hash of the file at path, or Missing-friendly errors: +// os.IsNotExist(err) is true when the file is absent. +func HashFile(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + return Hash(string(b)), nil +} + +// New returns an empty manifest stamped with the given scc version. +func New(sccVersion string) *Manifest { + return &Manifest{SCC: sccVersion} +} + +// Load reads the manifest at root. A missing file is not an error: it means the +// directory is not a workspace yet, and the caller gets an empty manifest plus +// found=false so it can tell "no workspace" from "empty workspace". +func Load(root string) (m *Manifest, found bool, err error) { + b, err := os.ReadFile(paths.Manifest(root)) + if errors.Is(err, fs.ErrNotExist) { + return &Manifest{}, false, nil + } + if err != nil { + return nil, false, err + } + m = &Manifest{} + if err := json.Unmarshal([]byte(textutil.NormalizeNewlines(string(b))), m); err != nil { + return nil, true, fmt.Errorf("%s is not valid scc manifest JSON: %w", paths.Manifest(root), err) + } + m.sortFiles() + return m, true, nil +} + +// Save writes the manifest under root atomically, so a concurrent reader — or a +// crash — never observes a half-written marker. +func Save(root string, m *Manifest) error { + b, err := m.Bytes() + if err != nil { + return err + } + return workspace.AtomicWrite(paths.Manifest(root), b, 0o644) +} + +// Bytes serializes the manifest exactly as Save would write it: sorted, indented +// with two spaces, LF, one trailing newline. Exposed separately so a caller can +// compare against what is already on disk and skip an identical write — which is +// what makes a second `scc init` a true no-op instead of a rewrite with the same +// content. +func (m *Manifest) Bytes() ([]byte, error) { + m.sortFiles() + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, err + } + // MarshalIndent already emits LF; normalize anyway so the guarantee is stated + // in one place rather than inherited from the encoder's current behavior. + return []byte(textutil.NormalizeNewlines(string(b)) + "\n"), nil +} + +// Get returns the entry for a slash-separated relative path. +func (m *Manifest) Get(rel string) (Entry, bool) { + for _, e := range m.Files { + if e.Path == rel { + return e, true + } + } + return Entry{}, false +} + +// Set records or replaces the entry for rel, preserving any unknown fields a +// newer scc had written for that same entry. +func (m *Manifest) Set(rel, hash, version string) { + rel = path.Clean(strings.ReplaceAll(rel, `\`, "/")) + for i, e := range m.Files { + if e.Path == rel { + m.Files[i].Hash = hash + m.Files[i].Version = version + return + } + } + m.Files = append(m.Files, Entry{Path: rel, Hash: hash, Version: version}) + m.sortFiles() +} + +// Remove drops the entry for rel, reporting whether it was there. +func (m *Manifest) Remove(rel string) bool { + for i, e := range m.Files { + if e.Path == rel { + m.Files = append(m.Files[:i], m.Files[i+1:]...) + return true + } + } + return false +} + +// Status reports what the managed file recorded by e currently is on disk under +// root: pristine, edited by the user, or missing. +// +// A read error other than "not exist" is returned rather than guessed at. Calling +// an unreadable file either name would be wrong, and §5's rule applies to the +// upgrade path too: a wrong answer here overwrites authored work. +func (m *Manifest) Status(root string, e Entry) (Status, error) { + got, err := HashFile(filepath.Join(root, filepath.FromSlash(e.Path))) + if errors.Is(err, fs.ErrNotExist) { + return Missing, nil + } + if err != nil { + return "", err + } + if got == e.Hash { + return Pristine, nil + } + return Edited, nil +} + +func (m *Manifest) sortFiles() { + sort.Slice(m.Files, func(i, j int) bool { return m.Files[i].Path < m.Files[j].Path }) +} + +// MarshalJSON writes the manifest as an object built from a map, which +// encoding/json emits with sorted keys — so known and unknown fields alike land +// in one deterministic order without this code maintaining one. +func (m Manifest) MarshalJSON() ([]byte, error) { + out := cloneRaw(m.extra) + if err := putJSON(out, keySCC, m.SCC); err != nil { + return nil, err + } + files := m.Files + if files == nil { + files = []Entry{} // an empty manifest serializes as [], never null + } + if err := putJSON(out, keyFiles, files); err != nil { + return nil, err + } + return json.Marshal(out) +} + +// UnmarshalJSON keeps every key it does not recognize. An older binary that +// dropped them would silently discard whatever a newer scc recorded — and the +// manifest is the only state scc has, so a dropped field is lost state. +func (m *Manifest) UnmarshalJSON(b []byte) error { + raw := map[string]json.RawMessage{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if v, ok := raw[keySCC]; ok { + if err := json.Unmarshal(v, &m.SCC); err != nil { + return fmt.Errorf("field %q: %w", keySCC, err) + } + delete(raw, keySCC) + } + if v, ok := raw[keyFiles]; ok { + if err := json.Unmarshal(v, &m.Files); err != nil { + return fmt.Errorf("field %q: %w", keyFiles, err) + } + delete(raw, keyFiles) + } + m.extra = orNil(raw) + return nil +} + +// MarshalJSON mirrors Manifest.MarshalJSON: map-built, so key order is sorted and +// therefore identical on every machine. +func (e Entry) MarshalJSON() ([]byte, error) { + out := cloneRaw(e.extra) + for k, v := range map[string]string{keyPath: e.Path, keyHash: e.Hash, keyVersion: e.Version} { + if err := putJSON(out, k, v); err != nil { + return nil, err + } + } + return json.Marshal(out) +} + +// UnmarshalJSON preserves unknown per-entry fields, for the same reason +// Manifest.UnmarshalJSON does. +func (e *Entry) UnmarshalJSON(b []byte) error { + raw := map[string]json.RawMessage{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + for key, dst := range map[string]*string{keyPath: &e.Path, keyHash: &e.Hash, keyVersion: &e.Version} { + if v, ok := raw[key]; ok { + if err := json.Unmarshal(v, dst); err != nil { + return fmt.Errorf("field %q: %w", key, err) + } + delete(raw, key) + } + } + e.extra = orNil(raw) + return nil +} + +func cloneRaw(in map[string]json.RawMessage) map[string]json.RawMessage { + out := make(map[string]json.RawMessage, len(in)+3) + for k, v := range in { + out[k] = v + } + return out +} + +func putJSON(dst map[string]json.RawMessage, key string, v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + dst[key] = b + return nil +} + +func orNil(raw map[string]json.RawMessage) map[string]json.RawMessage { + if len(raw) == 0 { + return nil + } + return raw +} diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go new file mode 100644 index 0000000..e537764 --- /dev/null +++ b/internal/manifest/manifest_test.go @@ -0,0 +1,258 @@ +package manifest + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +// The hash is taken over LF-normalized text, so the same template checked out +// with CRLF on Windows hashes identically to the LF copy on Linux. Without this, +// every managed file reads as edited on one of the two platforms. +func TestHashIgnoresLineEndingsAndBOM(t *testing.T) { + lf := "line one\nline two\n" + crlf := "line one\r\nline two\r\n" + // Built from the code point so the byte sequence never appears literally in + // this source file — gofmt rejects a BOM outside position zero. + bom := string(rune(0xFEFF)) + lf + if Hash(lf) != Hash(crlf) { + t.Errorf("CRLF hashes differently from LF: %s vs %s", Hash(crlf), Hash(lf)) + } + if Hash(lf) != Hash(bom) { + t.Errorf("BOM changes the hash: %s vs %s", Hash(bom), Hash(lf)) + } +} + +func TestHashIsStableAndDistinguishing(t *testing.T) { + if Hash("a") != Hash("a") { + t.Error("Hash is not deterministic") + } + if Hash("a") == Hash("b") { + t.Error("different content hashed identically") + } + if len(Hash("a")) != 64 { + t.Errorf("hash length = %d, want 64 hex chars", len(Hash("a"))) + } +} + +// Byte-identical output on every machine is what keeps an upgrade's diff limited +// to what actually changed. Insertion order must not survive into the file. +func TestBytesAreDeterministicAndSorted(t *testing.T) { + a := New("v1.0.0") + a.Set("specs/z.md", Hash("z"), "v1") + a.Set(".claude/rules/routing.md", Hash("r"), "v1") + + b := New("v1.0.0") + b.Set(".claude/rules/routing.md", Hash("r"), "v1") + b.Set("specs/z.md", Hash("z"), "v1") + + ab, err := a.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + bb, err := b.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + if string(ab) != string(bb) { + t.Errorf("insertion order leaked into the output:\n%s\n---\n%s", ab, bb) + } + if i, j := strings.Index(string(ab), "routing.md"), strings.Index(string(ab), "specs/z.md"); i > j { + t.Errorf("entries not sorted by path:\n%s", ab) + } + if !strings.HasSuffix(string(ab), "}\n") { + t.Errorf("output does not end in exactly one LF-terminated brace: %q", string(ab)) + } + if strings.Contains(string(ab), "\r") { + t.Error("output contains CR") + } +} + +// A manifest written on Windows is read on Linux — a committed workspace is a +// shared workspace — so the recorded separator cannot be the host's. +func TestPathsAreStoredSlashSeparated(t *testing.T) { + m := New("v1.0.0") + m.Set(filepath.Join(".claude", "rules", "routing.md"), Hash("r"), "v1") + b, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + if !strings.Contains(string(b), `".claude/rules/routing.md"`) { + t.Errorf("path not stored slash-separated:\n%s", b) + } + if strings.Contains(string(b), `\\`) { + t.Errorf("path stored with escaped backslashes:\n%s", b) + } +} + +func TestEmptyManifestSerializesFilesAsArray(t *testing.T) { + b, err := New("v1.0.0").Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + if !strings.Contains(string(b), `"files": []`) { + t.Errorf("empty manifest did not serialize files as []:\n%s", b) + } +} + +// The manifest is the only state scc has, so a field this build does not +// understand is state an older binary must not delete on the next write. +func TestUnknownFieldsSurviveARoundTrip(t *testing.T) { + in := `{ + "scc": "v9.9.9", + "future": {"nested": true}, + "files": [ + {"path": "a.md", "hash": "deadbeef", "version": "v9", "provenance": "generated"} + ] +}` + var m Manifest + if err := json.Unmarshal([]byte(in), &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + out, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + for _, want := range []string{`"future"`, `"nested"`, `"provenance"`, `"generated"`} { + if !strings.Contains(string(out), want) { + t.Errorf("unknown field %s dropped:\n%s", want, out) + } + } +} + +func TestGetSetRemove(t *testing.T) { + m := New("v1.0.0") + if _, ok := m.Get("a.md"); ok { + t.Error("Get on an empty manifest reported a hit") + } + m.Set("a.md", "h1", "v1") + m.Set("a.md", "h2", "v2") // replaces, never duplicates + if len(m.Files) != 1 { + t.Fatalf("Set duplicated an entry: %+v", m.Files) + } + e, ok := m.Get("a.md") + if !ok || e.Hash != "h2" || e.Version != "v2" { + t.Errorf("Get = (%+v, %v), want the replaced entry", e, ok) + } + if !m.Remove("a.md") || len(m.Files) != 0 { + t.Errorf("Remove left %d entries", len(m.Files)) + } + if m.Remove("a.md") { + t.Error("Remove of an absent path reported a removal") + } +} + +// Status is the check that stands between an upgrade and the user's authored +// work, so all three answers are load-bearing. +func TestStatusDistinguishesPristineEditedMissing(t *testing.T) { + root := t.TempDir() + pristine := "the shipped template\n" + rel := ".claude/rules/routing.md" + m := New("v1.0.0") + m.Set(rel, Hash(pristine), "v1.0.0") + e, _ := m.Get(rel) + + if got, err := m.Status(root, e); err != nil || got != Missing { + t.Errorf("Status before the write = (%v, %v), want missing", got, err) + } + + target := filepath.Join(root, filepath.FromSlash(rel)) + if err := workspace.AtomicWrite(target, []byte(pristine), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + if got, err := m.Status(root, e); err != nil || got != Pristine { + t.Errorf("Status of the shipped content = (%v, %v), want pristine", got, err) + } + + // A CRLF checkout of an untouched file is still untouched. + if err := workspace.AtomicWrite(target, []byte("the shipped template\r\n"), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + if got, err := m.Status(root, e); err != nil || got != Pristine { + t.Errorf("Status of a CRLF checkout = (%v, %v), want pristine", got, err) + } + + if err := workspace.AtomicWrite(target, []byte(pristine+"my own note\n"), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + if got, err := m.Status(root, e); err != nil || got != Edited { + t.Errorf("Status of an edited file = (%v, %v), want edited", got, err) + } +} + +// A missing manifest is "not a workspace", which is a different answer from "an +// empty workspace" — and Find/IsWorkspace depend on the distinction. +func TestLoadOnADirectoryWithoutAManifest(t *testing.T) { + m, found, err := Load(t.TempDir()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if found { + t.Error("found = true for a directory with no manifest") + } + if len(m.Files) != 0 { + t.Errorf("entries = %d, want 0", len(m.Files)) + } +} + +func TestSaveLoadRoundTrip(t *testing.T) { + root := t.TempDir() + m := New("v1.2.3") + m.Set(".claude/rules/routing.md", Hash("r"), "v1.2.3") + m.Set(".claude/CLAUDE.md", Hash("c"), "v1.0.0") + if err := Save(root, m); err != nil { + t.Fatalf("Save: %v", err) + } + if !workspace.IsWorkspace(root) { + t.Error("Save did not create the workspace marker") + } + got, found, err := Load(root) + if err != nil || !found { + t.Fatalf("Load = (%v, %v), want found", found, err) + } + if got.SCC != "v1.2.3" || len(got.Files) != 2 { + t.Fatalf("round trip lost data: %+v", got) + } + e, ok := got.Get(".claude/rules/routing.md") + if !ok || e.Hash != Hash("r") || e.Version != "v1.2.3" { + t.Errorf("entry = (%+v, %v), want the saved entry", e, ok) + } +} + +// Save writes what Bytes reports, so a caller can compare against the file on +// disk and skip an identical write — which is what makes a second `scc init` a +// no-op rather than a rewrite. +func TestSaveWritesExactlyBytes(t *testing.T) { + root := t.TempDir() + m := New("v1.0.0") + m.Set("a.md", Hash("a"), "v1.0.0") + want, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + if err := Save(root, m); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := os.ReadFile(paths.Manifest(root)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != string(want) { + t.Errorf("file = %q, Bytes = %q", got, want) + } +} + +func TestLoadRejectsInvalidJSON(t *testing.T) { + root := t.TempDir() + if err := workspace.AtomicWrite(paths.Manifest(root), []byte("{not json"), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + if _, found, err := Load(root); err == nil { + t.Errorf("Load = (found %v, nil error), want an error naming the file", found) + } +} diff --git a/internal/mdscan/frontmatter.go b/internal/mdscan/frontmatter.go new file mode 100644 index 0000000..a008598 --- /dev/null +++ b/internal/mdscan/frontmatter.go @@ -0,0 +1,201 @@ +// Package mdscan reads the Markdown artifacts scc governs. Exactly one package +// knows how to parse them, so eight validators cannot disagree about what a task +// line or a heading is — and so the fence-awareness that prevents most false +// positives is written once. +// +// Nothing here parses source code. That boundary is deliberate and permanent: a +// per-language parser would make scc own every ecosystem it touches, and a checker +// that is confidently incomplete is worse than one that knows it is judgment. +package mdscan + +import ( + "fmt" + "strings" + + "github.com/protonspy/spec-claude-code/internal/textutil" +) + +// Frontmatter is the delimited block at the very top of an artifact — the one part +// of a Markdown file that is already machine-readable, which is why the kickoff +// answers (autonomy, ci) are recorded there. +type Frontmatter struct { + // Present is true when the file opened with a --- delimited block. + Present bool + // Lines is how many lines the block occupies, delimiters included, so a caller + // can report line numbers in the body without counting them again. + Lines int + // Values holds scalar keys in the order-insensitive form every consumer wants. + Values map[string]string + // Lists holds inline lists ([a, b, c]). + Lists map[string][]string + // Maps holds one level of nested scalars. Exactly one field scc reads needs + // this — the Agent Skills spec's optional `metadata` mapping — and supporting + // it here is cheaper than reporting a finding on a skill that is perfectly + // valid. Nothing deeper is accepted. + Maps map[string]map[string]string +} + +// Get returns a scalar value and whether the key was present. +func (f Frontmatter) Get(key string) (string, bool) { + v, ok := f.Values[key] + return v, ok +} + +const delimiter = "---" + +// ParseFrontmatter reads the leading frontmatter block, if any. +// +// It is deliberately not a YAML parser: flat `key: value` pairs, optionally quoted +// strings, simple inline lists, and one level of nested scalars. Anything else — +// deeper nesting, block sequences, multi-line scalars, anchors — is an error rather +// than a guess. That is the right trade for this tool. A partial YAML parser that +// silently mis-reads a nested key produces a finding about a file that was fine, +// and one false positive costs more than a miss; carrying a real YAML dependency to +// read a handful of keys costs a supply-chain surface on six platforms. +// +// One level of nesting is in rather than out because the Agent Skills spec — an +// external contract scc conforms to rather than defines — has an optional +// `metadata` mapping. Refusing it would mean reporting a finding on a skill that is +// valid by the standard the validator exists to enforce. +// +// A file with no leading delimiter is not an error: frontmatter is optional +// everywhere scc reads it, and a missing block means "not recorded", never "wrong". +func ParseFrontmatter(content string) (Frontmatter, error) { + fm := Frontmatter{ + Values: map[string]string{}, + Lists: map[string][]string{}, + Maps: map[string]map[string]string{}, + } + text := textutil.NormalizeNewlines(content) + lines := strings.Split(text, "\n") + if len(lines) == 0 || strings.TrimRight(lines[0], " \t") != delimiter { + return fm, nil + } + fm.Present = true + + // pending is a key that arrived with no value: either an empty scalar or the + // head of a nested mapping, and which one it is only becomes clear on the next + // line. Its resolution is deferred rather than guessed. + pending, pendingIndent := "", 0 + + for i := 1; i < len(lines); i++ { + line := strings.TrimRight(lines[i], " \t") + if line == delimiter { + resolvePending(&fm, pending) + fm.Lines = i + 1 + return fm, nil + } + if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + + if indented := line != strings.TrimLeft(line, " \t"); indented { + if pending == "" { + return fm, fmt.Errorf("frontmatter line %d is indented under a key that already has a value", i+1) + } + width := len(line) - len(strings.TrimLeft(line, " \t")) + if pendingIndent == 0 { + pendingIndent = width + } else if width != pendingIndent { + return fm, fmt.Errorf("frontmatter line %d changes indentation inside %q; scc reads one level of nesting only", i+1, pending) + } + key, value, found := strings.Cut(strings.TrimSpace(line), ":") + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + if !found || key == "" || value == "" { + return fm, fmt.Errorf("frontmatter line %d is not a `key: value` pair inside %q; scc reads one level of nesting only", i+1, pending) + } + if fm.Maps[pending] == nil { + fm.Maps[pending] = map[string]string{} + } + if _, dup := fm.Maps[pending][key]; dup { + return fm, fmt.Errorf("frontmatter line %d repeats the key %q inside %q", i+1, key, pending) + } + fm.Maps[pending][key] = unquote(value) + continue + } + + resolvePending(&fm, pending) + pending, pendingIndent = "", 0 + + key, value, found := strings.Cut(line, ":") + if !found { + return fm, fmt.Errorf("frontmatter line %d is not `key: value`: %q", i+1, line) + } + key = strings.TrimSpace(key) + if key == "" { + return fm, fmt.Errorf("frontmatter line %d has an empty key", i+1) + } + if fm.has(key) { + return fm, fmt.Errorf("frontmatter line %d repeats the key %q", i+1, key) + } + value = strings.TrimSpace(value) + if value == "" { + pending = key + continue + } + if items, isList, err := parseInlineList(value); err != nil { + return fm, fmt.Errorf("frontmatter line %d: %w", i+1, err) + } else if isList { + fm.Lists[key] = items + continue + } + fm.Values[key] = unquote(value) + } + return fm, fmt.Errorf("frontmatter opened at line 1 and was never closed by a %q line", delimiter) +} + +// resolvePending records a valueless key as an empty scalar when no nested block +// followed it. `autonomy:` with nothing after it is a key the user left blank, which +// is different from a key that heads a mapping. +func resolvePending(fm *Frontmatter, pending string) { + if pending == "" { + return + } + if _, isMap := fm.Maps[pending]; isMap { + return + } + fm.Values[pending] = "" +} + +func (f Frontmatter) has(key string) bool { + if _, ok := f.Values[key]; ok { + return true + } + if _, ok := f.Lists[key]; ok { + return true + } + _, ok := f.Maps[key] + return ok +} + +// parseInlineList reads `[a, b, c]`. A bare `[` with no closing bracket is a block +// list or a typo, and either way scc will not guess at it. +func parseInlineList(value string) (items []string, isList bool, err error) { + if !strings.HasPrefix(value, "[") { + return nil, false, nil + } + if !strings.HasSuffix(value, "]") { + return nil, true, fmt.Errorf("unterminated inline list %q", value) + } + inner := strings.TrimSpace(value[1 : len(value)-1]) + if inner == "" { + return []string{}, true, nil + } + for _, part := range strings.Split(inner, ",") { + item := unquote(strings.TrimSpace(part)) + if item == "" { + return nil, true, fmt.Errorf("empty item in list %q", value) + } + items = append(items, item) + } + return items, true, nil +} + +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/internal/mdscan/frontmatter_test.go b/internal/mdscan/frontmatter_test.go new file mode 100644 index 0000000..1527484 --- /dev/null +++ b/internal/mdscan/frontmatter_test.go @@ -0,0 +1,177 @@ +package mdscan + +import "testing" + +func TestParseFrontmatterReadsTheKickoffAnswers(t *testing.T) { + fm, err := ParseFrontmatter("---\nautonomy: auto\nci: no-wait\n---\n\n# Heading\n") + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if !fm.Present { + t.Fatal("Present = false for a file that opens with a block") + } + if got, _ := fm.Get("autonomy"); got != "auto" { + t.Errorf("autonomy = %q, want auto", got) + } + if got, _ := fm.Get("ci"); got != "no-wait" { + t.Errorf("ci = %q, want no-wait", got) + } + if fm.Lines != 4 { + t.Errorf("Lines = %d, want 4", fm.Lines) + } +} + +// Frontmatter is optional everywhere scc reads it: absent means "not recorded", +// never "wrong". A run that predates the convention is not a finding. +func TestNoFrontmatterIsNotAnError(t *testing.T) { + fm, err := ParseFrontmatter("# Heading\n\nprose\n") + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if fm.Present { + t.Error("Present = true for a file with no block") + } + if _, ok := fm.Get("autonomy"); ok { + t.Error("a key appeared out of nowhere") + } +} + +// A `---` that is not on the very first line is a horizontal rule, not frontmatter. +func TestDelimiterMustBeTheFirstLine(t *testing.T) { + fm, err := ParseFrontmatter("\n---\nautonomy: auto\n---\n") + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if fm.Present { + t.Error("a mid-file --- was read as frontmatter") + } +} + +func TestQuotedValuesAndComments(t *testing.T) { + fm, err := ParseFrontmatter("---\n# a comment\nname: \"user auth\"\nother: 'single'\n\n---\n") + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if got, _ := fm.Get("name"); got != "user auth" { + t.Errorf("name = %q, want unquoted", got) + } + if got, _ := fm.Get("other"); got != "single" { + t.Errorf("other = %q, want unquoted", got) + } +} + +func TestInlineLists(t *testing.T) { + fm, err := ParseFrontmatter("---\ntools: [Read, Grep, \"Bash\"]\nempty: []\n---\n") + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + got := fm.Lists["tools"] + if len(got) != 3 || got[0] != "Read" || got[2] != "Bash" { + t.Errorf("tools = %v, want [Read Grep Bash]", got) + } + if l, ok := fm.Lists["empty"]; !ok || len(l) != 0 { + t.Errorf("empty = (%v, %v), want an empty list", l, ok) + } +} + +// The parser refuses what it cannot read rather than guessing. A partial YAML +// parser that silently mis-reads a nested key produces a finding about a file that +// was fine — and one false positive costs more than a miss. +func TestParserRejectsWhatItCannotRead(t *testing.T) { + cases := map[string]string{ + "two levels of nesting": "---\nmetadata:\n owner:\n name: me\n---\n", + "block list": "---\ntools:\n - Read\n---\n", + "inconsistent indent": "---\nmetadata:\n a: 1\n b: 2\n---\n", + "indented under a scalar": "---\nname: x\n nested: y\n---\n", + "not a pair": "---\njust a sentence\n---\n", + "empty key": "---\n: value\n---\n", + "unterminated block": "---\nautonomy: auto\n", + "unterminated list": "---\ntools: [Read, Grep\n---\n", + "duplicate key": "---\nci: wait\nci: no-wait\n---\n", + "empty item in list": "---\ntools: [Read, , Grep]\n---\n", + "duplicate list key": "---\ntools: [Read]\ntools: [Grep]\n---\n", + "scalar after a list": "---\ntools: [Read]\ntools: x\n---\n", + } + for name, in := range cases { + if _, err := ParseFrontmatter(in); err == nil { + t.Errorf("%s: parsed without error, want a refusal", name) + } + } +} + +// One level of nesting is accepted because the Agent Skills spec's optional +// `metadata` field is a mapping, and refusing it would mean reporting a finding on a +// skill that is valid by the very standard the validator enforces. +func TestOneLevelOfNestingIsAccepted(t *testing.T) { + fm, err := ParseFrontmatter("---\nname: pdf-processing\nmetadata:\n author: example-org\n version: \"1.0\"\nlicense: Apache-2.0\n---\n") + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if got := fm.Maps["metadata"]["author"]; got != "example-org" { + t.Errorf("metadata.author = %q, want example-org", got) + } + if got := fm.Maps["metadata"]["version"]; got != "1.0" { + t.Errorf("metadata.version = %q, want 1.0 unquoted", got) + } + // The keys around the mapping still parse as scalars. + if got, _ := fm.Get("license"); got != "Apache-2.0" { + t.Errorf("license = %q, want Apache-2.0", got) + } + if _, isScalar := fm.Get("metadata"); isScalar { + t.Error("the mapping head was also recorded as an empty scalar") + } +} + +// A key left blank is a key the user did not fill in — different from a key that +// heads a mapping, and it must not be mistaken for one. +func TestValuelessKeyIsAnEmptyScalar(t *testing.T) { + fm, err := ParseFrontmatter("---\nautonomy:\nci: wait\n---\n") + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + v, ok := fm.Get("autonomy") + if !ok || v != "" { + t.Errorf("autonomy = (%q, %v), want an empty scalar", v, ok) + } + if len(fm.Maps) != 0 { + t.Errorf("a blank key became a mapping: %+v", fm.Maps) + } +} + +// A blank key as the last line before the delimiter still resolves. +func TestValuelessKeyAtTheEndOfTheBlock(t *testing.T) { + fm, err := ParseFrontmatter("---\nci: wait\nautonomy:\n---\n") + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if _, ok := fm.Get("autonomy"); !ok { + t.Error("a trailing blank key was dropped") + } +} + +// CRLF input behaves identically to LF: the working tree's line endings are not +// part of the contract. +func TestCRLFParsesIdentically(t *testing.T) { + lf, err := ParseFrontmatter("---\nautonomy: auto\n---\n") + if err != nil { + t.Fatalf("LF: %v", err) + } + crlf, err := ParseFrontmatter("---\r\nautonomy: auto\r\n---\r\n") + if err != nil { + t.Fatalf("CRLF: %v", err) + } + if lf.Lines != crlf.Lines || lf.Values["autonomy"] != crlf.Values["autonomy"] { + t.Errorf("CRLF parsed differently: %+v vs %+v", crlf, lf) + } +} + +// A BOM-prefixed file (Windows Notepad) still has its frontmatter read. +func TestBOMDoesNotHideTheBlock(t *testing.T) { + fm, err := ParseFrontmatter(string(rune(0xFEFF)) + "---\nci: wait\n---\n") + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if !fm.Present { + t.Error("a BOM hid the frontmatter block") + } +} diff --git a/internal/mdscan/mdscan.go b/internal/mdscan/mdscan.go new file mode 100644 index 0000000..6de9144 --- /dev/null +++ b/internal/mdscan/mdscan.go @@ -0,0 +1,294 @@ +package mdscan + +import ( + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/protonspy/spec-claude-code/internal/textutil" +) + +// Document is one parsed Markdown artifact. Line numbers throughout are 1-based, +// so a finding can carry one straight to the user's editor. +// +// This type is where false positives are prevented for every validator at once. A +// `- [ ]` inside a fenced block is documentation about task syntax, not a task. A +// `[[link]]` in a code span is an example, not a wikilink. And an example inside an +// HTML comment is not anything at all — which matters more here than in most +// Markdown tooling, because scc's own templates carry their instructions in HTML +// comments, examples included. A scanner that missed that would ship a tool whose +// first act is to report findings on the file it just generated. +type Document struct { + Path string + Lines []string + + // Body is Lines with everything that is not content blanked out: frontmatter, + // fenced blocks, and HTML comments. Indices line up with Lines, so a validator + // scanning for its own grammar gets fence- and comment-awareness for free + // instead of re-deriving it and getting it wrong. + Body []string + Frontmatter Frontmatter + Headings []Heading + Checkboxes []Checkbox + Links []Link + Wikilinks []Wikilink +} + +// Heading is an ATX heading (`## Title`). Setext headings (underlined with === or +// ---) are deliberately not recognized: the artifacts scc governs are generated from +// its own templates, none of which use them, and `---` is already the frontmatter +// delimiter. +type Heading struct { + Line int + Level int + Text string + Slug string // GitHub-compatible anchor, de-duplicated within the document +} + +// Checkbox is a task-list item. The grammar layered on top of it — the number, the +// methodology annotation, the requirement citations — belongs to the validators, +// because it is scc's grammar rather than Markdown's. +type Checkbox struct { + Line int + Indent int // leading spaces, tabs expanded to 4 + Checked bool // `- [x]` + Text string // everything after the box +} + +// Link is an inline link. Target may be empty: a codewiki citation is written +// `[internal/cli/cli.go:48-64]()`, which is a link with no destination on purpose. +type Link struct { + Line int + Text string + Target string +} + +// Wikilink is `[[target]]` or `[[target|label]]`. +type Wikilink struct { + Line int + Target string + Label string +} + +var ( + headingRe = regexp.MustCompile(`^ {0,3}(#{1,6})(\s+(.*?))?\s*$`) + checkboxRe = regexp.MustCompile(`^([ \t]*)[-*+] \[([ xX])\]\s*(.*)$`) + fenceRe = regexp.MustCompile("^[ \t]{0,3}(`{3,}|~{3,})(.*)$") + linkRe = regexp.MustCompile(`\[([^\[\]]*)\]\(([^()]*)\)`) + wikilinkRe = regexp.MustCompile(`\[\[([^\[\]]+)\]\]`) + trailingRe = regexp.MustCompile(`\s+#+\s*$`) // closing sequence of an ATX heading +) + +// Parse reads content as one of scc's Markdown artifacts. +// +// The only error it can return comes from the frontmatter parser refusing input it +// cannot read; the body is always scannable, because every construct it does not +// recognize is simply prose. +func Parse(path, content string) (*Document, error) { + text := textutil.NormalizeNewlines(content) + doc := &Document{Path: path, Lines: strings.Split(text, "\n")} + doc.Body = make([]string, len(doc.Lines)) + + fm, err := ParseFrontmatter(text) + doc.Frontmatter = fm + if err != nil { + return doc, err + } + + slugs := map[string]int{} + var fence string // the open fence's delimiter, "" when not in one + inComment := false + + for i := fm.Lines; i < len(doc.Lines); i++ { + line := doc.Lines[i] + num := i + 1 + + // Fences first: an HTML comment marker inside a code block is code, and a + // fence inside a comment is commented out. + if fence != "" { + if closesFence(line, fence) { + fence = "" + } + continue + } + if !inComment { + if open, delim := opensFence(line); open { + fence = delim + continue + } + } + + body, stillInComment := stripComments(line, inComment) + inComment = stillInComment + doc.Body[i] = body + if strings.TrimSpace(body) == "" { + continue + } + + if m := headingRe.FindStringSubmatch(body); m != nil { + text := strings.TrimSpace(trailingRe.ReplaceAllString(m[3], "")) + h := Heading{Line: num, Level: len(m[1]), Text: text, Slug: Slug(text)} + if n := slugs[h.Slug]; n > 0 { + h.Slug = h.Slug + "-" + strconv.Itoa(n) + } + slugs[Slug(text)]++ + doc.Headings = append(doc.Headings, h) + continue + } + + if m := checkboxRe.FindStringSubmatch(body); m != nil { + doc.Checkboxes = append(doc.Checkboxes, Checkbox{ + Line: num, + Indent: indentWidth(m[1]), + Checked: m[2] != " ", + Text: strings.TrimSpace(m[3]), + }) + } + + // Links and wikilinks are read from the line with code spans masked, so an + // example in backticks is not mistaken for a reference to something real. + masked := maskCodeSpans(body) + for _, m := range wikilinkRe.FindAllStringSubmatch(masked, -1) { + target, label, _ := strings.Cut(m[1], "|") + doc.Wikilinks = append(doc.Wikilinks, Wikilink{ + Line: num, + Target: strings.TrimSpace(target), + Label: strings.TrimSpace(label), + }) + } + for _, m := range linkRe.FindAllStringSubmatch(masked, -1) { + doc.Links = append(doc.Links, Link{Line: num, Text: m[1], Target: strings.TrimSpace(m[2])}) + } + } + return doc, nil +} + +// Content returns the 1-based line n with frontmatter, fences, and comments already +// stripped — the text a validator should apply its grammar to. +func (d *Document) Content(n int) string { + if n < 1 || n > len(d.Body) { + return "" + } + return d.Body[n-1] +} + +// Line returns the 1-based line n, or "" when n is out of range. Callers report +// line numbers they got from this package, so an out-of-range n is a bug rather +// than user input — returning "" keeps it from being a panic in a lint run. +func (d *Document) Line(n int) string { + if n < 1 || n > len(d.Lines) { + return "" + } + return d.Lines[n-1] +} + +func opensFence(line string) (bool, string) { + m := fenceRe.FindStringSubmatch(line) + if m == nil { + return false, "" + } + // A tilde fence's info string may contain anything; a backtick fence's may not + // contain a backtick, which is what keeps `` `code` `` from opening a block. + if m[1][0] == '`' && strings.Contains(m[2], "`") { + return false, "" + } + return true, m[1] +} + +// closesFence reports whether line closes an open fence. The closing run must be at +// least as long as the opening one and carry no info string — and it may be +// tab-indented, which a naive prefix check on spaces misses. +func closesFence(line, open string) bool { + m := fenceRe.FindStringSubmatch(line) + if m == nil { + return false + } + if m[1][0] != open[0] || len(m[1]) < len(open) { + return false + } + return strings.TrimSpace(m[2]) == "" +} + +// stripComments blanks out the HTML-comment spans in a line and reports whether a +// comment is still open at the end of it. +func stripComments(line string, inComment bool) (string, bool) { + var out strings.Builder + rest := line + for { + if inComment { + end := strings.Index(rest, "-->") + if end < 0 { + return out.String(), true + } + rest = rest[end+3:] + inComment = false + continue + } + start := strings.Index(rest, "", + "", + "- [ ] 1.1 (Unit) the only task here", + "prose continues", + "", + }, "\n")) + if len(doc.Checkboxes) != 1 || doc.Checkboxes[0].Line != 6 { + t.Errorf("checkboxes = %+v, want only the real one at line 6", doc.Checkboxes) + } + if len(doc.Headings) != 0 { + t.Errorf("a commented heading was collected: %+v", doc.Headings) + } +} + +// A fence inside a comment is commented out; a comment marker inside a fence is code. +func TestFencesAndCommentsDoNotConfuseEachOther(t *testing.T) { + doc := parse(t, strings.Join([]string{ + "```", + "", + "## another real heading", + "", + }, "\n")) + var texts []string + for _, h := range doc.Headings { + texts = append(texts, h.Text) + } + if strings.Join(texts, "|") != "a real heading|another real heading" { + t.Errorf("headings = %v, want the two real ones", texts) + } +} + +func TestTabIndentedFenceStillCloses(t *testing.T) { + doc := parse(t, "\t```\n- [ ] inside\n\t```\n- [ ] 1.1 outside\n") + if len(doc.Checkboxes) != 1 || doc.Checkboxes[0].Text != "1.1 outside" { + t.Errorf("checkboxes = %+v, want only the one outside the fence", doc.Checkboxes) + } +} + +// A longer closing run closes the block; a shorter one does not, so a nested +// three-backtick example inside a four-backtick block stays inside it. +func TestFenceLengthsMustMatch(t *testing.T) { + doc := parse(t, "````\n```\n- [ ] still inside\n```\n````\n- [ ] 1.1 out\n") + if len(doc.Checkboxes) != 1 || doc.Checkboxes[0].Text != "1.1 out" { + t.Errorf("checkboxes = %+v, want only the one after the outer fence", doc.Checkboxes) + } +} + +func TestTildeFences(t *testing.T) { + doc := parse(t, "~~~go\n- [ ] inside\n~~~\n- [ ] 1.1 out\n") + if len(doc.Checkboxes) != 1 { + t.Errorf("checkboxes = %+v, want only the one outside", doc.Checkboxes) + } +} + +// An inline code span is an example. `[[link]]` in backticks refers to nothing. +func TestCodeSpansAreNotLinks(t *testing.T) { + doc := parse(t, "See `[[example]]` and `[text](target.md)`, but [[real]] and [here](real.md) count.\n") + if len(doc.Wikilinks) != 1 || doc.Wikilinks[0].Target != "real" { + t.Errorf("wikilinks = %+v, want only [[real]]", doc.Wikilinks) + } + if len(doc.Links) != 1 || doc.Links[0].Target != "real.md" { + t.Errorf("links = %+v, want only real.md", doc.Links) + } +} + +// A single backtick that never closes is literal text, not the start of a span that +// swallows the rest of the line. +func TestUnterminatedCodeSpanDoesNotSwallowTheLine(t *testing.T) { + doc := parse(t, "a ` stray backtick and [[real]]\n") + if len(doc.Wikilinks) != 1 { + t.Errorf("wikilinks = %+v, want the one after the stray backtick", doc.Wikilinks) + } +} + +func TestWikilinkLabels(t *testing.T) { + doc := parse(t, "[[page-one]] and [[page-two|a nicer label]]\n") + if len(doc.Wikilinks) != 2 { + t.Fatalf("wikilinks = %+v, want 2", doc.Wikilinks) + } + if doc.Wikilinks[0].Target != "page-one" || doc.Wikilinks[0].Label != "" { + t.Errorf("first = %+v", doc.Wikilinks[0]) + } + if doc.Wikilinks[1].Target != "page-two" || doc.Wikilinks[1].Label != "a nicer label" { + t.Errorf("second = %+v", doc.Wikilinks[1]) + } +} + +// A codewiki citation is a link with no destination on purpose, so an empty target +// must survive parsing rather than being discarded as malformed. +func TestEmptyLinkTargetIsPreserved(t *testing.T) { + doc := parse(t, "as written in [internal/cli/cli.go:48-64]()\n") + if len(doc.Links) != 1 { + t.Fatalf("links = %+v, want 1", doc.Links) + } + if doc.Links[0].Text != "internal/cli/cli.go:48-64" || doc.Links[0].Target != "" { + t.Errorf("link = %+v, want the citation with an empty target", doc.Links[0]) + } +} + +// Frontmatter is skipped rather than scanned: a `#` in a value is not a heading, and +// line numbers after the block still have to be right. +func TestFrontmatterIsSkippedButCounted(t *testing.T) { + doc := parse(t, "---\nautonomy: auto\n---\n\n# Title\n") + if got, _ := doc.Frontmatter.Get("autonomy"); got != "auto" { + t.Errorf("frontmatter not parsed: %+v", doc.Frontmatter) + } + if len(doc.Headings) != 1 || doc.Headings[0].Line != 5 { + t.Errorf("heading = %+v, want one at line 5", doc.Headings) + } +} + +// Malformed frontmatter is returned as an error along with a usable document, so a +// validator can report the frontmatter problem without losing the rest of the file. +func TestParseReturnsBothErrorAndDocument(t *testing.T) { + doc, err := Parse("test.md", "---\ntools:\n - Read\n---\n# Title\n") + if err == nil { + t.Error("malformed frontmatter parsed without an error") + } + if doc == nil || len(doc.Lines) == 0 { + t.Error("no document was returned alongside the error") + } +} + +// The working tree's line endings are not part of the contract. +func TestCRLFScansIdentically(t *testing.T) { + body := "# Title\n\n- [ ] 1.1 (Unit) task\n\n[[link]]\n" + lf := parse(t, body) + crlf := parse(t, strings.ReplaceAll(body, "\n", "\r\n")) + if len(lf.Headings) != len(crlf.Headings) || + len(lf.Checkboxes) != len(crlf.Checkboxes) || + len(lf.Wikilinks) != len(crlf.Wikilinks) { + t.Errorf("CRLF scanned differently: %+v vs %+v", crlf, lf) + } + if lf.Checkboxes[0].Line != crlf.Checkboxes[0].Line { + t.Errorf("line numbers differ under CRLF: %d vs %d", crlf.Checkboxes[0].Line, lf.Checkboxes[0].Line) + } +} + +// Body gives every validator fence- and comment-awareness for free. A validator that +// re-derived it would get it wrong in a different way each time. +func TestBodyBlanksOutEverythingThatIsNotContent(t *testing.T) { + doc := parse(t, strings.Join([]string{ + "---", + "autonomy: auto", + "---", + "real prose", + "```", + "code line", + "```", + "", + "more prose continues", + "", + }, "\n")) + want := map[int]string{ + 2: "", // frontmatter + 4: "real prose", // content + 6: "", // inside the fence + 8: "", // whole-line comment + 9: "more prose continues", + } + for line, expect := range want { + if got := doc.Content(line); got != expect { + t.Errorf("Content(%d) = %q, want %q", line, got, expect) + } + } + if len(doc.Body) != len(doc.Lines) { + t.Errorf("Body has %d lines, Lines has %d — indices must line up", len(doc.Body), len(doc.Lines)) + } + if doc.Content(0) != "" || doc.Content(999) != "" { + t.Error("out-of-range Content() did not return empty") + } +} + +func TestLineIsOneBasedAndSafe(t *testing.T) { + doc := parse(t, "first\nsecond\n") + if doc.Line(1) != "first" || doc.Line(2) != "second" { + t.Errorf("Line() is not 1-based: %q %q", doc.Line(1), doc.Line(2)) + } + if doc.Line(0) != "" || doc.Line(999) != "" { + t.Error("out-of-range Line() did not return empty") + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go new file mode 100644 index 0000000..103f072 --- /dev/null +++ b/internal/paths/paths.go @@ -0,0 +1,194 @@ +// Package paths centralizes the on-disk layout of an scc workspace. Every +// directory and file name the CLI reads or writes is defined here so the layout +// lives in exactly one place — never hardcode ".claude" or "specs" elsewhere. +// +// The layout is Claude Code-native: no conversion layer, no parallel config +// format, and no directory of its own. Everything scc keeps lives under +// .claude/ — Claude Code's own configuration directory — alongside the agents, +// skills, and commands Claude Code already reads from there. +// +// Every segment the product governs is defined here now: Claude Code's own +// directories, scc's manifest, the rules directory the scaffolded CLAUDE.md +// points at, the two work trees (specs/, plans/), and the knowledge base under +// docs/. +package paths + +import "path/filepath" + +// ClaudeDir is Claude Code's own configuration directory at the project root. +// scc writes into it — agents, skills, commands, hooks, and its own two files — +// but it does not own it. +const ClaudeDir = ".claude" + +// ManifestSeg is scc's only file, and it does two jobs. +// +// It records, per scc-managed file, a content hash and the template version that +// produced it. The hash answers "did the user edit this?"; the version answers +// "what did it look like before?" — and an upgrade needs both, because it applies +// a three-way merge (re-render old, re-render new, diff, merge) rather than +// dropping a backup file. A manifest carrying only hashes cannot support that. +// +// Its presence is also the workspace marker: only scc writes it, so it exists +// exactly when scc has scaffolded this directory — the question a marker answers. +// +// The marker is deliberately this FILE and not .claude/ itself. ~/.claude is +// Claude Code's global configuration directory and exists on every machine that +// runs Claude Code, so an upward walk for the directory resolves the root to +// $HOME for any command run outside a workspace — and every command would then +// read and write the user's global configuration. .claude/ also exists in every +// repo that merely *uses* Claude Code, where scc was never initialized. See +// workspace.Find. +// +// There is no scc config file. scc runs no tests and no linters (see the design +// doc's §5 and §7 — that is the orchestrator's job), so it has nothing to +// configure; a project's test and lint commands belong in a rule under +// .claude/rules/, which is Markdown the orchestrator already reads. +const ManifestSeg = "scc-manifest.json" + +// Entry-point files at the project root. +const ( + // EntryFile is the agent entry point. Keeping it small is a product + // requirement, not a preference: every Claude Code session pays for it in + // context, so it links out to steering/knowledge rather than inlining them. + EntryFile = "CLAUDE.md" +) + +// Directory segments under .claude/. All but RulesSeg are Claude Code +// conventions. +const ( + AgentsSeg = "agents" // subagent definitions (review, security) + SkillsSeg = "skills" // model-invoked skills + CommandsSeg = "commands" // slash commands + HooksSeg = "hooks" // deterministic automation scripts + SettingsSeg = "settings.json" // hooks + permissions + + // RulesSeg holds the methodology, one file per concern. It is where scc's + // product actually lives: CLAUDE.md stays small and links here, because every + // session pays for CLAUDE.md in context and a rule only needs reading when it + // is relevant. Length has a measured cost in accuracy, not just in tokens. + RulesSeg = "rules" +) + +// Project-root directory segments that scc governs. The split is strict and each +// name means exactly one thing: specs/ holds features, plans/ holds initiatives, +// docs/ is the knowledge base. csdd kept plans under docs/plans/; a plan is work, +// not knowledge, so it moves out. +const ( + SpecsSeg = "specs" // one directory per feature + PlansSeg = "plans" // one file per initiative + DocsSeg = "docs" // the knowledge base +) + +// The three artifacts of a spec, directly under specs//. There is no +// intermediate route directory: a plan lives in plans/ and the Tasks route writes +// nothing at all, so nothing would be distinguished by one. +const ( + RequirementsSeg = "requirements.md" + DesignSeg = "design.md" + TasksSeg = "tasks.md" +) + +// The knowledge base under docs/. It answers *why* — the durable reasoning, the +// decisions, the material read from outside — while a spec answers what one +// feature does now. Neither replaces the other. +const ( + WikiSeg = "wiki" // the graph of durable knowledge + ADRSeg = "adr" // architecture decision records, numbered + RawSeg = "raw" // sources dropped in to be processed into the wiki + CodewikiSeg = "codewiki" // narrated code, citing [path:start-end]() + GlossarySeg = "glossary.md" // one canonical term per concept + StackSeg = "stack.md" // adopted technology; unlisted means undecided + WikiIndex = "index.md" // the wiki's entry point; an unreachable page is an orphan + WikiLog = "changelog.md" // what changed in the wiki, and when +) + +// Claude returns the .claude/ directory under root. +func Claude(root string) string { return filepath.Join(root, ClaudeDir) } + +// Agents returns .claude/agents/. +func Agents(root string) string { return filepath.Join(root, ClaudeDir, AgentsSeg) } + +// Skills returns .claude/skills/. +func Skills(root string) string { return filepath.Join(root, ClaudeDir, SkillsSeg) } + +// Commands returns .claude/commands/. +func Commands(root string) string { return filepath.Join(root, ClaudeDir, CommandsSeg) } + +// Hooks returns .claude/hooks/. +func Hooks(root string) string { return filepath.Join(root, ClaudeDir, HooksSeg) } + +// Settings returns .claude/settings.json. +func Settings(root string) string { return filepath.Join(root, ClaudeDir, SettingsSeg) } + +// Manifest returns .claude/scc-manifest.json, scc's only file and the workspace +// marker. +func Manifest(root string) string { return filepath.Join(root, ClaudeDir, ManifestSeg) } + +// Specs returns the project-root specs/ directory. +func Specs(root string) string { return filepath.Join(root, SpecsSeg) } + +// Spec returns specs//. Callers must pass feature through +// workspace.SafeName before this — the name arrives from CLI args, and without +// that check `..` resolves to the workspace root. +func Spec(root, feature string) string { return filepath.Join(root, SpecsSeg, feature) } + +// Requirements returns specs//requirements.md. +func Requirements(root, feature string) string { + return filepath.Join(root, SpecsSeg, feature, RequirementsSeg) +} + +// Design returns specs//design.md. +func Design(root, feature string) string { + return filepath.Join(root, SpecsSeg, feature, DesignSeg) +} + +// Tasks returns specs//tasks.md. +func Tasks(root, feature string) string { + return filepath.Join(root, SpecsSeg, feature, TasksSeg) +} + +// Plans returns the project-root plans/ directory. +func Plans(root string) string { return filepath.Join(root, PlansSeg) } + +// Plan returns plans/.md. A plan is one document: its progress is derived +// from the specs it decomposes into, never tracked in the plan itself, so there +// is no per-plan state to give it a directory. +func Plan(root, name string) string { + return filepath.Join(root, PlansSeg, name+".md") +} + +// Rules returns .claude/rules/. +func Rules(root string) string { return filepath.Join(root, ClaudeDir, RulesSeg) } + +// Rule returns .claude/rules/.md. Callers must pass name through +// workspace.SafeName first when it arrives from CLI args. +func Rule(root, name string) string { + return filepath.Join(root, ClaudeDir, RulesSeg, name+".md") +} + +// Docs returns the project-root docs/ knowledge base. +func Docs(root string) string { return filepath.Join(root, DocsSeg) } + +// Wiki returns docs/wiki/. +func Wiki(root string) string { return filepath.Join(root, DocsSeg, WikiSeg) } + +// ADR returns docs/adr/. +func ADR(root string) string { return filepath.Join(root, DocsSeg, ADRSeg) } + +// Raw returns docs/raw/, the drop box for sources awaiting processing. A file +// still sitting here is a wiki finding: it was collected and never read. +func Raw(root string) string { return filepath.Join(root, DocsSeg, RawSeg) } + +// Codewiki returns docs/codewiki/. +func Codewiki(root string) string { return filepath.Join(root, DocsSeg, CodewikiSeg) } + +// Glossary returns docs/glossary.md. +func Glossary(root string) string { return filepath.Join(root, DocsSeg, GlossarySeg) } + +// Stack returns docs/stack.md. Technology absent from it is an open decision, +// never something adopted silently — and because dependency manifests are +// structured data, that rule is checkable without reading any source. +func Stack(root string) string { return filepath.Join(root, DocsSeg, StackSeg) } + +// Entry returns the project-root CLAUDE.md. +func Entry(root string) string { return filepath.Join(root, EntryFile) } diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..24ef8f3 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,56 @@ +// Package render groups terminal output helpers used by the CLI surface. +// +// Status lines split across streams on purpose: Info/OK/Ask go to stdout, and +// Warn/Err to stderr. That split is what keeps stdout a clean, parseable stream +// when a command runs with --json (see internal/cli/jsonout.go) — diagnostics +// must never land in the JSON a caller is piping into jq. +package render + +import ( + "fmt" + "os" +) + +var useColor = isTTY(os.Stdout) && os.Getenv("NO_COLOR") == "" + +func isTTY(f *os.File) bool { + fi, err := f.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +func code(c, text string) string { + if !useColor { + return text + } + return "\033[" + c + "m" + text + "\033[0m" +} + +func Cyan(s string) string { return code("36", s) } +func Green(s string) string { return code("32", s) } +func Yellow(s string) string { return code("33", s) } +func Red(s string) string { return code("31", s) } +func Bold(s string) string { return code("1", s) } + +// Info prints a neutral status line to stdout. +func Info(msg string) { fmt.Println(Cyan("•") + " " + msg) } + +// Ask prints an interactive prompt to stdout without a trailing newline so the +// user's answer appears on the same line. +func Ask(msg string) { fmt.Print(Cyan("?") + " " + msg) } + +// OK prints a success status line to stdout. +func OK(msg string) { fmt.Println(Green("✓") + " " + msg) } + +// Warn prints a warning status line to stderr. +func Warn(msg string) { fmt.Fprintln(os.Stderr, Yellow("!")+" "+msg) } + +// Err prints an error status line to stderr. +func Err(msg string) { fmt.Fprintln(os.Stderr, Red("✗")+" "+msg) } + +// Detail prints an unglyphed continuation line to stderr, for the body of a +// report whose heading was already printed by Warn or Err. It carries no status +// of its own, so it does not compete with the ✓/✗/! lines for attention. +func Detail(msg string) { fmt.Fprintln(os.Stderr, msg) } diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go new file mode 100644 index 0000000..1462b8d --- /dev/null +++ b/internal/scaffold/scaffold.go @@ -0,0 +1,206 @@ +// Package scaffold applies the embedded template set to a directory and records +// what it wrote in the manifest. It is the mechanism behind `scc init`; the +// content it writes lives in internal/assets. +// +// Two properties are the whole point of the package, and both are tested rather +// than asserted: +// +// - **It never authors over what the user owns.** An existing file is left alone, +// whether it is pristine or edited. --force overwrites, and names every edited +// file it clobbered — silently replacing authored work is the one outcome that +// would make the tool untrustworthy. +// - **It is idempotent.** A second run writes nothing at all, including the +// manifest, so re-running init is free and safe rather than a diff. +package scaffold + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/protonspy/spec-claude-code/internal/assets" + "github.com/protonspy/spec-claude-code/internal/manifest" + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +// Action is what happened to one file. +type Action string + +const ( + // Created means the file was missing and scc wrote it. + Created Action = "created" + // Skipped means the file was already there and scc left it alone. + Skipped Action = "skipped" + // Replaced means --force overwrote a file that was still pristine. + Replaced Action = "replaced" + // Clobbered means --force overwrote a file the user had edited. It is a + // separate action from Replaced because it is the only one that destroys work, + // and it has to be reportable on its own. + Clobbered Action = "clobbered" + // Unmanaged means the manifest carried an entry for a file this version no + // longer ships. The entry is dropped and the file, if any, becomes the user's. + Unmanaged Action = "unmanaged" +) + +// Change is one file's outcome. Path is slash-separated and relative to the root, +// matching what the manifest records. +type Change struct { + Path string `json:"path"` + Action Action `json:"action"` +} + +// Result is what a run did, in a shape both the human report and --json read from. +type Result struct { + Root string `json:"root"` + Changes []Change `json:"changes"` + Created int `json:"created"` + Skipped int `json:"skipped"` + Replaced int `json:"replaced"` + Clobbered []string `json:"clobbered"` + ManifestWritten bool `json:"manifestWritten"` + AlreadyPresent bool `json:"alreadyPresent"` +} + +// Options are the knobs of one run. +type Options struct { + // SCCVersion is stamped into the manifest as a diagnostic — the first thing + // worth knowing when a workspace behaves like a different version than the user + // expects. No logic branches on it; only assets.Version decides behavior. + SCCVersion string + // Force overwrites existing files instead of leaving them alone. + Force bool +} + +// Apply writes the template set into root. With opts.Force, existing files are +// overwritten; without it, they are left exactly as they are. +// +// The manifest is written last, and only when its serialization differs from what +// is already on disk. Last, so a crash midway leaves no workspace marker and the +// retry is a clean adoption rather than a half-initialized tree. Only-when-changed, +// so a second run is a true no-op — the marker's mtime is not noise a reviewer +// should have to explain. +func Apply(root string, opts Options) (*Result, error) { + prior, found, err := manifest.Load(root) + if err != nil { + return nil, err + } + res := &Result{Root: root, AlreadyPresent: found} + + for _, dir := range assets.Dirs() { + if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(dir)), 0o755); err != nil { + return nil, err + } + } + + next := manifest.New(opts.SCCVersion) + for _, f := range assets.Workspace() { + pristine, err := assets.Content(f.Name) + if err != nil { + return nil, err + } + action, err := write(root, f, pristine, prior, opts.Force) + if err != nil { + return nil, fmt.Errorf("%s: %w", f.Rel, err) + } + res.record(f.Rel, action) + + // A file scc did not write keeps whatever version the manifest already + // recorded for it: that version is the base revision an upgrade merges + // from, and overwriting it with today's would claim the file looked like + // something it never looked like. A file with no prior entry is adopted at + // the current version, which makes today's template its merge base — the + // only honest answer when scc has no record of where it came from. + if e, ok := prior.Get(f.Rel); ok && action == Skipped { + next.Set(f.Rel, e.Hash, e.Version) + continue + } + next.Set(f.Rel, manifest.Hash(pristine), assets.Version) + } + + // Entries for templates this version no longer ships are dropped: the manifest + // is exactly the set of files scc manages, and a stale entry would send a later + // upgrade looking for a template that does not exist. + managed := map[string]bool{} + for _, f := range assets.Workspace() { + managed[f.Rel] = true + } + for _, e := range prior.Files { + if !managed[e.Path] { + res.record(e.Path, Unmanaged) + } + } + + same, err := manifestUnchanged(root, next) + if err != nil { + return nil, err + } + if !same { + if err := manifest.Save(root, next); err != nil { + return nil, err + } + res.ManifestWritten = true + } + return res, nil +} + +// write puts one file in place and reports what it did to whatever was there. +func write(root string, f assets.File, pristine string, prior *manifest.Manifest, force bool) (Action, error) { + dest := filepath.Join(root, filepath.FromSlash(f.Rel)) + onDisk, err := os.ReadFile(dest) + switch { + case errors.Is(err, fs.ErrNotExist): + return Created, workspace.AtomicWrite(dest, []byte(pristine), 0o644) + case err != nil: + return "", err + } + + if !force { + return Skipped, nil + } + // Writing identical content is not an overwrite; skipping it keeps --force from + // touching files it has nothing to change. + if manifest.Hash(string(onDisk)) == manifest.Hash(pristine) { + return Skipped, nil + } + // Edited, as far as scc can tell: either it differs from the version the + // manifest recorded, or scc has no record of it at all. Both mean somebody + // else's content is about to be destroyed, so both are reported as Clobbered. + action := Clobbered + if e, ok := prior.Get(f.Rel); ok && manifest.Hash(string(onDisk)) == e.Hash { + action = Replaced + } + return action, workspace.AtomicWrite(dest, []byte(pristine), 0o644) +} + +func manifestUnchanged(root string, m *manifest.Manifest) (bool, error) { + next, err := m.Bytes() + if err != nil { + return false, err + } + current, err := os.ReadFile(paths.Manifest(root)) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return string(current) == string(next), nil +} + +func (r *Result) record(rel string, a Action) { + r.Changes = append(r.Changes, Change{Path: rel, Action: a}) + switch a { + case Created: + r.Created++ + case Skipped: + r.Skipped++ + case Replaced: + r.Replaced++ + case Clobbered: + r.Replaced++ + r.Clobbered = append(r.Clobbered, rel) + } +} diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go new file mode 100644 index 0000000..33fec0d --- /dev/null +++ b/internal/scaffold/scaffold_test.go @@ -0,0 +1,379 @@ +package scaffold + +import ( + "os" + "path/filepath" + "testing" + + "github.com/protonspy/spec-claude-code/internal/assets" + "github.com/protonspy/spec-claude-code/internal/manifest" + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +func apply(t *testing.T, root string, force bool) *Result { + t.Helper() + res, err := Apply(root, Options{SCCVersion: "v0.0.0-test", Force: force}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + return res +} + +func read(t *testing.T, root, rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + t.Fatalf("reading %s: %v", rel, err) + } + return string(b) +} + +func TestApplyWritesTheWholeSet(t *testing.T) { + root := t.TempDir() + res := apply(t, root, false) + + if res.AlreadyPresent { + t.Error("AlreadyPresent = true for a fresh directory") + } + for _, f := range assets.Workspace() { + want, err := assets.Content(f.Name) + if err != nil { + t.Fatalf("Content: %v", err) + } + if got := read(t, root, f.Rel); got != want { + t.Errorf("%s: content does not match the template", f.Rel) + } + } + if res.Created != len(assets.Workspace()) { + t.Errorf("created = %d, want %d", res.Created, len(assets.Workspace())) + } + for _, dir := range assets.Dirs() { + info, err := os.Stat(filepath.Join(root, filepath.FromSlash(dir))) + if err != nil || !info.IsDir() { + t.Errorf("directory %s was not created: %v", dir, err) + } + } +} + +// The marker is what makes every other command able to find the root, from any +// depth, which is the reason it is a file rather than the .claude/ directory. +func TestApplyMakesTheDirectoryAWorkspace(t *testing.T) { + root := t.TempDir() + apply(t, root, false) + + if !workspace.IsWorkspace(root) { + t.Fatal("root is not a workspace after Apply") + } + deep := filepath.Join(root, paths.SpecsSeg, "user-auth", "nested") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // t.TempDir() can sit under a symlink (/var -> /private/var on macOS) and + // Windows reports 8.3 short names, so compare identities rather than strings. + found, err := os.Stat(workspace.Find(deep)) + if err != nil { + t.Fatalf("Stat: %v", err) + } + want, err := os.Stat(root) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if !os.SameFile(found, want) { + t.Errorf("Find(%q) did not resolve back to the root", deep) + } +} + +func TestApplyRecordsEveryManagedFile(t *testing.T) { + root := t.TempDir() + apply(t, root, false) + + m, found, err := manifest.Load(root) + if err != nil || !found { + t.Fatalf("Load = (%v, %v), want found", found, err) + } + if m.SCC != "v0.0.0-test" { + t.Errorf("scc stamp = %q, want the version passed in", m.SCC) + } + if len(m.Files) != len(assets.Workspace()) { + t.Fatalf("entries = %d, want %d", len(m.Files), len(assets.Workspace())) + } + for _, f := range assets.Workspace() { + e, ok := m.Get(f.Rel) + if !ok { + t.Errorf("%s is not in the manifest", f.Rel) + continue + } + if e.Version != assets.Version { + t.Errorf("%s: version = %q, want %q", f.Rel, e.Version, assets.Version) + } + status, err := m.Status(root, e) + if err != nil || status != manifest.Pristine { + t.Errorf("%s: status = (%v, %v) right after writing it, want pristine", f.Rel, status, err) + } + } +} + +// Spec and plan artifacts are authored from birth and owned immediately, so they +// are deliberately absent from the manifest — which is what keeps an upgrade from +// ever touching a requirement. +func TestManifestRecordsOnlySccManagedFiles(t *testing.T) { + root := t.TempDir() + apply(t, root, false) + m, _, err := manifest.Load(root) + if err != nil { + t.Fatalf("Load: %v", err) + } + for _, e := range m.Files { + if len(e.Path) >= 6 && (e.Path[:6] == "specs/" || e.Path[:6] == "plans/") { + t.Errorf("manifest records a user artifact: %s", e.Path) + } + } +} + +// A second run writes nothing — not one file, and not the manifest. Re-running +// init has to be free, or the "just run init again" upgrade story costs a diff. +func TestApplyIsIdempotent(t *testing.T) { + root := t.TempDir() + apply(t, root, false) + before, err := os.ReadFile(paths.Manifest(root)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + res := apply(t, root, false) + if !res.AlreadyPresent { + t.Error("AlreadyPresent = false on a second run") + } + if res.Created != 0 || res.Replaced != 0 { + t.Errorf("second run wrote files: created=%d replaced=%d", res.Created, res.Replaced) + } + if res.Skipped != len(assets.Workspace()) { + t.Errorf("skipped = %d, want %d", res.Skipped, len(assets.Workspace())) + } + if res.ManifestWritten { + t.Error("second run rewrote the manifest") + } + after, err := os.ReadFile(paths.Manifest(root)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(before) != string(after) { + t.Error("the manifest changed across two identical runs") + } +} + +// Never author what the user owns: an edited file survives init, and it keeps the +// version the manifest recorded for it — that version is the base revision a later +// three-way merge starts from. +func TestApplyNeverOverwritesWithoutForce(t *testing.T) { + root := t.TempDir() + apply(t, root, false) + + target := ".claude/rules/methodology.md" + mine := "# mine\n\nI rewrote this rule.\n" + if err := workspace.AtomicWrite(filepath.Join(root, filepath.FromSlash(target)), []byte(mine), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + + res := apply(t, root, false) + if got := read(t, root, target); got != mine { + t.Errorf("init overwrote an edited file:\n%s", got) + } + if len(res.Clobbered) != 0 { + t.Errorf("clobbered = %v on a run without --force", res.Clobbered) + } + m, _, err := manifest.Load(root) + if err != nil { + t.Fatalf("Load: %v", err) + } + e, ok := m.Get(target) + if !ok { + t.Fatal("the edited file left the manifest") + } + status, err := m.Status(root, e) + if err != nil || status != manifest.Edited { + t.Errorf("status = (%v, %v), want edited", status, err) + } +} + +// --force overwrites, and has to name what it destroyed. A silent clobber is the +// one outcome that would make the tool untrustworthy. +func TestForceNamesEveryEditedFileItClobbers(t *testing.T) { + root := t.TempDir() + apply(t, root, false) + + edited := ".claude/rules/routing.md" + if err := workspace.AtomicWrite(filepath.Join(root, filepath.FromSlash(edited)), []byte("# mine\n"), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + + res := apply(t, root, true) + if len(res.Clobbered) != 1 || res.Clobbered[0] != edited { + t.Errorf("clobbered = %v, want exactly [%s]", res.Clobbered, edited) + } + want, err := assets.Content("claude/rules/routing.md") + if err != nil { + t.Fatalf("Content: %v", err) + } + if got := read(t, root, edited); got != want { + t.Error("--force did not restore the template") + } + // Every other file was already identical, so --force had nothing to write to + // them: an overwrite that changes nothing is not an overwrite. + if res.Replaced != 1 { + t.Errorf("replaced = %d, want 1", res.Replaced) + } +} + +// A pre-existing file scc has no record of — someone else's CLAUDE.md, adopting scc +// into a live repo — is reported as clobbered too. scc cannot tell it from an edit, +// and guessing in the direction of "it was probably fine to destroy" is the wrong +// default. +func TestForceReportsAdoptedFilesAsClobbered(t *testing.T) { + root := t.TempDir() + if err := workspace.AtomicWrite(paths.Entry(root), []byte("# their own CLAUDE.md\n"), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + res := apply(t, root, true) + if len(res.Clobbered) != 1 || res.Clobbered[0] != paths.EntryFile { + t.Errorf("clobbered = %v, want [%s]", res.Clobbered, paths.EntryFile) + } +} + +// Adopting a directory that already has content must not lose it, and the file that +// was there keeps being the user's. +func TestApplyAdoptsAnExistingFileWithoutLosingIt(t *testing.T) { + root := t.TempDir() + theirs := "# their own CLAUDE.md\n" + if err := workspace.AtomicWrite(paths.Entry(root), []byte(theirs), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + apply(t, root, false) + + if got := read(t, root, paths.EntryFile); got != theirs { + t.Errorf("adoption overwrote their file:\n%s", got) + } + m, _, err := manifest.Load(root) + if err != nil { + t.Fatalf("Load: %v", err) + } + e, ok := m.Get(paths.EntryFile) + if !ok { + t.Fatal("the adopted file is not in the manifest") + } + // Recorded at the current template version: today's template is the only honest + // merge base when scc has no record of where the file came from. + if e.Version != assets.Version { + t.Errorf("version = %q, want %q", e.Version, assets.Version) + } + status, _ := m.Status(root, e) + if status != manifest.Edited { + t.Errorf("status = %v, want edited", status) + } +} + +// A CRLF checkout of an untouched file is untouched. Without normalization every +// managed file on a Windows working tree would read as edited. +func TestCRLFOnDiskIsNotAnEdit(t *testing.T) { + root := t.TempDir() + apply(t, root, false) + + rel := ".claude/rules/tasks.md" + pristine := read(t, root, rel) + crlf := "" + for _, line := range splitLines(pristine) { + crlf += line + "\r\n" + } + if err := workspace.AtomicWrite(filepath.Join(root, filepath.FromSlash(rel)), []byte(crlf), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + + res := apply(t, root, true) + if len(res.Clobbered) != 0 { + t.Errorf("a CRLF checkout was reported as edited: %v", res.Clobbered) + } + if got := read(t, root, rel); got != crlf { + t.Error("--force rewrote a file whose content was equivalent") + } +} + +// A manifest entry for a template this version no longer ships is dropped rather +// than left to send a later upgrade looking for a template that does not exist. +func TestStaleManifestEntriesAreDropped(t *testing.T) { + root := t.TempDir() + apply(t, root, false) + + m, _, err := manifest.Load(root) + if err != nil { + t.Fatalf("Load: %v", err) + } + m.Set(".claude/rules/removed-in-a-later-version.md", "deadbeef", "0") + if err := manifest.Save(root, m); err != nil { + t.Fatalf("Save: %v", err) + } + + res := apply(t, root, false) + var unmanaged []string + for _, c := range res.Changes { + if c.Action == Unmanaged { + unmanaged = append(unmanaged, c.Path) + } + } + if len(unmanaged) != 1 || unmanaged[0] != ".claude/rules/removed-in-a-later-version.md" { + t.Errorf("unmanaged = %v, want the stale entry", unmanaged) + } + after, _, err := manifest.Load(root) + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := after.Get(".claude/rules/removed-in-a-later-version.md"); ok { + t.Error("the stale entry survived") + } +} + +// A missing file is restored: nothing is lost by deleting a managed file, which is +// what makes "delete it and re-run init" a safe instruction. +func TestApplyRestoresADeletedFile(t *testing.T) { + root := t.TempDir() + apply(t, root, false) + rel := ".claude/agents/code-review.md" + if err := os.Remove(filepath.Join(root, filepath.FromSlash(rel))); err != nil { + t.Fatalf("Remove: %v", err) + } + res := apply(t, root, false) + if res.Created != 1 { + t.Errorf("created = %d, want 1", res.Created) + } + want, _ := assets.Content("claude/agents/code-review.md") + if got := read(t, root, rel); got != want { + t.Error("the deleted file was not restored from the template") + } +} + +// Changes are reported in one deterministic order so a --json consumer and a human +// reading two runs see the same sequence. +func TestChangesAreSortedByPath(t *testing.T) { + root := t.TempDir() + res := apply(t, root, false) + for i := 1; i < len(res.Changes); i++ { + if res.Changes[i-1].Path >= res.Changes[i].Path { + t.Fatalf("changes not sorted: %q before %q", res.Changes[i-1].Path, res.Changes[i].Path) + } + } +} + +func splitLines(s string) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + out = append(out, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + out = append(out, s[start:]) + } + return out +} diff --git a/internal/textutil/textutil.go b/internal/textutil/textutil.go new file mode 100644 index 0000000..c3687e0 --- /dev/null +++ b/internal/textutil/textutil.go @@ -0,0 +1,27 @@ +// Package textutil holds tiny, dependency-free text helpers shared across the +// codebase. It exists so line-ending and BOM handling is defined in exactly one +// place: scc runs on Windows/WSL where the working tree is frequently CRLF, and +// every content comparison (manifest hashes, frontmatter parsing, Markdown +// regexes) must treat "\r\n" and "\n" identically or the tool misbehaves. +package textutil + +import "strings" + +// bom is U+FEFF, which editors like Windows Notepad prepend to files. It is not +// Unicode whitespace, so it must be stripped explicitly. Built from its code +// point so the byte sequence never appears literally in this source file. +var bom = string(rune(0xFEFF)) + +// NormalizeNewlines converts Windows (\r\n) and classic-Mac (lone \r) line +// endings to Unix (\n) and strips a leading UTF-8 BOM. The result is what every +// parser and content hash in scc should operate on, so behavior is identical +// regardless of how git checked the files out. +func NormalizeNewlines(s string) string { + s = strings.TrimPrefix(s, bom) + if strings.IndexByte(s, '\r') == -1 { + return s + } + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + return s +} diff --git a/internal/textutil/textutil_test.go b/internal/textutil/textutil_test.go new file mode 100644 index 0000000..9622543 --- /dev/null +++ b/internal/textutil/textutil_test.go @@ -0,0 +1,38 @@ +package textutil + +import "testing" + +func TestNormalizeNewlines(t *testing.T) { + bomPrefix := string(rune(0xFEFF)) + cases := []struct { + name string + in string + want string + }{ + {"already lf", "a\nb\n", "a\nb\n"}, + {"crlf", "a\r\nb\r\n", "a\nb\n"}, + {"lone cr", "a\rb\r", "a\nb\n"}, + {"mixed", "a\r\nb\rc\n", "a\nb\nc\n"}, + {"bom stripped", bomPrefix + "a\n", "a\n"}, + {"bom with crlf", bomPrefix + "a\r\n", "a\n"}, + {"bom only at start", "a" + bomPrefix + "b", "a" + bomPrefix + "b"}, + {"empty", "", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := NormalizeNewlines(c.in); got != c.want { + t.Errorf("NormalizeNewlines(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// Normalizing is idempotent — content hashes depend on it, so a second pass must +// never change the result. +func TestNormalizeNewlinesIdempotent(t *testing.T) { + in := string(rune(0xFEFF)) + "a\r\nb\rc\n" + once := NormalizeNewlines(in) + if twice := NormalizeNewlines(once); twice != once { + t.Errorf("not idempotent: %q then %q", once, twice) + } +} diff --git a/internal/validate/adr.go b/internal/validate/adr.go new file mode 100644 index 0000000..4c3fb7e --- /dev/null +++ b/internal/validate/adr.go @@ -0,0 +1,169 @@ +package validate + +import ( + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/paths" +) + +var ( + // adrNameRe is the filename shape: a four-digit number, a hyphen, a kebab slug. + adrNameRe = regexp.MustCompile(`^(\d{4})-([a-z0-9][a-z0-9-]*)\.md$`) + // adrCitationRe matches `adr:0007-use-sqlite-for-the-cache` anywhere in prose. + adrCitationRe = regexp.MustCompile(`\badr:(\d{4}-[a-z0-9][a-z0-9-]*)\b`) +) + +// adrRecord is one record on disk. +type adrRecord struct { + number int + file string // the finding-facing path + path string // the path on disk +} + +// adrStatuses are the four a record can be in. +var adrStatuses = map[string]bool{"proposed": true, "accepted": true, "rejected": true, "superseded": true} + +// ADR validates docs/adr/. +// +// The check that carries the weight is the superseding one. **A superseded record is +// marked, never edited** — the point of an ADR is that it records what was believed at +// the time, so rewriting one destroys the only thing it exists to preserve. Structurally +// that means: a record marked superseded names its successor, and the successor exists. +func ADR(root string) (*finding.Set, error) { + set := &finding.Set{} + dir := paths.ADR(root) + files, err := markdownFiles(dir) + if err != nil { + return nil, err + } + // Citations are resolved even when there are no records at all: `adr:0003-x` in a + // wiki page sends the reader nowhere whether or not docs/adr/ exists, and that is a + // broken reference either way. + if len(files) == 0 { + return set, checkADRCitations(set, root, nil) + } + + slugs := map[string]bool{} // "0007-use-sqlite" -> exists + numbers := map[int]string{} // 7 -> filename + var records []adrRecord + + for _, path := range files { + name := filepath.Base(path) + m := adrNameRe.FindStringSubmatch(name) + if m == nil { + set.Addf(rel(root, path), 0, "adr.malformed-filename", + "an ADR is named `NNNN-kebab-slug.md`, so records sort and cite predictably") + continue + } + n, _ := strconv.Atoi(m[1]) + slug := strings.TrimSuffix(name, ".md") + if prior, dup := numbers[n]; dup { + set.Addf(rel(root, path), 0, "adr.duplicate-number", + "%04d is already used by %s; a citation would be ambiguous", n, prior) + continue + } + numbers[n] = name + slugs[slug] = true + records = append(records, adrRecord{number: n, file: rel(root, path), path: path}) + } + + sort.Slice(records, func(i, j int) bool { return records[i].number < records[j].number }) + checkADRContiguity(set, records) + + for _, r := range records { + if err := checkADRRecord(set, root, r.path, r.file, slugs); err != nil { + return nil, err + } + } + if err := checkADRCitations(set, root, slugs); err != nil { + return nil, err + } + return set, nil +} + +// checkADRContiguity reports gaps. Unlike requirement numbering — where a delta +// legitimately leaves a hole — an ADR is never removed, so a missing number means a +// record was deleted or never committed, and either way the history has a hole in it. +func checkADRContiguity(set *finding.Set, records []adrRecord) { + for i, r := range records { + want := i + 1 + if r.number == want { + continue + } + set.Addf(r.file, 0, "adr.numbering-gap", + "numbered %04d where %04d was expected; ADR numbers run contiguously from 0001 because a gap means a record went missing", + r.number, want) + return // one finding, not one per record after the gap + } +} + +func checkADRRecord(set *finding.Set, root, path, file string, slugs map[string]bool) error { + doc, err := read(root, path) + if err != nil { + if doc == nil { + return err + } + set.Addf(file, 1, "adr.frontmatter-unreadable", "%v", err) + return nil + } + status, hasStatus := doc.Frontmatter.Get("status") + successor, hasSuccessor := doc.Frontmatter.Get("superseded-by") + + switch { + case !hasStatus: + set.Addf(file, 1, "adr.missing-status", + "an ADR records its `status`: %s", allowed(adrStatuses)) + case !adrStatuses[status]: + set.Addf(file, 1, "adr.status-invalid", + "`status: %s` is not one of %s", status, allowed(adrStatuses)) + } + + // The two halves have to agree, in both directions: a record cannot be superseded + // by nothing, and it cannot name a successor while claiming to be current. + if status == "superseded" && (!hasSuccessor || successor == "") { + set.Addf(file, 1, "adr.superseded-without-successor", + "marked superseded and names no successor; add `superseded-by: NNNN-slug`") + } + if hasSuccessor && successor != "" { + if status != "superseded" { + set.Addf(file, 1, "adr.successor-without-status", + "names a successor but its status is %q; set `status: superseded`", status) + } + if !slugs[successor] { + set.Addf(file, 1, "adr.unknown-successor", "`superseded-by: %s` resolves to no record", successor) + } + } + return nil +} + +// checkADRCitations resolves `adr:` across the knowledge base. A citation to a +// record that does not exist is a reader sent to nothing. +func checkADRCitations(set *finding.Set, root string, slugs map[string]bool) error { + dirs := []string{paths.ADR(root), paths.Wiki(root), paths.Codewiki(root)} + for _, dir := range dirs { + files, err := markdownFiles(dir) + if err != nil { + return err + } + for _, path := range files { + doc, err := read(root, path) + if err != nil || doc == nil { + continue + } + for i, line := range doc.Body { + for _, m := range adrCitationRe.FindAllStringSubmatch(line, -1) { + if !slugs[m[1]] { + set.Addf(rel(root, path), i+1, "adr.unknown-citation", + "adr:%s resolves to no record under %s/", m[1], paths.ADRSeg) + } + } + } + } + } + return nil +} diff --git a/internal/validate/all.go b/internal/validate/all.go new file mode 100644 index 0000000..9d6a8fb --- /dev/null +++ b/internal/validate/all.go @@ -0,0 +1,59 @@ +package validate + +import ( + "sort" + + "github.com/protonspy/spec-claude-code/internal/finding" +) + +// Validator is one named check over a workspace. +type Validator struct { + Name string + Run func(root string) (*finding.Set, error) +} + +// All is every validator that runs under `scc validate`, in a fixed order so two runs +// over one workspace report identically. +// +// Each one is silent when its subject is absent: a workspace with no skills is not a +// workspace with findings. That is what lets the aggregate command run unconditionally +// instead of asking the user which validators apply. +func All() []Validator { + return []Validator{ + {Name: "skill", Run: Skills}, + {Name: "spec", Run: Specs}, + {Name: "plan", Run: Plans}, + {Name: "wiki", Run: Wiki}, + {Name: "adr", Run: ADR}, + {Name: "glossary", Run: Glossary}, + {Name: "stack", Run: Stack}, + {Name: "codewiki", Run: Codewiki}, + } +} + +// Result is one validator's outcome, kept separate from the merged set so the report +// can say which check found what. +type Result struct { + Name string `json:"validator"` + Findings int `json:"findings"` +} + +// Everything runs every validator and merges the findings into one set. +// +// One exit code and one document, because eight validators the user has to invoke +// separately is eight chances to skip one. The per-validator counts come back +// alongside so the report can group by check without re-running anything. +func Everything(root string) (*finding.Set, []Result, error) { + set := &finding.Set{} + var results []Result + for _, v := range All() { + one, err := v.Run(root) + if err != nil { + return nil, nil, err + } + results = append(results, Result{Name: v.Name, Findings: one.Len()}) + set.Extend(one) + } + sort.SliceStable(results, func(i, j int) bool { return results[i].Name < results[j].Name }) + return set, results, nil +} diff --git a/internal/validate/codewiki.go b/internal/validate/codewiki.go new file mode 100644 index 0000000..b105bc6 --- /dev/null +++ b/internal/validate/codewiki.go @@ -0,0 +1,175 @@ +package validate + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/mdscan" + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// citationRe matches a codewiki citation: `[internal/cli/cli.go:48-64]()`, or a single +// line, `[internal/cli/cli.go:48]()`. +var citationRe = regexp.MustCompile(`^(.+?):(\d+)(?:-(\d+))?$`) + +// Codewiki validates docs/codewiki/. +// +// This is the heaviest validator and the one most likely to be wrong, because it is the +// only one that resolves anything against the live checkout. It still reads no code: a +// citation names a file and a line range, and whether that file has that many lines is +// arithmetic, not comprehension. +// +// What it holds pages to: +// +// - every citation resolves — the file exists and actually has those lines +// - every section cites something, because a section that cites nothing is prose that +// has drifted free of the code it describes +// - headings are unique within a page, so a slug identifies one section +// +// The `Structure` tree check named in the design is deliberately absent: the design has +// not settled what that tree looks like on disk, and a check written against a guess +// would be the false positive that costs the user's trust in the other three. +func Codewiki(root string) (*finding.Set, error) { + set := &finding.Set{} + files, err := markdownFiles(paths.Codewiki(root)) + if err != nil { + return nil, err + } + for _, path := range files { + if err := codewikiPage(set, root, path); err != nil { + return nil, err + } + } + return set, nil +} + +func codewikiPage(set *finding.Set, root, path string) error { + file := rel(root, path) + doc, err := read(root, path) + if err != nil { + if doc == nil { + return err + } + set.Addf(file, 1, "codewiki.frontmatter-unreadable", "%v", err) + return nil + } + + checkCodewikiHeadings(set, file, doc) + + // Citations, and which section each one belongs to, so "no section cites anything" + // can be answered per section rather than per page. + cited := map[int]bool{} // heading line -> saw a citation under it + for _, link := range doc.Links { + if link.Target != "" { + continue // an ordinary link, not a citation + } + m := citationRe.FindStringSubmatch(link.Text) + if m == nil { + continue + } + cited[sectionOf(doc, link.Line)] = true + checkCitation(set, root, file, link.Line, m) + } + + for _, h := range doc.Headings { + // Level 1 is the page title, which is not a section. + if h.Level < 2 || cited[h.Line] { + continue + } + set.Addf(file, h.Line, "codewiki.section-cites-nothing", + "%q cites no code; a section with no citation is prose that has drifted free of what it describes", h.Text) + } + return nil +} + +// checkCitation resolves one citation against the checkout. +func checkCitation(set *finding.Set, root, file string, line int, m []string) { + target, startText, endText := m[1], m[2], m[3] + start, _ := strconv.Atoi(startText) + end := start + if endText != "" { + end, _ = strconv.Atoi(endText) + } + + if start < 1 { + set.Addf(file, line, "codewiki.citation-invalid", "%s: line numbers start at 1", target) + return + } + if end < start { + set.Addf(file, line, "codewiki.citation-invalid", + "%s:%d-%d ends before it starts", target, start, end) + return + } + + resolved := filepath.Join(root, filepath.FromSlash(target)) + if !isFile(resolved) { + set.Addf(file, line, "codewiki.citation-unresolved", "%s does not exist", target) + return + } + b, err := os.ReadFile(resolved) + if err != nil { + // Unreadable is not the same as wrong, and scc will not report a finding it + // cannot stand behind. + return + } + if lines := countLines(b); end > lines { + set.Addf(file, line, "codewiki.citation-out-of-range", + "%s:%d-%d, but the file has %d lines — the code moved and the citation did not", + target, start, end, lines) + } +} + +// checkCodewikiHeadings holds slugs to being unique and derived from their headings. A +// duplicated heading gives two sections one anchor, and a link to it lands on whichever +// came first. +func checkCodewikiHeadings(set *finding.Set, file string, doc *mdscan.Document) { + seen := map[string]int{} + for _, h := range doc.Headings { + if h.Text == "" { + set.Addf(file, h.Line, "codewiki.empty-heading", "a heading with no text has no slug to link to") + continue + } + slug := mdscan.Slug(h.Text) + if slug == "" { + set.Addf(file, h.Line, "codewiki.unslugged-heading", + "%q yields no anchor; a heading needs at least one letter or digit", h.Text) + continue + } + if prior, dup := seen[slug]; dup { + set.Addf(file, h.Line, "codewiki.duplicate-heading", + "%q repeats the heading on line %d, so both sections share one anchor", h.Text, prior) + continue + } + seen[slug] = h.Line + } +} + +// sectionOf returns the line of the nearest heading at or above line, or 0. +func sectionOf(doc *mdscan.Document, line int) int { + best := 0 + for _, h := range doc.Headings { + if h.Line <= line && h.Line > best { + best = h.Line + } + } + return best +} + +// countLines counts the lines in a file the way an editor numbers them: a trailing +// newline does not add a line, so the last line of a well-formed file is its content +// rather than the empty string after it. +func countLines(b []byte) int { + if len(b) == 0 { + return 0 + } + n := bytes.Count(b, []byte("\n")) + if !strings.HasSuffix(string(b), "\n") { + n++ + } + return n +} diff --git a/internal/validate/glossary.go b/internal/validate/glossary.go new file mode 100644 index 0000000..3bdcd84 --- /dev/null +++ b/internal/validate/glossary.go @@ -0,0 +1,132 @@ +package validate + +import ( + "regexp" + "sort" + "strings" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// glossaryEntryRe matches one entry: +// +// - **order total** — the amount charged, in minor units. Avoid: grand total, sum +var ( + glossaryEntryRe = regexp.MustCompile(`^\s*[-*+]\s+\*\*([^*]+)\*\*\s*(.*)$`) + glossaryAvoidRe = regexp.MustCompile(`(?i)\bavoid:\s*(.*)$`) +) + +// Glossary validates docs/glossary.md and the vocabulary it governs. +// +// Domain vocabulary drifts by default — three names for one thing appear within a week +// of two people working in parallel — so the glossary picks one canonical term and lists +// the others as avoided. This validator holds the knowledge base to that. +// +// **Whole-word matching only.** Substring matching here is a false-positive generator: +// an avoided synonym like "sum" appears inside "summary", "assume", and "consumer", and +// a validator that reported those would be muted within a day and take the real +// findings with it. +func Glossary(root string) (*finding.Set, error) { + set := &finding.Set{} + path := paths.Glossary(root) + if !isFile(path) { + return set, nil + } + file := rel(root, path) + doc, err := read(root, path) + if err != nil { + if doc == nil { + return nil, err + } + set.Addf(file, 1, "glossary.frontmatter-unreadable", "%v", err) + return set, nil + } + + canonical := map[string]int{} // lowercased term -> line + avoided := map[string]string{} // lowercased synonym -> the canonical term to use + + for i, line := range doc.Body { + m := glossaryEntryRe.FindStringSubmatch(line) + if m == nil { + continue + } + term := strings.TrimSpace(m[1]) + key := strings.ToLower(term) + if prior, dup := canonical[key]; dup { + set.Addf(file, i+1, "glossary.duplicate-term", + "%q is already defined on line %d; one canonical term means one entry", term, prior) + continue + } + canonical[key] = i + 1 + + if a := glossaryAvoidRe.FindStringSubmatch(m[2]); a != nil { + for _, syn := range strings.Split(a[1], ",") { + syn = strings.Trim(strings.TrimSpace(syn), ".") + if syn == "" { + continue + } + avoided[strings.ToLower(syn)] = term + } + } + } + + // A term cannot be both the word to use and a word to avoid. Whichever the author + // meant, the glossary currently says both. + for syn, term := range avoided { + if line, isCanonical := canonical[syn]; isCanonical { + set.Addf(file, line, "glossary.synonym-is-canonical", + "%q is defined as canonical and also listed as a synonym to avoid under %q", syn, term) + } + } + + if len(avoided) == 0 { + return set, nil + } + if err := checkAvoidedTerms(set, root, avoided); err != nil { + return nil, err + } + return set, nil +} + +// checkAvoidedTerms scans the knowledge base for avoided synonyms. +// +// The knowledge base only: docs/wiki/, docs/adr/, and docs/codewiki/. Not source, which +// scc never reads, and not specs/ — a requirement quoting an external contract verbatim +// is not a vocabulary slip, and reporting it would be the validator second-guessing a +// quotation it cannot see the source of. +func checkAvoidedTerms(set *finding.Set, root string, avoided map[string]string) error { + synonyms := make([]string, 0, len(avoided)) + for syn := range avoided { + synonyms = append(synonyms, syn) + } + sort.Strings(synonyms) + + matchers := map[string]*regexp.Regexp{} + for _, syn := range synonyms { + // \b around a quoted phrase: whole words, and phrases matched as phrases. + matchers[syn] = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(syn) + `\b`) + } + + for _, dir := range []string{paths.Wiki(root), paths.ADR(root), paths.Codewiki(root)} { + files, err := markdownFiles(dir) + if err != nil { + return err + } + for _, path := range files { + doc, err := read(root, path) + if err != nil || doc == nil { + continue + } + for i, line := range doc.Body { + for _, syn := range synonyms { + if matchers[syn].MatchString(line) { + set.Addf(rel(root, path), i+1, "glossary.avoided-synonym", + "%q is an avoided synonym; the glossary's canonical term is %q", syn, avoided[syn]) + } + } + } + } + } + return nil +} diff --git a/internal/validate/knowledge_test.go b/internal/validate/knowledge_test.go new file mode 100644 index 0000000..c672d47 --- /dev/null +++ b/internal/validate/knowledge_test.go @@ -0,0 +1,386 @@ +package validate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/paths" +) + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +func runValidator(t *testing.T, fn func(string) (*finding.Set, error), root string) []string { + t.Helper() + set, err := fn(root) + if err != nil { + t.Fatalf("validator: %v", err) + } + return rules(set) +} + +// Every knowledge-base validator is silent when its subject does not exist. That is what +// lets `scc validate` run all eight unconditionally instead of asking the user which +// apply. +func TestKnowledgeValidatorsAreSilentOnAnEmptyWorkspace(t *testing.T) { + root := t.TempDir() + for name, fn := range map[string]func(string) (*finding.Set, error){ + "wiki": Wiki, + "adr": ADR, + "glossary": Glossary, + "stack": Stack, + "codewiki": Codewiki, + } { + if got := runValidator(t, fn, root); len(got) != 0 { + t.Errorf("%s produced findings on an empty workspace: %v", name, got) + } + } +} + +func TestWikiGraphChecks(t *testing.T) { + root := t.TempDir() + wiki := paths.Wiki(root) + write(t, filepath.Join(wiki, paths.WikiIndex), "# Index\n\n- [[order-total]]\n- [[nowhere]]\n") + write(t, filepath.Join(wiki, "order-total.md"), "# Order total\n\nThe amount charged.\n") + write(t, filepath.Join(wiki, "unlinked.md"), "# Unlinked\n\nNobody links here.\n") + write(t, filepath.Join(wiki, paths.WikiLog), "# Changelog\n\n- added [[order-total]]\n- added [[deleted-page]]\n") + + got := runValidator(t, Wiki, root) + for _, want := range []string{"wiki.broken-link", "wiki.orphan-page", "wiki.changelog-desync"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } + // The index and the changelog are never orphans: the index is the entry point, and + // the log is a log rather than a page. + set, _ := Wiki(root) + for _, f := range set.Sorted() { + if f.Rule == "wiki.orphan-page" && !strings.Contains(f.File, "unlinked") { + t.Errorf("orphan reported against %s, want only the unlinked page", f.File) + } + } +} + +// Reachability is transitive: a page linked from a page linked from the index is not an +// orphan. +func TestWikiReachabilityIsTransitive(t *testing.T) { + root := t.TempDir() + wiki := paths.Wiki(root) + write(t, filepath.Join(wiki, paths.WikiIndex), "# Index\n\n- [[one]]\n") + write(t, filepath.Join(wiki, "one.md"), "# One\n\nsee [[two]]\n") + write(t, filepath.Join(wiki, "two.md"), "# Two\n\nsee [[three|the third]]\n") + write(t, filepath.Join(wiki, "three.md"), "# Three\n\nend of the chain\n") + write(t, filepath.Join(wiki, paths.WikiLog), "# Changelog\n") + + if got := runValidator(t, Wiki, root); len(got) != 0 { + t.Errorf("a fully reachable wiki produced findings: %v", got) + } +} + +func TestWikiMissingIndexAndChangelog(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(paths.Wiki(root), "lonely.md"), "# Lonely\n") + got := runValidator(t, Wiki, root) + for _, want := range []string{"wiki.missing-index", "wiki.missing-changelog"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } +} + +// raw/ is a drop box, not storage: a file still sitting there was collected and never +// processed. A dotfile is how an empty directory gets committed, which is the opposite. +func TestRawIsADropBox(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(paths.Raw(root), "paper.pdf.md"), "raw notes\n") + write(t, filepath.Join(paths.Raw(root), ".gitkeep"), "") + got := runValidator(t, Wiki, root) + if !contains(got, "wiki.unprocessed-source") { + t.Errorf("rules = %v, want wiki.unprocessed-source", got) + } + if len(got) != 1 { + t.Errorf("rules = %v, want exactly one — the dotfile is not unprocessed work", got) + } +} + +func TestADRChecks(t *testing.T) { + root := t.TempDir() + adr := paths.ADR(root) + write(t, filepath.Join(adr, "0001-use-sqlite.md"), "---\nstatus: superseded\nsuperseded-by: 0002-use-redis\n---\n\n# 0001 · SQLite\n") + write(t, filepath.Join(adr, "0002-use-redis.md"), "---\nstatus: accepted\n---\n\n# 0002 · Redis\n\nreplaces adr:0001-use-sqlite\n") + if got := runValidator(t, ADR, root); len(got) != 0 { + t.Errorf("a conforming ADR set produced findings: %v", got) + } +} + +// A superseded record is marked, never edited — and the mark has to be complete in both +// directions or it records nothing. +func TestADRSupersedingMustBeComplete(t *testing.T) { + root := t.TempDir() + adr := paths.ADR(root) + write(t, filepath.Join(adr, "0001-a.md"), "---\nstatus: superseded\n---\n\n# 0001\n") + write(t, filepath.Join(adr, "0002-b.md"), "---\nstatus: accepted\nsuperseded-by: 0009-ghost\n---\n\n# 0002\n") + got := runValidator(t, ADR, root) + for _, want := range []string{"adr.superseded-without-successor", "adr.successor-without-status", "adr.unknown-successor"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } +} + +// Unlike requirement numbering, an ADR gap means a record went missing: ADRs are never +// removed, so there is no legitimate hole. +func TestADRNumberingAndNames(t *testing.T) { + root := t.TempDir() + adr := paths.ADR(root) + write(t, filepath.Join(adr, "0001-first.md"), "---\nstatus: accepted\n---\n\n# 0001\n") + write(t, filepath.Join(adr, "0003-third.md"), "---\nstatus: accepted\n---\n\n# 0003\n") + write(t, filepath.Join(adr, "notes.md"), "---\nstatus: accepted\n---\n\n# not an ADR name\n") + got := runValidator(t, ADR, root) + for _, want := range []string{"adr.numbering-gap", "adr.malformed-filename"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } + // One gap finding, not one per record after the gap. + if n := strings.Count(strings.Join(got, " "), "adr.numbering-gap"); n != 1 { + t.Errorf("numbering-gap reported %d times, want 1", n) + } +} + +// A citation to a record that does not exist is broken whether or not docs/adr/ has any +// records at all, so it is reported either way. +func TestADRCitationsAreCheckedWithNoRecords(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(paths.Wiki(root), "page.md"), "# Page\n\ndecided in adr:0003-nowhere\n") + if got := runValidator(t, ADR, root); !contains(got, "adr.unknown-citation") { + t.Errorf("rules = %v, want adr.unknown-citation", got) + } +} + +func TestADRStatusAndCitations(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(paths.ADR(root), "0001-first.md"), "# 0001\n\nno frontmatter status\n") + write(t, filepath.Join(paths.Wiki(root), "page.md"), "# Page\n\ndecided in adr:0007-does-not-exist\n") + got := runValidator(t, ADR, root) + for _, want := range []string{"adr.missing-status", "adr.unknown-citation"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } +} + +func TestGlossaryChecks(t *testing.T) { + root := t.TempDir() + write(t, paths.Glossary(root), `# Glossary + +- **order total** — the amount charged, in minor units. Avoid: grand total, sum +- **workspace** — a directory holding the manifest. Avoid: project root +`) + write(t, filepath.Join(paths.Wiki(root), "page.md"), `# Page + +The grand total is computed here. + +The summary assumes a consumer of the order total. +`) + got := runValidator(t, Glossary, root) + if !contains(got, "glossary.avoided-synonym") { + t.Errorf("rules = %v, want glossary.avoided-synonym for 'grand total'", got) + } + // Whole-word matching only. "sum" inside "summary", "assumes", and "consumer" must + // not fire — substring matching here is a false-positive generator, and one wrong + // finding costs the user's trust in all eight validators. + if n := len(got); n != 1 { + set, _ := Glossary(root) + t.Errorf("rules = %v, want exactly one; findings: %+v", got, set.Sorted()) + } +} + +func TestGlossaryDuplicateAndContradiction(t *testing.T) { + root := t.TempDir() + write(t, paths.Glossary(root), `# Glossary + +- **order total** — the amount charged. Avoid: sum +- **order total** — defined twice +- **sum** — also canonical, which contradicts the entry above +`) + got := runValidator(t, Glossary, root) + for _, want := range []string{"glossary.duplicate-term", "glossary.synonym-is-canonical"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } +} + +func TestStackChecks(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "go.mod"), `module example.com/app + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.0.0 + github.com/undocumented/thing v1.0.0 + golang.org/x/sync v0.7.0 // indirect +) +`) + write(t, paths.Stack(root), "# Stack\n\n- chi — the router, because net/http's mux could not do it\n") + got := runValidator(t, Stack, root) + if !contains(got, "stack.undocumented-dependency") { + t.Errorf("rules = %v, want stack.undocumented-dependency", got) + } + // chi is documented by its base name, and the indirect dependency is not a decision + // anybody made — so exactly one finding. + if len(got) != 1 { + set, _ := Stack(root) + t.Errorf("rules = %v, want exactly one; findings: %+v", got, set.Sorted()) + } +} + +func TestStackReadsPackageJSON(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "package.json"), `{ + "dependencies": {"react": "^18.0.0"}, + "devDependencies": {"vitest": "^1.0.0"} +}`) + write(t, paths.Stack(root), "# Stack\n\n- react — the UI layer\n") + got := runValidator(t, Stack, root) + if len(got) != 1 || !contains(got, "stack.undocumented-dependency") { + t.Errorf("rules = %v, want one undocumented dependency (vitest)", got) + } +} + +// A manifest scc cannot parse produces no findings. It is not scc's place to report on +// the syntax of somebody else's build file, and a guess here is a finding that is wrong. +func TestStackIsSilentOnAnUnparseableManifest(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "package.json"), "{ not json") + if got := runValidator(t, Stack, root); len(got) != 0 { + t.Errorf("rules = %v, want silence on an unparseable manifest", got) + } +} + +func TestStackMissingFile(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "go.mod"), "module example.com/app\n\nrequire github.com/a/b v1.0.0\n") + got := runValidator(t, Stack, root) + if len(got) != 1 || !contains(got, "stack.missing") { + t.Errorf("rules = %v, want exactly stack.missing", got) + } +} + +func TestCodewikiCitations(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "main.go"), "one\ntwo\nthree\nfour\nfive\n") + write(t, filepath.Join(paths.Codewiki(root), "app.md"), `# App + +## How it starts + +[main.go:1-3]() + +Prose about the start. + +## How it ends + +[main.go:4-99]() + +## What it cites nowhere + +Prose with no citation. + +## Where the file went + +[gone.go:1-2]() +`) + got := runValidator(t, Codewiki, root) + for _, want := range []string{"codewiki.citation-out-of-range", "codewiki.section-cites-nothing", "codewiki.citation-unresolved"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } + // The resolving citation produced nothing, and the page title (level 1) is not a + // section that owes a citation. + if len(got) != 3 { + set, _ := Codewiki(root) + t.Errorf("rules = %v, want exactly three; findings: %+v", got, set.Sorted()) + } +} + +func TestCodewikiHeadingsAndRanges(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "main.go"), "one\ntwo\n") + write(t, filepath.Join(paths.Codewiki(root), "app.md"), `# App + +## Notes + +[main.go:2-1]() + +## Notes + +[main.go:1]() +`) + got := runValidator(t, Codewiki, root) + for _, want := range []string{"codewiki.duplicate-heading", "codewiki.citation-invalid"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } +} + +// An ordinary link is not a citation, and a citation-shaped example in a fence is not +// either. +func TestCodewikiIgnoresWhatIsNotACitation(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "main.go"), "one\n") + write(t, filepath.Join(paths.Codewiki(root), "app.md"), "# App\n\n## Section\n\n"+ + "[main.go:1]() and [a real link](https://example.com) and [another](../wiki/index.md)\n\n"+ + "```\n[does-not-exist.go:1-5]()\n```\n") + if got := runValidator(t, Codewiki, root); len(got) != 0 { + t.Errorf("rules = %v, want silence", got) + } +} + +func TestEverythingMergesAndCounts(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(paths.Wiki(root), "lonely.md"), "# Lonely\n") + writeSpec(t, root, "billing", goodRequirements, goodDesign, "# tasks\n\n- [ ] 1.1 No methodology — R1.1\n") + + set, results, err := Everything(root) + if err != nil { + t.Fatalf("Everything: %v", err) + } + if len(results) != len(All()) { + t.Fatalf("results = %+v, want one per validator", results) + } + byName := map[string]int{} + total := 0 + for _, r := range results { + byName[r.Name] = r.Findings + total += r.Findings + } + if byName["wiki"] == 0 || byName["spec"] == 0 { + t.Errorf("per-validator counts wrong: %+v", results) + } + if total != set.Len() { + t.Errorf("per-validator counts sum to %d, merged set has %d", total, set.Len()) + } + if set.ExitCode() != finding.ExitFindings { + t.Errorf("exit = %d, want %d", set.ExitCode(), finding.ExitFindings) + } + // Results are named and ordered, so two runs report identically. + for i := 1; i < len(results); i++ { + if results[i-1].Name >= results[i].Name { + t.Fatalf("results not sorted by name: %+v", results) + } + } +} diff --git a/internal/validate/plan.go b/internal/validate/plan.go new file mode 100644 index 0000000..2044c6b --- /dev/null +++ b/internal/validate/plan.go @@ -0,0 +1,120 @@ +package validate + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/mdscan" + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// specReferenceRe matches a reference to a spec from inside a plan: `specs//`, +// with or without backticks around it. +var specReferenceRe = regexp.MustCompile(`\b` + paths.SpecsSeg + `/([a-z0-9][a-z0-9-]*)/?`) + +// Plans validates every plan under plans/. +func Plans(root string) (*finding.Set, error) { + set := &finding.Set{} + entries, err := os.ReadDir(paths.Plans(root)) + if err != nil { + if os.IsNotExist(err) { + return set, nil + } + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + names = append(names, strings.TrimSuffix(e.Name(), ".md")) + } + sort.Strings(names) + for _, name := range names { + one, err := Plan(root, name) + if err != nil { + return nil, err + } + set.Extend(one) + } + return set, nil +} + +// Plan validates one plan. +// +// Three checks, and the third is the one that matters: **one source of truth per +// item.** An item either carries a checkbox — it is a task, and the box is its state — +// or it references a spec, whose state is read from that spec. An item that does both +// keeps two records of one fact, and the copy is the one that goes stale. +func Plan(root, name string) (*finding.Set, error) { + set := &finding.Set{} + planPath := paths.Plan(root, name) + if !isFile(planPath) { + return nil, fmt.Errorf("no plan %q under %s", name, paths.PlansSeg) + } + file := rel(root, planPath) + + doc, err := read(root, planPath) + if err != nil { + if doc == nil { + return nil, err + } + set.Addf(file, 1, "plan.frontmatter-unreadable", "%v", err) + return set, nil + } + checkKickoffAs(set, file, doc.Frontmatter, "plan.kickoff-invalid") + + // A plan's tasks carry no requirement citations: a plan has no requirements. The + // rest of the grammar is identical, because the methodology is a property of the + // task rather than of the vehicle that carried it. + tasks := parseTasks(set, file, doc, false) + + checkboxLines := map[int]bool{} + for _, t := range tasks { + checkboxLines[t.Line] = true + } + + referenced := 0 + for i, line := range doc.Body { + matches := specReferenceRe.FindAllStringSubmatch(line, -1) + if len(matches) == 0 { + continue + } + referenced += len(matches) + num := i + 1 + if checkboxLines[num] { + set.Addf(file, num, "plan.item-has-two-records", + "this item both carries a checkbox and references a spec; the state has to live in exactly one place") + } + for _, m := range matches { + if !isDir(paths.Spec(root, m[1])) { + set.Addf(file, num, "plan.unknown-spec", + "%s/%s/ does not exist", paths.SpecsSeg, m[1]) + } + } + } + + if len(tasks) == 0 && referenced == 0 { + set.Addf(file, 0, "plan.empty", + "a plan holds a checklist of tasks, references to the specs it decomposes into, or both — this has neither") + } + return set, nil +} + +// checkKickoffAs is checkKickoff with the rule slug the caller's subject uses, so a +// plan's finding reads `plan.` and a spec's reads `spec.` for the same defect. +func checkKickoffAs(set *finding.Set, file string, fm mdscan.Frontmatter, rule string) { + for _, key := range []string{"autonomy", "ci"} { + value, ok := fm.Get(key) + if !ok { + continue + } + if !kickoffValues[key][value] { + set.Addf(file, 1, rule, "`%s: %s` is not one of %s", key, value, allowed(kickoffValues[key])) + } + } +} diff --git a/internal/validate/plan_test.go b/internal/validate/plan_test.go new file mode 100644 index 0000000..de3313d --- /dev/null +++ b/internal/validate/plan_test.go @@ -0,0 +1,157 @@ +package validate + +import ( + "os" + "testing" + + "github.com/protonspy/spec-claude-code/internal/paths" +) + +func writePlan(t *testing.T, root, name, content string) { + t.Helper() + if err := os.MkdirAll(paths.Plans(root), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(paths.Plan(root, name), []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +func planFindings(t *testing.T, root, name string) []string { + t.Helper() + set, err := Plan(root, name) + if err != nil { + t.Fatalf("Plan: %v", err) + } + return rules(set) +} + +func TestConformingPlanHasNoFindings(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "cart-totals", goodRequirements, goodDesign, goodTasks) + writePlan(t, root, "checkout-revamp", `--- +autonomy: auto +ci: wait +--- + +# Checkout revamp + +## Decomposition + +- `+"`specs/cart-totals/`"+` — the totals engine + +## Tasks + +- [ ] 1.1 (Unit) Rename the legacy endpoint +- [ ] 1.2 (TDD) Migrate the rounding helper +`) + if got := planFindings(t, root, "checkout-revamp"); len(got) != 0 { + t.Errorf("a conforming plan produced findings: %v", got) + } +} + +// One source of truth per item. An item that both carries a checkbox and references a +// spec keeps two records of one fact, and the copy is the one that goes stale. +func TestItemCannotBothCheckAndReference(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "cart-totals", goodRequirements, goodDesign, goodTasks) + writePlan(t, root, "checkout-revamp", `# Checkout revamp + +- [ ] 1.1 (Unit) Build `+"`specs/cart-totals/`"+` +`) + if got := planFindings(t, root, "checkout-revamp"); !contains(got, "plan.item-has-two-records") { + t.Errorf("rules = %v, want plan.item-has-two-records", got) + } +} + +func TestPlanReferencesMustResolve(t *testing.T) { + root := t.TempDir() + writePlan(t, root, "checkout-revamp", `# Checkout revamp + +## Decomposition + +- specs/does-not-exist/ — nothing is here +`) + if got := planFindings(t, root, "checkout-revamp"); !contains(got, "plan.unknown-spec") { + t.Errorf("rules = %v, want plan.unknown-spec", got) + } +} + +// A plan's tasks carry no requirement citations — a plan has no requirements — but the +// rest of the grammar is identical, because the methodology belongs to the task and not +// to the vehicle that carried it. +func TestPlanTasksUseTheSameGrammarWithoutCitations(t *testing.T) { + root := t.TempDir() + writePlan(t, root, "sweep", `# Sweep + +- [ ] 1.1 (Unit) A task with no citation, which is fine here +- [ ] 1.2 A task with no methodology, which is not +`) + got := planFindings(t, root, "sweep") + if !contains(got, "task.missing-methodology") { + t.Errorf("rules = %v, want task.missing-methodology", got) + } + if contains(got, "task.missing-requirement") { + t.Errorf("rules = %v, want no requirement citation demanded of a plan", got) + } +} + +// A plan that is neither a checklist nor a decomposition records nothing, and recording +// the work is the only reason both vehicles write a file. +func TestEmptyPlan(t *testing.T) { + root := t.TempDir() + writePlan(t, root, "hollow", "# Hollow\n\nSome prose and no items at all.\n") + if got := planFindings(t, root, "hollow"); !contains(got, "plan.empty") { + t.Errorf("rules = %v, want plan.empty", got) + } +} + +func TestPlanKickoffAnswers(t *testing.T) { + root := t.TempDir() + writePlan(t, root, "sweep", "---\nci: eventually\n---\n\n# Sweep\n\n- [ ] 1.1 (Unit) Do it\n") + if got := planFindings(t, root, "sweep"); !contains(got, "plan.kickoff-invalid") { + t.Errorf("rules = %v, want plan.kickoff-invalid", got) + } +} + +// A spec path inside a fenced block is an example, not a reference. +func TestPlanExamplesAreNotReferences(t *testing.T) { + root := t.TempDir() + writePlan(t, root, "documented", "# Documented\n\n- [ ] 1.1 (Unit) Real work\n\n"+ + "```\n- specs/an-example/ — from the docs\n```\n\n\n") + if got := planFindings(t, root, "documented"); len(got) != 0 { + t.Errorf("examples were treated as references: %v", got) + } +} + +func TestPlansValidatesEveryPlan(t *testing.T) { + root := t.TempDir() + writePlan(t, root, "zebra", "# Zebra\n\n- [ ] 1.1 No methodology\n") + writePlan(t, root, "alpha", "# Alpha\n\n- [ ] 1.1 No methodology\n") + set, err := Plans(root) + if err != nil { + t.Fatalf("Plans: %v", err) + } + if len(set.Sorted()) != 2 { + t.Fatalf("findings = %v, want one per plan", rules(set)) + } + if got := set.Sorted()[0].File; got != "plans/alpha.md" { + t.Errorf("first finding is in %s, want plans/alpha.md", got) + } +} + +func TestPlansOnAnEmptyWorkspaceIsSilent(t *testing.T) { + set, err := Plans(t.TempDir()) + if err != nil { + t.Fatalf("Plans: %v", err) + } + if !set.Empty() { + t.Errorf("an empty workspace produced findings: %v", rules(set)) + } +} + +func TestPlanOnAnUnknownNameIsAnError(t *testing.T) { + if _, err := Plan(t.TempDir(), "ghost"); err == nil { + t.Error("validating a plan that does not exist returned no error") + } +} diff --git a/internal/validate/skill.go b/internal/validate/skill.go new file mode 100644 index 0000000..3a8cbcd --- /dev/null +++ b/internal/validate/skill.go @@ -0,0 +1,194 @@ +package validate + +import ( + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/mdscan" + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// The Agent Skills contract, from the published specification at +// agentskills.io/specification. These numbers are quoted from it, not chosen here: +// the value of this validator is that it enforces somebody else's standard — one +// that tools across competing vendors read — and a limit scc invented would forfeit +// exactly that. +const ( + skillNameMax = 64 // "Must be 1-64 characters" + skillDescriptionMax = 1024 // "Must be 1-1024 characters" + skillCompatibilityMax = 500 // "Must be 1-500 characters if provided" + skillBodyMaxLines = 500 // "Keep your main SKILL.md under 500 lines" +) + +// skillNameRe is the name charset: lowercase alphanumerics and hyphens, no leading +// or trailing hyphen, no consecutive hyphens. +var skillNameRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +// skillFile is the file the spec requires in every skill directory. +const skillFile = "SKILL.md" + +// Skills validates every skill under .claude/skills/. +// +// Implemented in Go rather than by delegating to the reference `skills-ref` +// validator: scc is a single binary on six platforms, and shelling out to Node for a +// handful of regexes would break that. +// +// A workspace with no skills directory is not a workspace with findings — it is a +// workspace with no skills. +func Skills(root string) (*finding.Set, error) { + set := &finding.Set{} + dir := paths.Skills(root) + if !isDir(dir) { + return set, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + for _, name := range names { + if err := skill(set, root, filepath.Join(dir, name), name); err != nil { + return nil, err + } + } + return set, nil +} + +func skill(set *finding.Set, root, dir, dirName string) error { + manifestPath := filepath.Join(dir, skillFile) + if !isFile(manifestPath) { + set.Addf(rel(root, dir), 0, "skill.missing-skill-md", + "a skill directory must contain %s", skillFile) + return nil + } + file := rel(root, manifestPath) + + doc, err := read(root, manifestPath) + if err != nil { + if doc == nil { + return err + } + // The frontmatter could not be read. Report that and stop: every other check + // depends on fields this one could not extract, and guessing at them is how a + // validator produces findings about a file that was fine. + set.Addf(file, 1, "skill.frontmatter-unreadable", "%v", err) + return nil + } + fm := doc.Frontmatter + if !fm.Present { + set.Addf(file, 1, "skill.missing-frontmatter", + "%s must open with YAML frontmatter carrying `name` and `description`", skillFile) + return nil + } + + checkSkillName(set, file, fm, dirName) + checkSkillDescription(set, file, fm) + + if v, ok := fm.Get("compatibility"); ok && len(v) > skillCompatibilityMax { + set.Addf(file, 1, "skill.compatibility-too-long", + "`compatibility` is %d characters; the spec allows %d", len(v), skillCompatibilityMax) + } + + // The body budget is a SHOULD in the spec, and it is reported as one: the agent + // loads the whole file once the skill activates, so length is a real cost, but + // exceeding it is a recommendation missed rather than a broken contract. + if body := len(doc.Lines) - fm.Lines; body > skillBodyMaxLines { + set.Addf(file, fm.Lines+skillBodyMaxLines+1, "skill.body-too-long", + "the body is %d lines; the spec recommends keeping %s under %d and moving detail into references/", + body, skillFile, skillBodyMaxLines) + } + + checkSkillReferences(set, dir, file, doc) + return nil +} + +func checkSkillName(set *finding.Set, file string, fm mdscan.Frontmatter, dirName string) { + name, ok := fm.Get("name") + if !ok || name == "" { + set.Addf(file, 1, "skill.missing-name", "`name` is required and must be 1-%d characters", skillNameMax) + return + } + if len(name) > skillNameMax { + set.Addf(file, 1, "skill.name-too-long", + "`name` is %d characters; the spec allows 1-%d", len(name), skillNameMax) + } + if !skillNameRe.MatchString(name) { + set.Addf(file, 1, "skill.name-invalid", + "`name` %q must be lowercase alphanumerics and hyphens, with no leading, trailing, or consecutive hyphen", name) + } + // The one that actually breaks loading: a mismatched name means the skill does + // not load at all, in any tool that reads this format. + if name != dirName { + set.Addf(file, 1, "skill.name-mismatch", + "`name` is %q but the directory is %q; they must match or the skill will not load", name, dirName) + } +} + +func checkSkillDescription(set *finding.Set, file string, fm mdscan.Frontmatter) { + desc, ok := fm.Get("description") + if !ok || strings.TrimSpace(desc) == "" { + set.Addf(file, 1, "skill.missing-description", + "`description` is required: say what the skill does and when to use it") + return + } + if len(desc) > skillDescriptionMax { + set.Addf(file, 1, "skill.description-too-long", + "`description` is %d characters; the spec allows 1-%d", len(desc), skillDescriptionMax) + } +} + +// checkSkillReferences holds the two rules about bundled files: a reference resolves, +// and it is one level deep. +// +// Only relative links are considered. A URL, an anchor, and an absolute path are all +// things this validator cannot resolve, and a check that cannot resolve its subject +// says nothing. +func checkSkillReferences(set *finding.Set, dir, file string, doc *mdscan.Document) { + for _, link := range doc.Links { + target := link.Target + if !isLocalReference(target) { + continue + } + target = strings.SplitN(target, "#", 2)[0] + if target == "" { + continue + } + if strings.Count(strings.Trim(path.Clean(target), "/"), "/") > 1 { + set.Addf(file, link.Line, "skill.reference-too-deep", + "%q is more than one level from %s; the spec asks for shallow references", target, skillFile) + } + resolved := filepath.Join(dir, filepath.FromSlash(target)) + if !exists(resolved) { + set.Addf(file, link.Line, "skill.broken-reference", "%q does not exist", target) + } + } +} + +// isLocalReference reports whether a link target is a path inside the skill — +// the only kind this validator can check. +func isLocalReference(target string) bool { + switch { + case target == "": + return false + case strings.HasPrefix(target, "#"): // an anchor within this file + return false + case strings.HasPrefix(target, "/"): // absolute: outside the skill + return false + case strings.HasPrefix(target, ".."): // escapes the skill; not scc's to judge + return false + case strings.Contains(target, "://"), strings.HasPrefix(target, "mailto:"): + return false + } + return true +} diff --git a/internal/validate/skill_test.go b/internal/validate/skill_test.go new file mode 100644 index 0000000..56e3c9f --- /dev/null +++ b/internal/validate/skill_test.go @@ -0,0 +1,296 @@ +package validate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// writeSkill lays out .claude/skills//SKILL.md with the given content. +func writeSkill(t *testing.T, root, name, content string) string { + t.Helper() + dir := filepath.Join(paths.Skills(root), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + path := filepath.Join(dir, "SKILL.md") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return dir +} + +func skills(t *testing.T, root string) *finding.Set { + t.Helper() + set, err := Skills(root) + if err != nil { + t.Fatalf("Skills: %v", err) + } + return set +} + +// rules returns the rule slugs of every finding, which is what a CI job filters on +// and therefore what these tests assert against rather than on prose. +func rules(set *finding.Set) []string { + var out []string + for _, f := range set.Sorted() { + out = append(out, f.Rule) + } + return out +} + +func hasRule(set *finding.Set, rule string) bool { + for _, r := range rules(set) { + if r == rule { + return true + } + } + return false +} + +func TestValidSkillHasNoFindings(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "pdf-processing", `--- +name: pdf-processing +description: Extracts text and tables from PDF files. Use when working with PDFs. +license: Apache-2.0 +metadata: + author: example-org + version: "1.0" +--- + +# PDF processing + +Step-by-step instructions here. +`) + set := skills(t, root) + if !set.Empty() { + t.Errorf("a spec-conforming skill produced findings: %v", rules(set)) + } +} + +// A workspace with no skills is not a workspace with findings. +func TestNoSkillsDirectoryIsSilent(t *testing.T) { + set := skills(t, t.TempDir()) + if !set.Empty() { + t.Errorf("an empty workspace produced findings: %v", rules(set)) + } + if set.ExitCode() != finding.ExitOK { + t.Errorf("exit = %d, want %d", set.ExitCode(), finding.ExitOK) + } +} + +// The check that matters most: a mismatched name means the skill does not load at +// all, in any tool that reads this format. +func TestNameMustMatchTheDirectory(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "pdf-processing", `--- +name: pdf-tools +description: Does things with PDFs. Use when handling PDFs. +--- + +Body. +`) + set := skills(t, root) + if !hasRule(set, "skill.name-mismatch") { + t.Errorf("rules = %v, want skill.name-mismatch", rules(set)) + } + // The message has to name both halves, or the user cannot tell which to change. + msg := set.Sorted()[0].Message + if !strings.Contains(msg, "pdf-tools") || !strings.Contains(msg, "pdf-processing") { + t.Errorf("message = %q, want both names", msg) + } + if set.ExitCode() != finding.ExitFindings { + t.Errorf("exit = %d, want %d", set.ExitCode(), finding.ExitFindings) + } +} + +func TestNameCharsetRules(t *testing.T) { + cases := map[string]bool{ // name -> should be reported invalid + "pdf-processing": false, + "data-analysis2": false, + "a": false, + "PDF-Processing": true, // uppercase + "-pdf": true, // leading hyphen + "pdf-": true, // trailing hyphen + "pdf--processing": true, // consecutive hyphens + "pdf_processing": true, // underscore + "pdf processing": true, // space + } + for name, wantInvalid := range cases { + root := t.TempDir() + // The directory carries the same name, so name-mismatch cannot mask the + // charset finding this case is about. + writeSkill(t, root, name, "---\nname: "+name+"\ndescription: A skill. Use it when testing.\n---\n\nBody.\n") + set := skills(t, root) + if got := hasRule(set, "skill.name-invalid"); got != wantInvalid { + t.Errorf("%q: name-invalid = %v, want %v (rules: %v)", name, got, wantInvalid, rules(set)) + } + } +} + +func TestRequiredFieldsAreRequired(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "no-name", "---\ndescription: A skill. Use it when testing.\n---\n\nBody.\n") + if !hasRule(skills(t, root), "skill.missing-name") { + t.Error("a skill with no name produced no missing-name finding") + } + + root = t.TempDir() + writeSkill(t, root, "no-description", "---\nname: no-description\n---\n\nBody.\n") + if !hasRule(skills(t, root), "skill.missing-description") { + t.Error("a skill with no description produced no missing-description finding") + } + + root = t.TempDir() + writeSkill(t, root, "blank-description", "---\nname: blank-description\ndescription: \" \"\n---\n\nBody.\n") + if !hasRule(skills(t, root), "skill.missing-description") { + t.Error("a whitespace-only description was accepted") + } +} + +func TestLengthLimitsComeFromTheSpec(t *testing.T) { + root := t.TempDir() + long := strings.Repeat("a", skillDescriptionMax+1) + writeSkill(t, root, "wordy", "---\nname: wordy\ndescription: "+long+"\n---\n\nBody.\n") + set := skills(t, root) + if !hasRule(set, "skill.description-too-long") { + t.Errorf("rules = %v, want skill.description-too-long", rules(set)) + } + if !strings.Contains(set.Sorted()[0].Message, "1024") { + t.Errorf("message = %q, want it to quote the spec's limit", set.Sorted()[0].Message) + } + + // One character under is fine: an off-by-one here is a false positive on a valid + // skill, which costs more than a miss. + root = t.TempDir() + writeSkill(t, root, "wordy", "---\nname: wordy\ndescription: "+strings.Repeat("a", skillDescriptionMax)+"\n---\n\nBody.\n") + if !skills(t, root).Empty() { + t.Errorf("a description exactly at the limit was reported: %v", rules(skills(t, root))) + } +} + +func TestCompatibilityLimit(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "picky", "---\nname: picky\ndescription: A skill. Use when testing.\ncompatibility: "+ + strings.Repeat("x", skillCompatibilityMax+1)+"\n---\n\nBody.\n") + if !hasRule(skills(t, root), "skill.compatibility-too-long") { + t.Error("an over-long compatibility field was accepted") + } +} + +// The body budget is a recommendation in the spec and is reported as one — but it is +// still deterministic, so it is a finding rather than silence. +func TestBodyBudget(t *testing.T) { + root := t.TempDir() + head := "---\nname: verbose\ndescription: A skill. Use when testing.\n---\n" + writeSkill(t, root, "verbose", head+strings.Repeat("line\n", skillBodyMaxLines+2)) + set := skills(t, root) + if !hasRule(set, "skill.body-too-long") { + t.Errorf("rules = %v, want skill.body-too-long", rules(set)) + } + + root = t.TempDir() + writeSkill(t, root, "verbose", head+strings.Repeat("line\n", skillBodyMaxLines-1)) + if !skills(t, root).Empty() { + t.Error("a body under the budget was reported") + } +} + +func TestMissingSkillMD(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(paths.Skills(root), "empty-dir"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + set := skills(t, root) + if !hasRule(set, "skill.missing-skill-md") { + t.Errorf("rules = %v, want skill.missing-skill-md", rules(set)) + } +} + +func TestMissingFrontmatter(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "bare", "# Just a heading\n\nNo frontmatter at all.\n") + if !hasRule(skills(t, root), "skill.missing-frontmatter") { + t.Error("a SKILL.md with no frontmatter was accepted") + } +} + +// Frontmatter scc cannot read produces one finding saying so, not a pile of findings +// about fields it failed to extract. +func TestUnreadableFrontmatterReportsOnce(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "weird", "---\ntools:\n - Read\n---\n\nBody.\n") + set := skills(t, root) + if got := rules(set); len(got) != 1 || got[0] != "skill.frontmatter-unreadable" { + t.Errorf("rules = %v, want exactly [skill.frontmatter-unreadable]", got) + } +} + +func TestReferenceChecks(t *testing.T) { + root := t.TempDir() + dir := writeSkill(t, root, "refs", `--- +name: refs +description: A skill with references. Use when testing. +--- + +See [the reference](references/REFERENCE.md) for details. +And [the deep one](references/nested/deeper/DETAIL.md). +And [the missing one](references/GONE.md). +Also [an external link](https://example.com/page) and [an anchor](#section). +`) + if err := os.MkdirAll(filepath.Join(dir, "references", "nested", "deeper"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + for _, f := range []string{ + filepath.Join(dir, "references", "REFERENCE.md"), + filepath.Join(dir, "references", "nested", "deeper", "DETAIL.md"), + } { + if err := os.WriteFile(f, []byte("x\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + } + + set := skills(t, root) + if !hasRule(set, "skill.reference-too-deep") { + t.Errorf("rules = %v, want skill.reference-too-deep", rules(set)) + } + if !hasRule(set, "skill.broken-reference") { + t.Errorf("rules = %v, want skill.broken-reference", rules(set)) + } + // A URL and an anchor are things this validator cannot resolve, and a check that + // cannot resolve its subject says nothing. + if n := len(rules(set)); n != 2 { + t.Errorf("rules = %v, want exactly the two resolvable problems", rules(set)) + } +} + +// A reference inside a fenced block is an example, not a reference. This is the +// scanner's job, and it is worth asserting from here too: it is the difference +// between a validator the user trusts and one they mute. +func TestExamplesInFencesAreNotReferences(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "documented", "---\nname: documented\ndescription: A skill. Use when testing.\n---\n\n"+ + "```markdown\nSee [the reference](references/DOES-NOT-EXIST.md)\n```\n") + if set := skills(t, root); !set.Empty() { + t.Errorf("an example inside a fence was treated as a reference: %v", rules(set)) + } +} + +// Findings arrive sorted by file, so two skills report in a stable order. +func TestFindingsAreOrderedAcrossSkills(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "zebra", "---\nname: wrong\ndescription: A skill. Use when testing.\n---\n\nBody.\n") + writeSkill(t, root, "alpha", "---\nname: wrong\ndescription: A skill. Use when testing.\n---\n\nBody.\n") + got := skills(t, root).Sorted() + if len(got) != 2 { + t.Fatalf("findings = %d, want 2", len(got)) + } + if !strings.Contains(got[0].File, "alpha") { + t.Errorf("first finding is %s, want the alpha skill", got[0].File) + } +} diff --git a/internal/validate/spec.go b/internal/validate/spec.go new file mode 100644 index 0000000..155c57f --- /dev/null +++ b/internal/validate/spec.go @@ -0,0 +1,269 @@ +package validate + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" + + "github.com/protonspy/spec-claude-code/internal/ears" + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// requirementRe matches a numbered requirement, and only in that position: +// +// - **R1.2** (MODIFIED) When the manifest is missing, the CLI shall exit 1 +// +// Prose elsewhere in requirements.md is not a requirement and gets no findings. The +// alternative — parsing every sentence as EARS — is a validator that fires on the +// document's own introduction, which is exactly how a tool teaches its user to +// disbelieve it. +var requirementRe = regexp.MustCompile(`^\s*[-*+]\s+\*\*(R\d+(?:\.\d+)+)\*\*\s*(?:\(([^)]*)\)\s*)?(.*)$`) + +// The delta markers a change to an existing spec is written with. A change is +// proposed as a delta so that adopting scc does not mean writing the spec for +// everything that already works, and so two sessions can change one spec as long as +// they touch different requirements. +var deltaMarkers = map[string]bool{"ADDED": true, "MODIFIED": true, "REMOVED": true} + +// The kickoff answers, validated only when present. A missing key means the run +// predates the convention, not that the user did something wrong. +var kickoffValues = map[string]map[string]bool{ + "autonomy": {"auto": true, "gated": true}, + "ci": {"wait": true, "no-wait": true}, +} + +// requirement is one parsed requirement line. +type requirement struct { + ID string + Line int + Delta string // "", "ADDED", "MODIFIED", "REMOVED" + Text string +} + +// Specs validates every spec under specs/. A workspace with no specs is not a +// workspace with findings. +func Specs(root string) (*finding.Set, error) { + set := &finding.Set{} + entries, err := os.ReadDir(paths.Specs(root)) + if err != nil { + if os.IsNotExist(err) { + return set, nil + } + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + for _, name := range names { + one, err := Spec(root, name) + if err != nil { + return nil, err + } + set.Extend(one) + } + return set, nil +} + +// Spec validates one feature's three artifacts. +func Spec(root, feature string) (*finding.Set, error) { + set := &finding.Set{} + dir := paths.Spec(root, feature) + if !isDir(dir) { + return nil, fmt.Errorf("no spec %q under %s", feature, paths.SpecsSeg) + } + + reqs, ok, err := checkRequirements(set, root, feature) + if err != nil { + return nil, err + } + if err := checkDesign(set, root, feature, reqs, ok); err != nil { + return nil, err + } + if err := checkTasks(set, root, feature, reqs, ok); err != nil { + return nil, err + } + return set, nil +} + +// checkRequirements parses requirements.md and returns the requirements it found. ok +// is false when the file is missing or unreadable, which every downstream check honors +// by staying silent: a traceability finding against requirements nobody could read is +// a finding about the wrong thing. +func checkRequirements(set *finding.Set, root, feature string) (map[string]requirement, bool, error) { + path := paths.Requirements(root, feature) + file := rel(root, path) + if !isFile(path) { + set.Addf(file, 0, "spec.missing-requirements", + "a spec needs %s: what the feature must do, in EARS, numbered", paths.RequirementsSeg) + return nil, false, nil + } + doc, err := read(root, path) + if err != nil { + if doc == nil { + return nil, false, err + } + set.Addf(file, 1, "spec.frontmatter-unreadable", "%v", err) + return nil, false, nil + } + checkKickoffAs(set, file, doc.Frontmatter, "spec.kickoff-invalid") + + reqs := map[string]requirement{} + for i, line := range doc.Body { + m := requirementRe.FindStringSubmatch(line) + if m == nil { + continue + } + r := requirement{ID: m[1], Line: i + 1, Delta: m[2], Text: strings.TrimSpace(m[3])} + + if prior, dup := reqs[r.ID]; dup { + set.Addf(file, r.Line, "spec.duplicate-requirement", + "%s is already defined on line %d; a task citing it would be ambiguous", r.ID, prior.Line) + continue + } + if r.Delta != "" && !deltaMarkers[strings.ToUpper(r.Delta)] { + set.Addf(file, r.Line, "spec.delta-marker-invalid", + "%q is not a delta marker; use ADDED, MODIFIED, or REMOVED", r.Delta) + } else if r.Delta != "" && r.Delta != strings.ToUpper(r.Delta) { + set.Addf(file, r.Line, "spec.delta-marker-invalid", + "delta markers are upper case: write (%s)", strings.ToUpper(r.Delta)) + } + r.Delta = strings.ToUpper(r.Delta) + + // A REMOVED requirement is a statement that it is gone. There is nothing left + // to hold to EARS, and nothing for a task to implement. + if r.Delta != "REMOVED" { + if _, err := ears.Parse(r.Text); err != nil { + set.Addf(file, r.Line, "ears.malformed", "%s: %v", r.ID, err) + } + } + reqs[r.ID] = r + } + + if len(reqs) == 0 { + set.Addf(file, 0, "spec.no-requirements", + "no numbered requirements found; write them as `- **R1.1** The shall `") + return reqs, false, nil + } + return reqs, true, nil +} + +// checkDesign holds design.md to the one thing the design says to check: that it +// exists and traces to its requirements. +// +// It deliberately does not check that any particular section is present. A required +// heading is a request for filler, and invented architecture is worse than verbose — +// the next session reads it as a decision somebody made and honors it. +func checkDesign(set *finding.Set, root, feature string, reqs map[string]requirement, reqsOK bool) error { + path := paths.Design(root, feature) + file := rel(root, path) + if !isFile(path) { + set.Addf(file, 0, "spec.missing-design", + "a spec needs %s, even when the answer is a few paragraphs", paths.DesignSeg) + return nil + } + doc, err := read(root, path) + if err != nil { + if doc == nil { + return err + } + set.Addf(file, 1, "spec.frontmatter-unreadable", "%v", err) + return nil + } + if !reqsOK { + return nil + } + + cited := map[string]bool{} + for i, line := range doc.Body { + for _, id := range requirementIDRe.FindAllString(line, -1) { + if _, ok := reqs[id]; !ok { + set.Addf(file, i+1, "spec.unknown-requirement", + "%s is not defined in %s", id, paths.RequirementsSeg) + continue + } + cited[id] = true + } + } + if len(cited) == 0 { + set.Addf(file, 0, "spec.design-cites-nothing", + "the design names no requirement it serves; cite them (R1.1) so the trace from what to how is readable") + } + return nil +} + +// checkTasks holds tasks.md to the grammar and closes the traceability loop in both +// directions: every task cites a requirement that exists, and every requirement +// reaches at least one task. +func checkTasks(set *finding.Set, root, feature string, reqs map[string]requirement, reqsOK bool) error { + path := paths.Tasks(root, feature) + file := rel(root, path) + if !isFile(path) { + set.Addf(file, 0, "spec.missing-tasks", + "a spec needs %s: the work, each task annotated (Unit) or (TDD)", paths.TasksSeg) + return nil + } + doc, err := read(root, path) + if err != nil { + if doc == nil { + return err + } + set.Addf(file, 1, "spec.frontmatter-unreadable", "%v", err) + return nil + } + + tasks := parseTasks(set, file, doc, true) + if len(tasks) == 0 { + set.Addf(file, 0, "spec.no-tasks", + "no tasks found; write them as `- [ ] 1.1 (Unit) — R1.1`") + return nil + } + if !reqsOK { + return nil + } + + covered := map[string]bool{} + for _, t := range tasks { + for _, id := range t.Requirements { + if _, ok := reqs[id]; !ok { + set.Addf(file, t.Line, "spec.unknown-requirement", + "task %s cites %s, which is not defined in %s", t.Number, id, paths.RequirementsSeg) + continue + } + covered[id] = true + } + } + + // Every requirement reaches a task. This is the direction that catches the + // requirement everyone agreed on and nobody built. + reqFile := rel(root, paths.Requirements(root, feature)) + ids := make([]string, 0, len(reqs)) + for id := range reqs { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + r := reqs[id] + if r.Delta == "REMOVED" || covered[id] { + continue + } + set.Addf(reqFile, r.Line, "spec.orphan-requirement", + "%s reaches no task in %s", id, paths.TasksSeg) + } + return nil +} + +func allowed(values map[string]bool) string { + out := make([]string, 0, len(values)) + for v := range values { + out = append(out, v) + } + sort.Strings(out) + return strings.Join(out, " | ") +} diff --git a/internal/validate/spec_test.go b/internal/validate/spec_test.go new file mode 100644 index 0000000..d88ebad --- /dev/null +++ b/internal/validate/spec_test.go @@ -0,0 +1,351 @@ +package validate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// writeSpec lays out specs// with the three artifacts. An empty string means +// "do not write this file", so the missing-artifact cases are expressible. +func writeSpec(t *testing.T, root, feature, requirements, design, tasks string) { + t.Helper() + dir := paths.Spec(root, feature) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + for seg, content := range map[string]string{ + paths.RequirementsSeg: requirements, + paths.DesignSeg: design, + paths.TasksSeg: tasks, + } { + if content == "" { + continue + } + if err := os.WriteFile(filepath.Join(dir, seg), []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + } +} + +// A spec that follows the rules, used as the baseline every negative case deviates +// from by exactly one thing. +const ( + goodRequirements = `--- +autonomy: auto +ci: wait +--- + +# Billing — requirements + +Some prose that is not a requirement and must not be parsed as one. + +## R1 · totals + +- **R1.1** The billing engine shall compute the order total +- **R1.2** When a coupon is applied, the billing engine shall recompute the total +- **R1.3** If the coupon has expired, then the billing engine shall reject it +` + goodDesign = `# Billing — design + +## What changes + +A new total calculator, serving R1.1, R1.2, and R1.3. +` + goodTasks = `# Billing — tasks + +## 1 · totals + +- [ ] 1.1 (TDD) Compute the order total — R1.1 +- [ ] 1.2 (Unit) Recompute on coupon application — R1.2 +- [x] 1.3 (TDD) Reject an expired coupon — R1.3 +` +) + +func specFindings(t *testing.T, root, feature string) []string { + t.Helper() + set, err := Spec(root, feature) + if err != nil { + t.Fatalf("Spec: %v", err) + } + return rules(set) +} + +func TestConformingSpecHasNoFindings(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", goodRequirements, goodDesign, goodTasks) + if got := specFindings(t, root, "billing"); len(got) != 0 { + t.Errorf("a conforming spec produced findings: %v", got) + } +} + +// The check the whole practice exists to produce: a task with no methodology is a task +// where nobody decided how it gets built. +func TestTaskMustCarryExactlyOneMethodology(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", goodRequirements, goodDesign, `# tasks + +- [ ] 1.1 Compute the order total — R1.1 +- [ ] 1.2 (Unit) (TDD) Recompute — R1.2 +- [ ] 1.3 (unit) Reject — R1.3 +`) + got := specFindings(t, root, "billing") + for _, want := range []string{"task.missing-methodology", "task.multiple-methodologies"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } + // A lowercase annotation is a near miss, and saying so beats "no methodology". + set, _ := Spec(root, "billing") + var sawLoose bool + for _, f := range set.Sorted() { + if strings.Contains(f.Message, "(unit)") { + sawLoose = true + } + } + if !sawLoose { + t.Error("a lowercase (unit) was not called out specifically") + } +} + +// Traceability runs both ways, and this is the direction that catches the requirement +// everyone agreed on and nobody built. +func TestOrphanRequirement(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", goodRequirements, goodDesign, `# tasks + +- [ ] 1.1 (TDD) Compute the order total — R1.1 +- [ ] 1.2 (Unit) Recompute on coupon application — R1.2 +`) + got := specFindings(t, root, "billing") + if !contains(got, "spec.orphan-requirement") { + t.Errorf("rules = %v, want spec.orphan-requirement for R1.3", got) + } + // The finding lands on the requirement, in requirements.md, not on the task list. + set, _ := Spec(root, "billing") + for _, f := range set.Sorted() { + if f.Rule == "spec.orphan-requirement" && !strings.HasSuffix(f.File, paths.RequirementsSeg) { + t.Errorf("orphan finding reported against %s, want requirements.md", f.File) + } + } +} + +func TestTaskCitingAnUnknownRequirement(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", goodRequirements, goodDesign, `# tasks + +- [ ] 1.1 (TDD) Compute — R1.1, R9.9 +- [ ] 1.2 (Unit) Recompute — R1.2 +- [ ] 1.3 (TDD) Reject — R1.3 +`) + if got := specFindings(t, root, "billing"); !contains(got, "spec.unknown-requirement") { + t.Errorf("rules = %v, want spec.unknown-requirement", got) + } +} + +func TestTaskMustCiteARequirement(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", goodRequirements, goodDesign, `# tasks + +- [ ] 1.1 (TDD) Compute the order total +- [ ] 1.2 (Unit) Recompute — R1.2 +- [ ] 1.3 (TDD) Reject — R1.3 +`) + if got := specFindings(t, root, "billing"); !contains(got, "task.missing-requirement") { + t.Errorf("rules = %v, want task.missing-requirement", got) + } +} + +func TestDuplicateIDs(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", `# requirements + +- **R1.1** The engine shall compute the total +- **R1.1** The engine shall do something else +`, "# design\n\nServes R1.1.\n", `# tasks + +- [ ] 1.1 (Unit) Compute — R1.1 +- [ ] 1.1 (Unit) Something else — R1.1 +`) + got := specFindings(t, root, "billing") + for _, want := range []string{"spec.duplicate-requirement", "task.duplicate-number"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } +} + +// Numbering gaps are deliberately NOT a finding. A requirement removed through a delta +// leaves a hole, which is legitimate — reporting it would be a false positive on the +// mechanism the design asks people to use. +func TestNumberingGapsAreNotFindings(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", `# requirements + +- **R1.1** The engine shall compute the total +- **R1.4** The engine shall round half up +`, "# design\n\nServes R1.1 and R1.4.\n", `# tasks + +- [ ] 1.1 (Unit) Compute — R1.1 +- [ ] 3.7 (TDD) Round — R1.4 +`) + if got := specFindings(t, root, "billing"); len(got) != 0 { + t.Errorf("a gap in numbering produced findings: %v", got) + } +} + +func TestEARSFindingsCarryTheRequirementID(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", `# requirements + +- **R1.1** The engine computes the total +`, "# design\n\nServes R1.1.\n", "# tasks\n\n- [ ] 1.1 (Unit) Compute — R1.1\n") + set, err := Spec(root, "billing") + if err != nil { + t.Fatalf("Spec: %v", err) + } + if !contains(rules(set), "ears.malformed") { + t.Fatalf("rules = %v, want ears.malformed", rules(set)) + } + msg := set.Sorted()[0].Message + if !strings.Contains(msg, "R1.1") || !strings.Contains(msg, "shall") { + t.Errorf("message = %q, want the ID and what is missing", msg) + } +} + +// Delta markers are how a change to an existing spec is written. A REMOVED requirement +// has nothing left to hold to EARS and nothing for a task to implement. +func TestDeltaMarkers(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", `# requirements + +- **R1.1** (MODIFIED) The engine shall compute the total in minor units +- **R1.2** (ADDED) When a refund is issued, the engine shall reverse the total +- **R1.3** (REMOVED) +- **R1.4** (modifed) The engine shall do something +`, "# design\n\nServes R1.1.\n", `# tasks + +- [ ] 1.1 (TDD) Compute in minor units — R1.1 +- [ ] 1.2 (Unit) Reverse on refund — R1.2 +- [ ] 1.3 (Unit) Something — R1.4 +`) + got := specFindings(t, root, "billing") + if !contains(got, "spec.delta-marker-invalid") { + t.Errorf("rules = %v, want spec.delta-marker-invalid for the typo", got) + } + // A REMOVED requirement must not be reported as orphaned or as malformed EARS. + for _, unwanted := range []string{"spec.orphan-requirement"} { + if contains(got, unwanted) { + t.Errorf("rules = %v, want no %s: a REMOVED requirement needs no task", got, unwanted) + } + } +} + +func TestMissingArtifacts(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "bare", "", "", "") + got := specFindings(t, root, "bare") + for _, want := range []string{"spec.missing-requirements", "spec.missing-design", "spec.missing-tasks"} { + if !contains(got, want) { + t.Errorf("rules = %v, want %s", got, want) + } + } + // Nothing else: a spec with no requirements cannot produce traceability findings, + // and reporting them would be findings about the wrong thing. + if len(got) != 3 { + t.Errorf("rules = %v, want exactly the three missing artifacts", got) + } +} + +func TestDesignMustTraceToRequirements(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", goodRequirements, "# design\n\nSome prose citing nothing.\n", goodTasks) + if got := specFindings(t, root, "billing"); !contains(got, "spec.design-cites-nothing") { + t.Errorf("rules = %v, want spec.design-cites-nothing", got) + } + + root = t.TempDir() + writeSpec(t, root, "billing", goodRequirements, "# design\n\nServes R9.9.\n", goodTasks) + if got := specFindings(t, root, "billing"); !contains(got, "spec.unknown-requirement") { + t.Errorf("rules = %v, want spec.unknown-requirement from the design", got) + } +} + +// The kickoff answers are validated only when present: a missing key means the run +// predates the convention, not that the user did something wrong. +func TestKickoffAnswers(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", strings.Replace(goodRequirements, "autonomy: auto", "autonomy: automatic", 1), goodDesign, goodTasks) + if got := specFindings(t, root, "billing"); !contains(got, "spec.kickoff-invalid") { + t.Errorf("rules = %v, want spec.kickoff-invalid", got) + } + + root = t.TempDir() + noFrontmatter := strings.SplitN(goodRequirements, "---\n", 3)[2] + writeSpec(t, root, "billing", noFrontmatter, goodDesign, goodTasks) + if got := specFindings(t, root, "billing"); len(got) != 0 { + t.Errorf("an artifact with no frontmatter produced findings: %v", got) + } +} + +// The templates ship examples and instructions inside HTML comments and fenced blocks. +// A validator that read them would report findings on the file scc just generated. +func TestExamplesInCommentsAndFencesAreNotContent(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "billing", goodRequirements+` + + +`+"```\n- **R8.8** neither is this\n```\n", goodDesign, goodTasks+` + +`+"```\n- [ ] 8.8 nor this one\n```\n") + if got := specFindings(t, root, "billing"); len(got) != 0 { + t.Errorf("examples were treated as content: %v", got) + } +} + +func TestSpecsValidatesEveryFeatureInOrder(t *testing.T) { + root := t.TempDir() + writeSpec(t, root, "zebra", goodRequirements, goodDesign, "# tasks\n\n- [ ] 1.1 Missing methodology — R1.1\n") + writeSpec(t, root, "alpha", goodRequirements, goodDesign, "# tasks\n\n- [ ] 1.1 Missing methodology — R1.1\n") + set, err := Specs(root) + if err != nil { + t.Fatalf("Specs: %v", err) + } + sorted := set.Sorted() + if len(sorted) == 0 { + t.Fatal("no findings across two broken specs") + } + if !strings.Contains(sorted[0].File, "alpha") { + t.Errorf("first finding is in %s, want the alpha spec", sorted[0].File) + } +} + +func TestSpecsOnAnEmptyWorkspaceIsSilent(t *testing.T) { + set, err := Specs(t.TempDir()) + if err != nil { + t.Fatalf("Specs: %v", err) + } + if !set.Empty() { + t.Errorf("an empty workspace produced findings: %v", rules(set)) + } +} + +func TestSpecOnAnUnknownFeatureIsAnError(t *testing.T) { + if _, err := Spec(t.TempDir(), "ghost"); err == nil { + t.Error("validating a spec that does not exist returned no error") + } +} + +func contains(haystack []string, want string) bool { + for _, s := range haystack { + if s == want { + return true + } + } + return false +} diff --git a/internal/validate/stack.go b/internal/validate/stack.go new file mode 100644 index 0000000..06fa92f --- /dev/null +++ b/internal/validate/stack.go @@ -0,0 +1,182 @@ +package validate + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/textutil" +) + +// Stack turns a rule into a gate. +// +// "Technology not listed in docs/stack.md is an open decision, never adopted silently" +// reads like an unenforceable principle — but a dependency manifest is structured data, +// not source, so diffing the manifest against stack.md catches the silent dependency +// without reading a line of code. That is the whole trick, and it works in any ecosystem +// that has a manifest. +// +// Two deliberate limits: +// +// - **Direct dependencies only.** An indirect dependency is not a decision anybody +// made; reporting the transitive closure would bury the one line that matters. +// - **An unparsed manifest produces no findings.** Ecosystems whose manifests cannot +// be read confidently without a dependency (TOML, for one) wait for a safe parse +// rather than getting a naive one. Silence is the correct behavior when a check +// cannot be sure. +func Stack(root string) (*finding.Set, error) { + set := &finding.Set{} + deps, err := dependencies(root) + if err != nil { + return nil, err + } + if len(deps) == 0 { + return set, nil + } + + path := paths.Stack(root) + file := rel(root, path) + if !isFile(path) { + set.Addf(file, 0, "stack.missing", + "this project declares %d dependencies and has no %s; adopted technology gets one line saying why it earned its place", + len(deps), paths.StackSeg) + return set, nil + } + doc, err := read(root, path) + if err != nil { + if doc == nil { + return nil, err + } + set.Addf(file, 1, "stack.frontmatter-unreadable", "%v", err) + return set, nil + } + listed := strings.ToLower(strings.Join(doc.Body, "\n")) + + names := make([]string, 0, len(deps)) + for name := range deps { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + // The full path, or the last segment — someone writing "chi" for + // github.com/go-chi/chi has documented the decision, and insisting on the + // module path would be the validator enforcing a spelling nobody agreed to. + if strings.Contains(listed, strings.ToLower(name)) || strings.Contains(listed, strings.ToLower(baseName(name))) { + continue + } + set.Addf(file, 0, "stack.undocumented-dependency", + "%s is declared in %s and absent from %s: an adopted dependency is a decision, and this one is unrecorded", + name, deps[name], paths.StackSeg) + } + return set, nil +} + +// dependencies collects direct dependencies, mapping each to the manifest that declared +// it. Ecosystems are added here one at a time, each with a parse that cannot be wrong. +func dependencies(root string) (map[string]string, error) { + deps := map[string]string{} + if err := goModDependencies(root, deps); err != nil { + return nil, err + } + if err := packageJSONDependencies(root, deps); err != nil { + return nil, err + } + return deps, nil +} + +// goModDependencies reads the require blocks of go.mod by hand. +// +// By hand rather than with golang.org/x/mod: scc is stdlib-only, the grammar in play +// here is two forms of one directive, and a dependency added to read a dependency file +// would be its own punchline. +func goModDependencies(root string, deps map[string]string) error { + path := filepath.Join(root, "go.mod") + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + inBlock := false + for _, raw := range strings.Split(textutil.NormalizeNewlines(string(b)), "\n") { + line := strings.TrimSpace(raw) + // An indirect dependency is not a decision anybody made. + if line == "" || strings.HasPrefix(line, "//") || strings.Contains(line, "// indirect") { + continue + } + switch { + case line == "require (": + inBlock = true + continue + case inBlock && line == ")": + inBlock = false + continue + } + fields := strings.Fields(line) + switch { + case inBlock && len(fields) >= 2: + deps[fields[0]] = "go.mod" + case !inBlock && len(fields) >= 3 && fields[0] == "require": + deps[fields[1]] = "go.mod" + } + } + return nil +} + +// packageJSONDependencies reads dependencies and devDependencies with encoding/json. +// Both count: a test framework is as much an adopted decision as a web server, and it is +// as much of a supply-chain surface. +func packageJSONDependencies(root string, deps map[string]string) error { + path := filepath.Join(root, "package.json") + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var manifest struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal(b, &manifest); err != nil { + // A manifest scc cannot parse produces no findings. It is not scc's place to + // report on the syntax of somebody else's build file. + return nil + } + for _, group := range []map[string]string{manifest.Dependencies, manifest.DevDependencies} { + for name := range group { + deps[name] = "package.json" + } + } + return nil +} + +// baseName is the name a human would write a dependency down as: the last path segment, +// with a Go major-version suffix dropped. +// +// The suffix matters. github.com/go-chi/chi/v5 ends in "v5", and a validator that looked +// for "v5" in stack.md would report a documented dependency as undocumented — a false +// positive on the single most common shape of Go module path. +func baseName(module string) string { + module = strings.TrimSuffix(module, "/") + if i := strings.LastIndex(module, "/"); i >= 0 { + if majorVersionRe.MatchString(module[i+1:]) { + module = module[:i] + if i = strings.LastIndex(module, "/"); i < 0 { + return module + } + } + return module[i+1:] + } + return module +} + +// majorVersionRe matches a Go module's major-version path suffix: v2, v5, v11. +var majorVersionRe = regexp.MustCompile(`^v[2-9]\d*$`) diff --git a/internal/validate/tasks.go b/internal/validate/tasks.go new file mode 100644 index 0000000..7e72604 --- /dev/null +++ b/internal/validate/tasks.go @@ -0,0 +1,98 @@ +package validate + +import ( + "regexp" + "strings" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/mdscan" +) + +// The task grammar, in one place, because one grammar governs every task line — +// whether it sits in a spec's tasks.md or in a plan's checklist. The methodology is a +// property of the task, not of the vehicle that carried it, so the findings a user +// sees are identical in both and share one `task.` slug namespace. +// +// - [ ] 1.1 (Unit) Parse the manifest file — R1.2, R1.4 +var ( + taskNumberRe = regexp.MustCompile(`^(\d+(?:\.\d+)*)\s+(.*)$`) + methodologyRe = regexp.MustCompile(`\((Unit|TDD)\)`) + looseMethodology = regexp.MustCompile(`(?i)\((unit|tdd|test[- ]?first|none)\)`) + requirementIDRe = regexp.MustCompile(`\bR\d+(?:\.\d+)+\b`) + citationSeparator = "—" // an em dash, per the grammar in .claude/rules/tasks.md +) + +// task is one parsed task line. +type task struct { + Line int + Checked bool + Number string + Methodology string // "Unit" or "TDD" + Text string + Requirements []string +} + +// parseTasks reads every checkbox in doc against the task grammar and reports what is +// missing. citations decides whether a task must cite requirements: in a spec's +// tasks.md it must, because that citation is what makes traceability checkable; a +// plan has no requirements to cite. +// +// Only checkboxes are considered, and mdscan has already excluded the ones inside +// fenced blocks and HTML comments — which is what keeps a rule file that documents +// the grammar, or a template that shows an example, from producing findings. +func parseTasks(set *finding.Set, file string, doc *mdscan.Document, citations bool) []task { + var tasks []task + seen := map[string]int{} + + for _, box := range doc.Checkboxes { + t := task{Line: box.Line, Checked: box.Checked} + rest := box.Text + + if m := taskNumberRe.FindStringSubmatch(rest); m != nil { + t.Number, rest = m[1], m[2] + if prior, dup := seen[t.Number]; dup { + set.Addf(file, box.Line, "task.duplicate-number", + "task %s is already used on line %d; a number has to identify one task", t.Number, prior) + } + seen[t.Number] = box.Line + } else { + set.Addf(file, box.Line, "task.missing-number", + "a task opens with its number: `- [ ] 1.1 (Unit) …`") + } + + switch found := methodologyRe.FindAllStringSubmatch(rest, -1); len(found) { + case 0: + // The one finding this whole practice exists to produce. A task with no + // methodology is a task where nobody decided. + if loose := looseMethodology.FindString(rest); loose != "" { + set.Addf(file, box.Line, "task.missing-methodology", + "%s is not a methodology: annotate the task `(Unit)` or `(TDD)`", loose) + } else { + set.Addf(file, box.Line, "task.missing-methodology", + "every task carries `(Unit)` or `(TDD)`: without it, nobody decided how this gets built") + } + case 1: + t.Methodology = found[0][1] + default: + set.Addf(file, box.Line, "task.multiple-methodologies", + "a task is built one way: found %d methodology annotations", len(found)) + } + + text, tail, hasTail := strings.Cut(rest, citationSeparator) + t.Text = strings.TrimSpace(methodologyRe.ReplaceAllString(text, "")) + t.Requirements = requirementIDRe.FindAllString(tail, -1) + + if t.Text == "" { + set.Addf(file, box.Line, "task.no-description", "the task says nothing about what to do") + } + if citations { + switch { + case !hasTail, len(t.Requirements) == 0: + set.Addf(file, box.Line, "task.missing-requirement", + "a task in a spec cites the requirements it satisfies: `… %s R1.1, R1.2`", citationSeparator) + } + } + tasks = append(tasks, t) + } + return tasks +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 0000000..e572bc4 --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,73 @@ +// Package validate holds every scc validator: one file per validator, one shared +// finding type, one shared Markdown scanner. +// +// One package rather than eight because the helpers below — resolve a path, read +// and normalize a file, report a finding at a line — must be identical across all +// of them. That is what keeps the message wording, the finding shape, and the +// path spelling from drifting between validators the user experiences as one tool. +// +// Three rules bind every check in here, and they are not style preferences: +// +// 1. **A false positive costs more than a miss.** In studied static-analysis +// deployments 35–91% of warnings are non-actionable, and false positives are +// the single most common reason developers suppress warnings. One wrong finding +// teaches the user to disbelieve all eight validators. When a check cannot be +// certain, it stays silent. +// 2. **Zero configuration.** Configuration is the other documented adoption +// barrier. A validator that must be tuned first will not be. +// 3. **Few findings, each fixable.** A finding the user cannot act on is noise +// wearing a useful shape. +// +// There is a reason to expect this to work: structural checks have the lowest +// false-positive rate of any class. A SKILL.md whose name does not match its +// directory is not a judgment call — it is true or false. Nothing here reads source +// code, which is the boundary that keeps it that way. +package validate + +import ( + "os" + "path/filepath" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/mdscan" +) + +// read returns a Markdown file parsed by mdscan, with the finding-facing path +// (slash-separated, relative to root) already attached. +func read(root, path string) (*mdscan.Document, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return mdscan.Parse(rel(root, path), string(b)) +} + +// rel is how every finding spells a path: relative to the workspace root and +// slash-separated, so a finding's identity does not depend on the platform that +// produced it. +func rel(root, path string) string { + r, err := filepath.Rel(root, path) + if err != nil { + return finding.Rel(path) + } + return finding.Rel(r) +} + +// exists reports whether path is present, of any kind. +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// isDir reports whether path is a directory. +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// isFile reports whether path is a regular file. A directory carrying a file's name +// is not that file — the same distinction the workspace marker rests on. +func isFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} diff --git a/internal/validate/wiki.go b/internal/validate/wiki.go new file mode 100644 index 0000000..ba3c11e --- /dev/null +++ b/internal/validate/wiki.go @@ -0,0 +1,177 @@ +package validate + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/protonspy/spec-claude-code/internal/finding" + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// Wiki validates docs/wiki/ and the drop box beside it. +// +// All four checks are graph facts over Markdown — no code is read, and none of them is +// a judgment call: +// +// - a [[wikilink]] resolves to a page +// - every page is reachable from index.md, because an orphan is a page nobody will +// find again +// - the changelog exists and names only pages that exist +// - docs/raw/ is empty, because a file still sitting there was collected and never +// processed +func Wiki(root string) (*finding.Set, error) { + set := &finding.Set{} + if err := checkRaw(set, root); err != nil { + return nil, err + } + + dir := paths.Wiki(root) + pages, err := markdownFiles(dir) + if err != nil { + return nil, err + } + if len(pages) == 0 { + return set, nil + } + + // A page's name is its slug: [[order-total]] resolves to order-total.md. + slugs := map[string]bool{} + for _, p := range pages { + slugs[strings.TrimSuffix(filepath.Base(p), ".md")] = true + } + + indexSlug := strings.TrimSuffix(paths.WikiIndex, ".md") + if !slugs[indexSlug] { + set.Addf(rel(root, filepath.Join(dir, paths.WikiIndex)), 0, "wiki.missing-index", + "the wiki has %d pages and no %s; without an entry point every page is an orphan", + len(pages), paths.WikiIndex) + } + + // links[from] = the pages it points at, collected once and used for both the + // broken-link check and the reachability walk. + links := map[string][]string{} + for _, p := range pages { + slug := strings.TrimSuffix(filepath.Base(p), ".md") + doc, err := read(root, p) + if err != nil { + if doc == nil { + return nil, err + } + set.Addf(rel(root, p), 1, "wiki.frontmatter-unreadable", "%v", err) + continue + } + for _, link := range doc.Wikilinks { + target := strings.TrimSuffix(link.Target, ".md") + if !slugs[target] { + set.Addf(rel(root, p), link.Line, "wiki.broken-link", + "[[%s]] resolves to no page under %s/", link.Target, paths.WikiSeg) + continue + } + links[slug] = append(links[slug], target) + } + } + + // Reachability from the index. The changelog is a log rather than a page, so it is + // not expected to be linked and is not an orphan when it is not. + logSlug := strings.TrimSuffix(paths.WikiLog, ".md") + reachable := map[string]bool{indexSlug: true, logSlug: true} + walk := []string{indexSlug} + for len(walk) > 0 { + cur := walk[len(walk)-1] + walk = walk[:len(walk)-1] + for _, next := range links[cur] { + if !reachable[next] { + reachable[next] = true + walk = append(walk, next) + } + } + } + orphans := make([]string, 0) + for slug := range slugs { + if !reachable[slug] { + orphans = append(orphans, slug) + } + } + sort.Strings(orphans) + for _, slug := range orphans { + set.Addf(rel(root, filepath.Join(dir, slug+".md")), 0, "wiki.orphan-page", + "not reachable from %s; link it from the index or from a page that is", paths.WikiIndex) + } + + checkChangelog(set, root, dir, slugs) + return set, nil +} + +// checkChangelog is the index/log desync check, in the form that is actually +// structural: the log exists, and every page it names still exists. Comparing the log +// against what changed would mean reading history, which scc does not do. +func checkChangelog(set *finding.Set, root, dir string, slugs map[string]bool) { + path := filepath.Join(dir, paths.WikiLog) + if !isFile(path) { + set.Addf(rel(root, path), 0, "wiki.missing-changelog", + "the wiki has pages and no %s; record what changed when you change it", paths.WikiLog) + return + } + doc, err := read(root, path) + if err != nil || doc == nil { + return + } + for _, link := range doc.Wikilinks { + target := strings.TrimSuffix(link.Target, ".md") + if !slugs[target] { + set.Addf(rel(root, path), link.Line, "wiki.changelog-desync", + "the log names [[%s]], which no longer exists", link.Target) + } + } +} + +// checkRaw reports sources that were collected and never processed. raw/ is a drop box, +// not storage: material goes there to be read, distilled into a page, and removed. +func checkRaw(set *finding.Set, root string) error { + dir := paths.Raw(root) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + // A dotfile is how a directory gets committed while empty, which is the + // opposite of unprocessed work. + if e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + names = append(names, e.Name()) + } + sort.Strings(names) + for _, name := range names { + set.Addf(rel(root, filepath.Join(dir, name)), 0, "wiki.unprocessed-source", + "still in %s/: distill it into a wiki page and remove it", paths.RawSeg) + } + return nil +} + +// markdownFiles lists the .md files directly in dir, sorted. Absent is not an error: +// every knowledge-base validator is silent when its subject does not exist. +func markdownFiles(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + out = append(out, filepath.Join(dir, e.Name())) + } + sort.Strings(out) + return out, nil +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go new file mode 100644 index 0000000..4a73f6c --- /dev/null +++ b/internal/workspace/workspace.go @@ -0,0 +1,168 @@ +// Package workspace resolves the scc workspace root and owns the primitives +// every writer goes through: name validation for hostile input, and atomic +// writes. It knows nothing about specs, wikis, or PRDs — only about the +// filesystem contract those features are built on. +package workspace + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/protonspy/spec-claude-code/internal/paths" +) + +var kebabRe = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + +// ErrAlreadyExists signals a refusal to overwrite an existing artifact without --force. +var ErrAlreadyExists = errors.New("already exists") + +// KebabCheck rejects names that are not kebab-case, returning a descriptive error. +// Every resource scc names on disk is kebab-case so a name maps to exactly one +// path on case-insensitive filesystems too. +func KebabCheck(name, kind string) error { + if !kebabRe.MatchString(name) { + return fmt.Errorf("%s name must be kebab-case (lowercase, hyphen-separated): got %q", kind, name) + } + return nil +} + +// SafeName rejects a positional artifact name that could escape its parent +// directory when joined onto a workspace path. Every delete/show/generate lookup +// MUST call this before filepath.Join, because those names arrive from CLI args +// and may be malformed or hostile — without it, `scc spec delete .. --force` +// resolves to the workspace root and RemoveAll deletes the whole project. It +// rejects path separators, "." / "..", absolute paths, and (on Windows) reserved +// device names via filepath.IsLocal. +func SafeName(name, kind string) error { + if name == "" { + return fmt.Errorf("%s name is required", kind) + } + if strings.ContainsAny(name, `/\`) || name == "." || name == ".." || !filepath.IsLocal(name) { + return fmt.Errorf("invalid %s name %q: must be a single path segment with no separators or '..'", kind, name) + } + return nil +} + +// AtomicWrite writes data to a temp file in the same directory as path and +// renames it into place, so a crash or a concurrent reader never observes a +// truncated or half-written file. The temp file is created in the destination +// directory so the rename stays on one filesystem. +func AtomicWrite(path string, data []byte, perm os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// Find walks upward from start looking for the workspace marker and returns the +// directory holding it, or start unchanged when there is none. +// +// The marker is the file .claude/scc-manifest.json, not the .claude/ directory. +// That distinction is the whole point, for two reasons: ~/.claude is Claude +// Code's own global configuration directory and exists on every machine that +// runs Claude Code, so a walk accepting the directory would resolve the root to +// $HOME for any command run outside a workspace — and every command would then +// read and write the user's global configuration. And .claude/ exists in every +// repo that merely uses Claude Code, where scc was never initialized. Only scc +// writes the manifest, so its presence answers exactly the right question. +func Find(start string) string { + abs, err := filepath.Abs(start) + if err != nil { + return start + } + cur := abs + for { + if isFile(paths.Manifest(cur)) { + return cur + } + parent := filepath.Dir(cur) + if parent == cur { + return abs + } + cur = parent + } +} + +// isFile reports whether path exists and is a regular file. A directory that +// happens to carry the manifest's name is not a marker. +func isFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +// IsWorkspace reports whether root holds the marker — i.e. whether it is an +// initialized workspace rather than just the directory the walk fell back to. +func IsWorkspace(root string) bool { return isFile(paths.Manifest(root)) } + +// Resolve normalizes the user-supplied --root flag. An empty arg falls back to +// Find(cwd). A non-empty arg must exist on disk, otherwise an error is returned. +func Resolve(arg string) (string, error) { + if arg != "" { + p, err := filepath.Abs(arg) + if err != nil { + return "", err + } + if _, err := os.Stat(p); err != nil { + return "", fmt.Errorf("--root path does not exist: %s", p) + } + return p, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return Find(cwd), nil +} + +// SafeWrite writes content to path only when path is missing. Returns true when +// the file was created. +func SafeWrite(path, content string) (bool, error) { + if _, err := os.Stat(path); err == nil { + return false, nil + } + if err := AtomicWrite(path, []byte(content), 0o644); err != nil { + return false, err + } + return true, nil +} + +// WriteFile writes content unconditionally unless the path exists and overwrite +// is false. +func WriteFile(path, content string, overwrite bool) error { + if !overwrite { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("refusing to overwrite existing file: %s (pass --force)", path) + } + } + return AtomicWrite(path, []byte(content), 0o644) +} + +// Relative reports the path of target relative to root, for friendly logging. +func Relative(root, target string) string { + rel, err := filepath.Rel(root, target) + if err != nil { + return target + } + return rel +} diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go new file mode 100644 index 0000000..1902f33 --- /dev/null +++ b/internal/workspace/workspace_test.go @@ -0,0 +1,234 @@ +package workspace + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/protonspy/spec-claude-code/internal/paths" +) + +func TestKebabCheck(t *testing.T) { + ok := []string{"a", "spec", "user-auth", "oauth2-login", "a1-b2"} + bad := []string{"", "A", "User-Auth", "user_auth", "-user", "user-", "user--auth", "1user", "user auth", "café"} + for _, n := range ok { + if err := KebabCheck(n, "spec"); err != nil { + t.Errorf("KebabCheck(%q) = %v, want nil", n, err) + } + } + for _, n := range bad { + if err := KebabCheck(n, "spec"); err == nil { + t.Errorf("KebabCheck(%q) = nil, want an error", n) + } + } +} + +// SafeName is the guard standing between a CLI arg and filepath.Join. These are +// the inputs that turn `delete ` into deleting the workspace. +func TestSafeNameRejectsTraversal(t *testing.T) { + bad := []string{"", ".", "..", "../x", "a/b", `a\b`, "/abs", "./x"} + for _, n := range bad { + if err := SafeName(n, "spec"); err == nil { + t.Errorf("SafeName(%q) = nil, want an error", n) + } + } + for _, n := range []string{"user-auth", "spec.md", "a"} { + if err := SafeName(n, "spec"); err != nil { + t.Errorf("SafeName(%q) = %v, want nil", n, err) + } + } +} + +// filepath.IsLocal only rejects reserved device names on Windows, so assert the +// platform-specific half of the contract where it actually applies. +func TestSafeNameRejectsWindowsDeviceNames(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("reserved device names are a Windows-only concern") + } + for _, n := range []string{"CON", "nul", "COM1"} { + if err := SafeName(n, "spec"); err == nil { + t.Errorf("SafeName(%q) = nil, want an error", n) + } + } +} + +func TestAtomicWriteCreatesParentsAndLeavesNoTemp(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "nested", "deep", "file.json") + if err := AtomicWrite(target, []byte("payload"), 0o644); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != "payload" { + t.Errorf("content = %q, want %q", got, "payload") + } + entries, err := os.ReadDir(filepath.Dir(target)) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 1 { + t.Errorf("temp file left behind: %v", entries) + } +} + +func TestAtomicWriteOverwrites(t *testing.T) { + target := filepath.Join(t.TempDir(), "file") + for _, want := range []string{"first", "second"} { + if err := AtomicWrite(target, []byte(want), 0o644); err != nil { + t.Fatalf("AtomicWrite(%q): %v", want, err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != want { + t.Errorf("content = %q, want %q", got, want) + } + } +} + +// markWorkspace creates the .claude/scc-manifest.json marker under root. +func markWorkspace(t *testing.T, root string) { + t.Helper() + if err := AtomicWrite(paths.Manifest(root), []byte("{}\n"), 0o644); err != nil { + t.Fatalf("writing marker: %v", err) + } +} + +func TestFindWalksUpToMarker(t *testing.T) { + root := t.TempDir() + markWorkspace(t, root) + deep := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // t.TempDir() can sit under a symlinked path (/var -> /private/var on macOS), + // which Find resolves differently than the raw name; compare identities. + assertSameDir(t, Find(deep), root) + if !IsWorkspace(root) { + t.Errorf("IsWorkspace(%q) = false, want true", root) + } +} + +// A bare .claude/ must NOT be treated as a marker, for two reasons: it is Claude +// Code's global config at $HOME, so honoring it would resolve the root to the +// user's home for any command run outside a workspace; and it exists in every +// repo that merely uses Claude Code, where scc was never initialized. +func TestFindIgnoresClaudeDirWithoutMarker(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(paths.Claude(root), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + deep := filepath.Join(root, "a") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + assertSameDir(t, Find(deep), deep) + if IsWorkspace(root) { + t.Errorf("IsWorkspace(%q) = true for a .claude-only dir, want false", root) + } +} + +// The marker must be a regular file. A directory carrying the manifest's name is +// not a workspace, and must not be silently accepted as one. +func TestFindRejectsMarkerThatIsADirectory(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(paths.Manifest(root), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + assertSameDir(t, Find(root), root) // falls back to start, not "found" + if IsWorkspace(root) { + t.Error("IsWorkspace = true for a directory named like the manifest, want false") + } +} + +// The nearest marker wins, so a workspace nested inside another resolves to +// itself rather than to its parent. +func TestFindStopsAtNearestMarker(t *testing.T) { + outer := t.TempDir() + markWorkspace(t, outer) + inner := filepath.Join(outer, "sub", "project") + if err := os.MkdirAll(inner, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + markWorkspace(t, inner) + assertSameDir(t, Find(filepath.Join(inner, "deep")), inner) +} + +func TestFindWithoutMarkerReturnsStart(t *testing.T) { + dir := t.TempDir() + assertSameDir(t, Find(dir), dir) +} + +func TestResolveRejectsMissingRoot(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nope") + if _, err := Resolve(missing); err == nil { + t.Errorf("Resolve(%q) = nil error, want one", missing) + } +} + +func TestResolveAcceptsExistingRoot(t *testing.T) { + dir := t.TempDir() + got, err := Resolve(dir) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + assertSameDir(t, got, dir) +} + +func TestSafeWriteDoesNotClobber(t *testing.T) { + target := filepath.Join(t.TempDir(), "file") + created, err := SafeWrite(target, "original") + if err != nil || !created { + t.Fatalf("SafeWrite = (%v, %v), want (true, nil)", created, err) + } + created, err = SafeWrite(target, "replacement") + if err != nil { + t.Fatalf("SafeWrite: %v", err) + } + if created { + t.Error("SafeWrite reported a create over an existing file") + } + got, _ := os.ReadFile(target) + if string(got) != "original" { + t.Errorf("content = %q, want %q", got, "original") + } +} + +func TestWriteFileRequiresOverwrite(t *testing.T) { + target := filepath.Join(t.TempDir(), "file") + if err := WriteFile(target, "a", false); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := WriteFile(target, "b", false); err == nil { + t.Error("WriteFile overwrote an existing file without overwrite=true") + } + if err := WriteFile(target, "b", true); err != nil { + t.Fatalf("WriteFile(overwrite): %v", err) + } + got, _ := os.ReadFile(target) + if string(got) != "b" { + t.Errorf("content = %q, want %q", got, "b") + } +} + +// assertSameDir compares two paths by the identity the filesystem reports, so a +// symlinked temp dir or Windows 8.3 short name doesn't fail a correct result. +func assertSameDir(t *testing.T, got, want string) { + t.Helper() + gi, err := os.Stat(got) + if err != nil { + t.Fatalf("stat %q: %v", got, err) + } + wi, err := os.Stat(want) + if err != nil { + t.Fatalf("stat %q: %v", want, err) + } + if !os.SameFile(gi, wi) { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/npm/.gitignore b/npm/.gitignore new file mode 100644 index 0000000..36b564f --- /dev/null +++ b/npm/.gitignore @@ -0,0 +1,3 @@ +# The assembled publish tree, built from release artifacts by +# scripts/build-packages.mjs. Reproducible from a tag — never committed. +dist/ diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..f5201c9 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,39 @@ +# npm packaging + +How `scc` reaches npm. Nothing here is built from source at install time. + +## Layout + +| Path | What it is | +|---|---| +| `scc/` | The published launcher package (`@protonspy/scc`): a Node shim, no binary. | +| `scripts/build-packages.mjs` | Assembles `npm/dist/` from the release artifacts. | +| `dist/` | Generated, git-ignored. One directory per package to publish. | + +## How the install works + +The launcher declares one `optionalDependencies` entry per platform +(`@protonspy/scc-linux-x64`, `…-darwin-arm64`, …), each carrying a prebuilt Go +binary and constrained by npm's `os`/`cpu` fields. npm installs only the package +matching the host, and `bin/scc.js` resolves that package's binary and execs it. + +No postinstall script and no network access at install time — the binary is +already there or the install failed. + +## Releasing + +The release workflow does this automatically. Manually, from a clean tree: + +```bash +make dist VERSION=v0.1.0 # cross-compile the 6 targets into dist/ +make npm-build VERSION=v0.1.0 # assemble npm/dist/ from those artifacts +make npm-dry-run # validate every package +make npm-publish # publish (skips already-published) +``` + +`build-packages.mjs` reuses the release artifacts as-is, so the binary published +to npm is byte-identical to the one attached to the GitHub Release. + +Adding a platform means adding it to `TARGETS` in `build-packages.mjs`, the +`PLATFORMS` list in the `Makefile`, and the `build` matrix in +`.github/workflows/release.yml` — all three must agree. diff --git a/npm/scc/README.md b/npm/scc/README.md new file mode 100644 index 0000000..b5acd51 --- /dev/null +++ b/npm/scc/README.md @@ -0,0 +1,25 @@ +# @protonspy/scc + +Spec-driven development for Claude Code — a single Go binary that turns the SDD +workflow into a mechanically validated contract for humans and AI agents. + +```bash +npx @protonspy/scc help # no install +npm i -g @protonspy/scc # then: scc help +``` + +This package is a thin launcher. The native binary ships in a per-platform +optional dependency (`@protonspy/scc-linux-x64`, `…-darwin-arm64`, …); npm +installs only the one matching your machine. There is no postinstall step and no +download at install time. + +Prebuilt for Linux, macOS, and Windows on x64 and arm64. On any other platform, +build from source instead: + +```bash +go install github.com/protonspy/spec-claude-code/cmd/scc@latest +``` + +Source, issues, and documentation: + +Licensed under Apache-2.0. diff --git a/npm/scc/bin/scc.js b/npm/scc/bin/scc.js new file mode 100644 index 0000000..673e13c --- /dev/null +++ b/npm/scc/bin/scc.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// Launcher for the scc CLI distributed via npm. +// +// The actual Go binary ships in a per-platform optional dependency +// (@protonspy/scc--). npm installs only the package whose +// "os"/"cpu" match the host, so this shim just resolves that package's binary +// and execs it — forwarding argv, stdio, signals, and the exit code. No +// postinstall, no network at install time. +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const PLATFORM = { darwin: "darwin", linux: "linux", win32: "win32" }[process.platform]; +const ARCH = { x64: "x64", arm64: "arm64" }[process.arch]; + +if (!PLATFORM || !ARCH) { + console.error( + `scc: unsupported platform ${process.platform}/${process.arch}. ` + + `Prebuilt binaries are available for linux, macOS, and Windows on x64/arm64.\n` + + `Build from source instead: go install github.com/protonspy/spec-claude-code/cmd/scc@latest` + ); + process.exit(1); +} + +const pkg = `@protonspy/scc-${PLATFORM}-${ARCH}`; +const binName = process.platform === "win32" ? "scc.exe" : "scc"; + +let binPath; +try { + binPath = require.resolve(`${pkg}/bin/${binName}`); +} catch { + console.error( + `scc: could not find the native binary for ${PLATFORM}-${ARCH}.\n` + + `The optional dependency "${pkg}" was not installed.\n` + + `Reinstall without --no-optional / --ignore-optional, or report an issue at\n` + + `https://github.com/protonspy/spec-claude-code/issues` + ); + process.exit(1); +} + +// When invoked via `npx @protonspy/scc` (which is `npm exec` under the hood), +// echo that exact spelling in the binary's help/usage output. A global install +// runs this same launcher as the bare `scc` command — there npm is not in the +// picture (npm_command is unset), so the binary keeps its default name. An +// explicit SCC_PROG always wins. +const env = { ...process.env }; +if (!env.SCC_PROG) { + const argv1 = process.argv[1] || ""; + const viaNpx = + process.env.npm_command === "exec" || + argv1.includes("/_npx/") || + argv1.includes("\\_npx\\"); + if (viaNpx) env.SCC_PROG = "npx @protonspy/scc"; +} + +const child = spawn(binPath, process.argv.slice(2), { stdio: "inherit", env }); + +// Forward terminating signals so long-running commands shut down cleanly. +for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(sig, () => { + try { + child.kill(sig); + } catch { + /* child already gone */ + } + }); +} + +child.on("error", (err) => { + console.error(`scc: failed to launch binary: ${err.message}`); + process.exit(1); +}); + +child.on("exit", (code, signal) => { + if (signal) { + // Re-raise the signal so the parent's exit status reflects it. Remove our own + // handler for that signal first, otherwise the forwarding listener installed + // above would intercept the re-raise and the wrapper would exit 0 instead of + // dying from the signal. + process.removeAllListeners(signal); + process.kill(process.pid, signal); + } else { + // Preserve the binary's exit code verbatim — 2 means "validation findings" + // and callers branch on it. + process.exit(code ?? 0); + } +}); diff --git a/npm/scc/package.json b/npm/scc/package.json new file mode 100644 index 0000000..56fb7a9 --- /dev/null +++ b/npm/scc/package.json @@ -0,0 +1,42 @@ +{ + "name": "@protonspy/scc", + "version": "0.0.0", + "description": "Spec-driven development for Claude Code — a single Go binary that turns the SDD workflow into a mechanically validated contract for humans and AI agents.", + "keywords": [ + "claude", + "claude-code", + "spec-driven-development", + "sdd", + "cli", + "ai", + "agents" + ], + "homepage": "https://github.com/protonspy/spec-claude-code#readme", + "bugs": { + "url": "https://github.com/protonspy/spec-claude-code/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/protonspy/spec-claude-code.git" + }, + "license": "Apache-2.0", + "type": "module", + "bin": { + "scc": "bin/scc.js" + }, + "files": [ + "bin/scc.js", + "README.md" + ], + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@protonspy/scc-linux-x64": "0.0.0", + "@protonspy/scc-linux-arm64": "0.0.0", + "@protonspy/scc-darwin-x64": "0.0.0", + "@protonspy/scc-darwin-arm64": "0.0.0", + "@protonspy/scc-win32-x64": "0.0.0", + "@protonspy/scc-win32-arm64": "0.0.0" + } +} diff --git a/npm/scripts/build-packages.mjs b/npm/scripts/build-packages.mjs new file mode 100644 index 0000000..57872e5 --- /dev/null +++ b/npm/scripts/build-packages.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Assemble the npm publish tree from the release artifacts. +// +// Usage: node npm/scripts/build-packages.mjs [artifactsDir] +// +// the release tag, e.g. "v1.2.3" (the leading "v" is stripped +// for the npm version; the raw tag is used to locate the +// artifact tarballs produced by the release workflow). +// [artifactsDir] directory holding scc___.{tar.gz,zip} +// (default: "artifacts"). +// +// Output: npm/dist/ +// scc/ launcher package (shim + optionalDependencies) +// scc--/ one per target, carrying the native binary +// +// The Go binaries are reused as-is from the release artifacts (they already +// carry the version baked in via -ldflags), so the npm binary is byte-identical +// to the GitHub release binary. +import { execFileSync } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCOPE = "@protonspy"; +const BIN = "scc"; + +// Go target -> npm platform/arch + os/cpu constraints. +// Must match the Makefile PLATFORMS and the release.yml build matrix. The +// launcher's optionalDependencies are generated from this list, so adding a +// target here emits its platform package and wires it up automatically. +const TARGETS = [ + { goos: "linux", goarch: "amd64", node: "linux-x64", os: "linux", cpu: "x64" }, + { goos: "linux", goarch: "arm64", node: "linux-arm64", os: "linux", cpu: "arm64" }, + { goos: "darwin", goarch: "amd64", node: "darwin-x64", os: "darwin", cpu: "x64" }, + { goos: "darwin", goarch: "arm64", node: "darwin-arm64", os: "darwin", cpu: "arm64" }, + { goos: "windows", goarch: "amd64", node: "win32-x64", os: "win32", cpu: "x64" }, + { goos: "windows", goarch: "arm64", node: "win32-arm64", os: "win32", cpu: "arm64" }, +]; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const npmDir = resolve(scriptDir, ".."); +const repoRoot = resolve(npmDir, ".."); + +const tag = process.argv[2]; +if (!tag) { + console.error("error: missing argument (e.g. v1.2.3)"); + process.exit(1); +} +const version = tag.replace(/^v/, ""); +const artifactsDir = resolve(process.argv[3] ?? join(repoRoot, "artifacts")); +const outDir = join(npmDir, "dist"); + +// Preflight: every platform artifact must exist before we touch npm/dist, so a +// version mismatch (e.g. `npm-build VERSION=v0.1.2` against v0.1.1 tarballs) +// fails with a clear message instead of a cryptic tar/unzip error — and never +// wipes a good npm/dist or leaves a half-built one that breaks `npm-publish`. +const missingArtifacts = TARGETS.map((t) => { + const ext = t.goos === "windows" ? "zip" : "tar.gz"; + return join(artifactsDir, `${BIN}_${tag}_${t.goos}_${t.goarch}.${ext}`); +}).filter((f) => !existsSync(f)); +if (missingArtifacts.length > 0) { + console.error( + `error: missing release artifacts for ${tag} in ${artifactsDir}:\n` + + missingArtifacts.map((f) => " " + f).join("\n") + + `\nbuild them first: make dist VERSION=${tag}` + ); + process.exit(1); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +const repository = { + type: "git", + url: "git+https://github.com/protonspy/spec-claude-code.git", +}; + +// --- per-platform packages ------------------------------------------------- +const optionalDependencies = {}; +for (const t of TARGETS) { + const pkgName = `${SCOPE}/${BIN}-${t.node}`; + const binName = t.goos === "windows" ? `${BIN}.exe` : BIN; + const pkgDir = join(outDir, `${BIN}-${t.node}`); + const binDir = join(pkgDir, "bin"); + mkdirSync(binDir, { recursive: true }); + + const base = `${BIN}_${tag}_${t.goos}_${t.goarch}`; + if (t.goos === "windows") { + execFileSync("unzip", ["-o", join(artifactsDir, `${base}.zip`), "-d", binDir], { + stdio: "inherit", + }); + } else { + execFileSync("tar", ["-xzf", join(artifactsDir, `${base}.tar.gz`), "-C", binDir], { + stdio: "inherit", + }); + chmodSync(join(binDir, binName), 0o755); + } + + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify( + { + name: pkgName, + version, + description: `scc native binary for ${t.node}`, + repository, + license: "Apache-2.0", + os: [t.os], + cpu: [t.cpu], + files: [`bin/${binName}`], + }, + null, + 2 + ) + "\n" + ); + optionalDependencies[pkgName] = version; + console.log(`built ${pkgName}@${version}`); +} + +// --- launcher package ------------------------------------------------------ +const rootSrc = join(npmDir, BIN); +const rootOut = join(outDir, BIN); +mkdirSync(join(rootOut, "bin"), { recursive: true }); +cpSync(join(rootSrc, "bin", `${BIN}.js`), join(rootOut, "bin", `${BIN}.js`)); +cpSync(join(rootSrc, "README.md"), join(rootOut, "README.md")); + +// The checked-in optionalDependencies are a placeholder; the real list is +// generated from TARGETS above so a new platform can never be half-wired. +const rootPkg = JSON.parse(readFileSync(join(rootSrc, "package.json"), "utf8")); +rootPkg.version = version; +rootPkg.optionalDependencies = optionalDependencies; +writeFileSync(join(rootOut, "package.json"), JSON.stringify(rootPkg, null, 2) + "\n"); +console.log(`built ${rootPkg.name}@${version}`);