diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 6c407407..e05b6009 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -2,23 +2,25 @@ The brief's user-facing command surface is the set enumerated below (not all are shipped yet — see [`06-delivery/`](../06-delivery) for current delivery state). Each has its own file with the surface contract: purpose, flow, acceptance criteria. (Operator-internal commands are wiring, not part of this user-facing surface — e.g. `run`, the autonomous-run operator surface over the pluggable run seam (`status`/`pause`/`resume`/`preflight`): a design target (itd-29, `intents/planned/`); no `run` verb or `commands/abcd/run.md` is on any shipped surface.) -| # | Command | Purpose | File | -|---|---|---|---| -| 1 | `/abcd:ahoy` | Install / update abcd in any project | [`01-ahoy.md`](01-ahoy.md) | -| 2 | `/abcd:disembark` | Pack a lifeboat from the current project — design target, not shipped: no `disembark` binary verb or `commands/abcd/disembark.md` (Phase 6, adr-33) | [`02-disembark.md`](02-disembark.md) | -| 3 | `/abcd:embark` | Unpack a lifeboat into a (typically empty) target — design target, not shipped: no `embark` binary verb or `commands/abcd/embark.md` (Phase 6, adr-33) | [`03-embark.md`](03-embark.md) | -| 4 | `/abcd:launch` | Preview the curated release bundle and gates (read-only); cutting/publishing the artefact is a design target (itd-72) — see [`04-launch.md`](04-launch.md) | [`04-launch.md`](04-launch.md) | -| 5 | `/abcd:intent` | Capture / refine / grill / plan / ship / review / consistency / shape / reclassify / link intents (press-release format; three review roles, three verbs per [`05-intent.md § 7`](05-intent.md#7-the-intent-fidelity-reviewer-agent-three-roles-three-verbs)) — design target, not shipped: no `intent` binary verb or `commands/abcd/intent.md` (backing intents in `intents/planned/`) | [`05-intent.md`](05-intent.md) | -| 6 | `/abcd:capture` | Issue ledger (capture / list / resolve / wontfix; `promote` is a design target per spc-30/itd-46, skill-orchestrated — see [`06-capture.md`](06-capture.md)) | [`06-capture.md`](06-capture.md) | -| 7 | `/abcd:memory` | Multi-upstream curated knowledge substrate (per itd-36) — `ingest` external sources / `ask` queries / `lint` health-checks. Component spec: [`05-internals/07-memory.md`](../05-internals/07-memory.md). | [`07-memory.md`](07-memory.md) | -| 8 | `/abcd` | Top-level where-am-i status board (per itd-20, `intents/planned/`) — cross-command re-orientation. The shipped bare render is four read-only lines (directory, git repo, record present, `.abcd/` work tiers); the richer board (visibility, lifeboat, dev-sync, recent logbook, active intents, next actions) is a design target. `status` is a positional plugin alias for the bare render; `help` is not an alias (the binary has no `status` verb, and `abcd help` prints command usage). Read-only. | [`08-abcd.md`](08-abcd.md) | -| 9 | `/abcd:reflect` | Phase retrospective (per itd-24) — `/abcd:reflect ` composes a five-section retrospective (went well / could improve / lessons / decisions / metrics) seeded by the spc-66 phase-audit receipt. Phase-only grain — design target, not shipped: no `reflect` binary verb or `commands/abcd/reflect.md` (itd-24) | [`09-reflect.md`](09-reflect.md) | -| 10 | `/abcd:docs` | Documentation-currency lint (`lint`) — change-narration, broken links, stray root markdown; read-only, the deterministic half of the docs release gate | [`10-docs.md`](10-docs.md) | -| 11 | `/abcd:history` | Session-transcript store (`capture` / `list` / `show`) — per-repo, redact-on-write, keyed on the root-commit SHA | [`11-history.md`](11-history.md) | -| 12 | `/abcd:version` | Print the installed abcd version — read-only | [`12-version.md`](12-version.md) | -| 13 | `/abcd:consult` | Consult the local sources corpus (`~/.abcd/sources`) and record source→decision provenance — host-delegated command, no Go verb | [`13-consult.md`](13-consult.md) | -| 14 | `/abcd:ingest` | Register a URL or document into the local sources corpus — host-delegated command, no Go verb | [`14-ingest.md`](14-ingest.md) | -| 15 | `/abcd:prepare-this-repo` | Bring an owned repo up to abcd's conventions (interim bridge) — host-delegated command, no Go verb | [`15-prepare-this-repo.md`](15-prepare-this-repo.md) | +| # | Command | Status | Purpose | File | +|---|---|---|---|---| +| 1 | `/abcd:ahoy` | shipped | Install / update abcd in any project | [`01-ahoy.md`](01-ahoy.md) | +| 2 | `/abcd:disembark` | staged | Pack a lifeboat from the current project — design target, not shipped: no `disembark` binary verb or `commands/abcd/disembark.md` (Phase 6, adr-33) | [`02-disembark.md`](02-disembark.md) | +| 3 | `/abcd:embark` | staged | Unpack a lifeboat into a (typically empty) target — design target, not shipped: no `embark` binary verb or `commands/abcd/embark.md` (Phase 6, adr-33) | [`03-embark.md`](03-embark.md) | +| 4 | `/abcd:launch` | shipped | Preview the curated release bundle and gates (read-only); cutting/publishing the artefact is a design target (itd-72) — see [`04-launch.md`](04-launch.md) | [`04-launch.md`](04-launch.md) | +| 5 | `/abcd:intent` | staged | Capture / refine / grill / plan / ship / review / consistency / shape / reclassify / link intents (press-release format; three review roles, three verbs per [`05-intent.md § 7`](05-intent.md#7-the-intent-fidelity-reviewer-agent-three-roles-three-verbs)) — design target, not shipped: no `intent` binary verb or `commands/abcd/intent.md` (backing intents in `intents/planned/`) | [`05-intent.md`](05-intent.md) | +| 6 | `/abcd:capture` | shipped | Issue ledger (capture / list / resolve / wontfix; `promote` is a design target per spc-30/itd-46, skill-orchestrated — see [`06-capture.md`](06-capture.md)) | [`06-capture.md`](06-capture.md) | +| 7 | `/abcd:memory` | shipped | Multi-upstream curated knowledge substrate (per itd-36) — `ingest` external sources / `ask` queries / `lint` health-checks. Component spec: [`05-internals/07-memory.md`](../05-internals/07-memory.md). | [`07-memory.md`](07-memory.md) | +| 8 | `/abcd` | shipped | Top-level where-am-i status board (per itd-20, `intents/planned/`) — cross-command re-orientation. The shipped bare render is four read-only lines (directory, git repo, record present, `.abcd/` work tiers); the richer board (visibility, lifeboat, dev-sync, recent logbook, active intents, next actions) is a design target. `status` is a positional plugin alias for the bare render; `help` is not an alias (the binary has no `status` verb, and `abcd help` prints command usage). Read-only. | [`08-abcd.md`](08-abcd.md) | +| 9 | `/abcd:reflect` | staged | Phase retrospective (per itd-24) — `/abcd:reflect ` composes a five-section retrospective (went well / could improve / lessons / decisions / metrics) seeded by the spc-66 phase-audit receipt. Phase-only grain — design target, not shipped: no `reflect` binary verb or `commands/abcd/reflect.md` (itd-24) | [`09-reflect.md`](09-reflect.md) | +| 10 | `/abcd:docs` | shipped | Documentation-currency lint (`lint`) — change-narration, broken links, stray root markdown; read-only, the deterministic half of the docs release gate | [`10-docs.md`](10-docs.md) | +| 11 | `/abcd:history` | shipped | Session-transcript store (`capture` / `list` / `show`) — per-repo, redact-on-write, keyed on the root-commit SHA | [`11-history.md`](11-history.md) | +| 12 | `/abcd:version` | shipped | Print the installed abcd version — read-only | [`12-version.md`](12-version.md) | +| 13 | `/abcd:consult` | shipped | Consult the local sources corpus (`~/.abcd/sources`) and record source→decision provenance — host-delegated command, no Go verb | [`13-consult.md`](13-consult.md) | +| 14 | `/abcd:ingest` | shipped | Register a URL or document into the local sources corpus — host-delegated command, no Go verb | [`14-ingest.md`](14-ingest.md) | +| 15 | `/abcd:prepare-this-repo` | shipped | Bring an owned repo up to abcd's conventions (interim bridge) — host-delegated command, no Go verb | [`15-prepare-this-repo.md`](15-prepare-this-repo.md) | + +The **Status** column is machine-checked: the `surface_coverage` record-lint rule asserts every `shipped` row has a backing surface (`commands/abcd/.md` or `skills//`) and every `staged` row (a design target) has none — and, in reverse, that every real surface has a row here. The bare `/abcd` top-level is binary-backed (no command file) and is exempt from the file check. Keeping this column honest is how the brief's surface set stays reconciled with the shipped binary; the semantic half — whether each row's *prose* matches binary behaviour — stays a release-gate agent check. **Bare-command-as-help is a universal abcd convention** — every command shows read-only status when invoked without args (bare `abcd`, `abcd ahoy`, `abcd capture`, `abcd memory` all render status). The **suggested-next-actions** half of this convention is a design target: no shipped bare invocation yet emits next actions (bare `abcd launch` currently only hints to pass `--dry-run`). Provides discoverability without forcing the user to remember subcommand names. diff --git a/.abcd/record-lint.json b/.abcd/record-lint.json index d2f8aad1..e9885e9b 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -22,7 +22,8 @@ "directory_coverage": {"enabled": true, "severity": "warn", "exempt": []}, "intent_lifecycle": {"enabled": true, "severity": "blocker", "intents_dir": "intents"}, "persona_registry": {"enabled": true, "severity": "blocker", "registry": ".abcd/development/personas.json"}, - "context_status_free": {"enabled": true, "severity": "blocker", "target": ".abcd/work/CONTEXT.md"} + "context_status_free": {"enabled": true, "severity": "blocker", "target": ".abcd/work/CONTEXT.md"}, + "surface_coverage": {"enabled": true, "severity": "blocker", "commands_dir": "commands/abcd", "skills_dir": "skills", "registry": ".abcd/development/brief/04-surfaces/README.md"} }, "exempt_paths": [".abcd/development/research/", ".abcd/development/intents/superseded/"], "exempt_if_status": ["superseded"] diff --git a/CHANGELOG.md b/CHANGELOG.md index bb583eed..4b0db4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ called out in a **Breaking** section. ### Added +- A `surface_coverage` record-lint rule (iss-35): the deterministic half of the + brief↔surface cross-check. It reads the plugin surface + (`rules.surface_coverage.commands_dir`, `skills_dir` — outside the lint roots) + and the brief's surface registry table (`rules.surface_coverage.registry`, by + convention `.abcd/development/brief/04-surfaces/README.md`), and asserts three + invariants: every real surface has a registry row; every row marked `shipped` + in the registry's **Status** column has a backing surface while every `staged` + row (a design target) has none; and every row's status is `shipped` or + `staged`. The bare `/abcd` top-level is binary-backed and exempt from the file + check. Chapter-link resolution stays with `links_resolve`; the semantic half — + each row's prose vs. binary behaviour — stays a release-gate agent check. - A managed-repo **git-identity gate** (iss-62): a repo can pin its expected commit identity in `.abcd/config/identity.json`, and every commit is checked against it. `ahoy doctor` reports a divergence (a repo-local override that diff --git a/internal/core/lint/config.go b/internal/core/lint/config.go index 53d12538..2808560b 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -59,9 +59,18 @@ type RuleConfig struct { // Allowlist is the stray_root_docs permitted basename-stem list (upper-cased, // extension-stripped) for top-level markdown files. Allowlist []string `json:"allowlist"` - // Registry is the persona_registry roster file, repo-relative - // (.abcd/development/personas.json). + // Registry is a rule's registry file, repo-relative. For persona_registry it + // is the persona roster (.abcd/development/personas.json); for + // surface_coverage it is the brief surface table + // (.abcd/development/brief/04-surfaces/README.md). Registry string `json:"registry"` + // CommandsDir is the surface_coverage plugin-command directory (commands/abcd); + // each *.md file (README excepted) is a shipped command surface. It lies + // outside Roots — the rule reads the surface tree and cross-checks the brief. + CommandsDir string `json:"commands_dir"` + // SkillsDir is the surface_coverage skills directory (skills); each immediate + // subdirectory is a shipped skill surface. Also outside Roots. + SkillsDir string `json:"skills_dir"` // Target is the context_status_free single-file target, repo-relative // (.abcd/work/CONTEXT.md). The rule runs even though the target lies outside // Roots; a missing target is not an error. diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 333462b1..88cab573 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -38,8 +38,10 @@ var ( // Brittle line reference: some-file.md:171 (check D). brittleRefRe = regexp.MustCompile(`[A-Za-z0-9_./-]+\.md:\d+`) // Intent id embedded in a filename or a superseded_by value. - intentIDRe = regexp.MustCompile(`itd-\d+`) - intentFileRe = regexp.MustCompile(`^itd-\d+.*\.md$`) + intentIDRe = regexp.MustCompile(`itd-\d+`) + intentFileRe = regexp.MustCompile(`^itd-\d+.*\.md$`) + // Surface registry Command cell: the bare "/abcd" top-level, or "/abcd:". + surfaceCmdRe = regexp.MustCompile(`^/abcd(?::([a-z0-9-]+))?$`) specIDRe = regexp.MustCompile(`^spc-`) supersededRe = regexp.MustCompile(`^itd-\d+`) intentBuckets = map[string]bool{ @@ -150,6 +152,17 @@ func Lint(cfg Config, repoRoot string) ([]Finding, error) { findings = append(findings, cs...) } + // surface_coverage cross-checks the plugin surface (commands/, skills/ — + // outside cfg.Roots) against the brief's surface registry (inside a root), so + // it too runs once, outside the per-root loop. + if scCfg, ok := cfg.Rules["surface_coverage"]; ok && scCfg.Enabled { + sc, err := checkSurfaceCoverage(repoRoot, scCfg) + if err != nil { + return nil, err + } + findings = append(findings, sc...) + } + sortFindings(findings) return findings, nil } @@ -284,6 +297,253 @@ func checkContextStatusFree(repoRoot string, cfg RuleConfig) ([]Finding, error) return out, nil } +// surfaceRow is one parsed row of the brief's surface registry table. +type surfaceRow struct { + name string // sub-verb after "/abcd:"; empty for the bare "/abcd" top-level + status string // lower-cased "shipped" | "staged"; any other value is flagged + line int // 1-based line of the row in the registry file +} + +// checkSurfaceCoverage is the deterministic (Direction-B) half of the iss-35 +// brief↔surface cross-check. It reads the plugin surface (commands/ + skills/, +// which live outside cfg.Roots) and the brief's surface registry table, then +// asserts three invariants: +// - coverage: every real surface (a commands/abcd/*.md file or a skills/*/ +// directory) has a registry row; +// - status fidelity: a row marked "shipped" has a backing surface, and a row +// marked "staged" does not — the bare "/abcd" top-level is binary-backed, +// has no command file, and is exempt from the file check; +// - registry integrity: every row's status is "shipped" or "staged". +// +// The semantic half (a brief claim vs. binary behaviour — flags, exit codes, +// schema fields) stays an agent/release-gate check, not a structural lint. A +// missing registry file is not an error. +func checkSurfaceCoverage(repoRoot string, cfg RuleConfig) ([]Finding, error) { + if cfg.Registry == "" { + return nil, nil + } + rows, err := parseSurfaceRegistry(repoRoot, cfg.Registry) + if err != nil { + return nil, err + } + if rows == nil { + return nil, nil // registry file absent — nothing to cross-check + } + real, realPaths, err := realSurfaces(repoRoot, cfg) + if err != nil { + return nil, err + } + + var out []Finding + rowNames := make(map[string]bool, len(rows)) + for _, r := range rows { + if r.name != "" { + rowNames[r.name] = true + } + switch r.status { + case "shipped": + if r.name != "" && !real[r.name] { + out = append(out, Finding{ + File: cfg.Registry, Line: r.line, RuleID: "surface_coverage", Severity: cfg.Severity, + Message: "surface row '" + surfaceLabel(r.name) + "' is marked shipped but no commands/ or skills/ surface backs it", + }) + } + case "staged": + if r.name != "" && real[r.name] { + out = append(out, Finding{ + File: cfg.Registry, Line: r.line, RuleID: "surface_coverage", Severity: cfg.Severity, + Message: "surface row '" + surfaceLabel(r.name) + "' is marked staged but a backing surface exists; mark it shipped", + }) + } + default: + out = append(out, Finding{ + File: cfg.Registry, Line: r.line, RuleID: "surface_coverage", Severity: cfg.Severity, + Message: "surface row '" + surfaceLabel(r.name) + "' has unknown status '" + r.status + "' (want shipped|staged)", + }) + } + } + + // Coverage: every real surface must resolve to a registry row. Iterated in + // sorted order so findings are deterministic before the final sort. + names := make([]string, 0, len(realPaths)) + for name := range realPaths { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if !rowNames[name] { + out = append(out, Finding{ + File: realPaths[name], Line: 0, RuleID: "surface_coverage", Severity: cfg.Severity, + Message: "surface '" + name + "' has no row in the brief surface registry (" + cfg.Registry + ")", + }) + } + } + return out, nil +} + +// surfaceLabel renders a surface name as its slash-command spelling. +func surfaceLabel(name string) string { + if name == "" { + return "/abcd" + } + return "/abcd:" + name +} + +// realSurfaces enumerates the shipped plugin surface as a name set plus a +// name→repo-relative-path map. Command surfaces are the *.md files directly under +// CommandsDir (README excepted); skill surfaces are the immediate subdirectories +// of SkillsDir. A missing directory contributes nothing and is not an error. +func realSurfaces(repoRoot string, cfg RuleConfig) (map[string]bool, map[string]string, error) { + set := map[string]bool{} + paths := map[string]string{} + + if cfg.CommandsDir != "" { + entries, err := os.ReadDir(filepath.Join(repoRoot, cfg.CommandsDir)) + if err != nil && !os.IsNotExist(err) { + return nil, nil, err + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + name := strings.TrimSuffix(e.Name(), ".md") + if strings.EqualFold(name, "README") { + continue + } + set[name] = true + paths[name] = filepath.Join(cfg.CommandsDir, e.Name()) + } + } + + if cfg.SkillsDir != "" { + entries, err := os.ReadDir(filepath.Join(repoRoot, cfg.SkillsDir)) + if err != nil && !os.IsNotExist(err) { + return nil, nil, err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + set[e.Name()] = true + paths[e.Name()] = filepath.Join(cfg.SkillsDir, e.Name()) + } + } + return set, paths, nil +} + +// parseSurfaceRegistry reads the surface registry markdown and returns its table +// rows. It locates the one pipe-table whose header names both a Command and a +// Status column, then reads each data row's Command and Status cells (keyed by +// header position, so column order is free). Fenced code blocks are masked (the +// house convention, per fenceMask) so a table shown as a markdown *example* — a +// real risk in a doc whose subject is the surface table itself — is never +// mistaken for the registry. A missing file yields (nil, nil); a present file +// with no such table yields an empty, non-nil slice. +func parseSurfaceRegistry(repoRoot, registry string) ([]surfaceRow, error) { + content, err := os.ReadFile(filepath.Join(repoRoot, registry)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + lines := strings.Split(string(content), "\n") + mask := fenceMask(lines) + + cmdCol, statusCol, headerIdx := -1, -1, -1 + for i, line := range lines { + if mask[i] || !strings.HasPrefix(strings.TrimSpace(line), "|") { + continue + } + c, s := -1, -1 + for j, cell := range tableCells(line) { + switch strings.ToLower(cell) { + case "command": + c = j + case "status": + s = j + } + } + if c >= 0 && s >= 0 { + cmdCol, statusCol, headerIdx = c, s, i + break + } + } + rows := []surfaceRow{} + if headerIdx < 0 { + return rows, nil + } + + for i := headerIdx + 1; i < len(lines); i++ { + trimmed := strings.TrimSpace(lines[i]) + if mask[i] || !strings.HasPrefix(trimmed, "|") { + break // table ended (a fence closes it too) + } + if isTableSeparator(trimmed) { + continue + } + // A row whose Command/Status cell is missing or malformed is skipped + // here (status-fidelity unchecked) but still caught by the coverage + // direction if it names a real backing surface — fail-loud, not silent. + cells := tableCells(lines[i]) + if cmdCol >= len(cells) || statusCol >= len(cells) { + continue + } + name, ok := parseSurfaceCommand(cells[cmdCol]) + if !ok { + continue // not a /abcd surface row + } + rows = append(rows, surfaceRow{ + name: name, + status: strings.ToLower(strings.TrimSpace(cells[statusCol])), + line: i + 1, + }) + } + return rows, nil +} + +// tableCells splits a markdown table row into trimmed cell strings, dropping the +// empty cells the leading and trailing border pipes produce. +func tableCells(line string) []string { + parts := strings.Split(strings.TrimSpace(line), "|") + cells := make([]string, 0, len(parts)) + for _, p := range parts { + cells = append(cells, strings.TrimSpace(p)) + } + if len(cells) > 0 && cells[0] == "" { + cells = cells[1:] + } + if len(cells) > 0 && cells[len(cells)-1] == "" { + cells = cells[:len(cells)-1] + } + return cells +} + +// isTableSeparator reports whether a table row is the header separator (only +// pipes, dashes, colons, and whitespace). +func isTableSeparator(line string) bool { + for _, r := range line { + switch r { + case '|', '-', ':', ' ', '\t': + default: + return false + } + } + return true +} + +// parseSurfaceCommand extracts the surface name from a Command cell. The bare +// "/abcd" yields ("", true); "/abcd:" yields (name, true); anything else +// yields ("", false) so non-command rows are skipped rather than misread. +func parseSurfaceCommand(cell string) (string, bool) { + c := strings.TrimSpace(strings.Trim(strings.TrimSpace(cell), "`")) + m := surfaceCmdRe.FindStringSubmatch(c) + if m == nil { + return "", false + } + return m[1], true +} + // tokenCheck is a compiled BannedToken ready for line matching. type tokenCheck struct { token BannedToken diff --git a/internal/core/lint/lint_test.go b/internal/core/lint/lint_test.go index ad4ec789..dc7c57c6 100644 --- a/internal/core/lint/lint_test.go +++ b/internal/core/lint/lint_test.go @@ -348,6 +348,132 @@ func TestExemptions(t *testing.T) { } } +func TestSurfaceCoverage(t *testing.T) { + root := t.TempDir() + + // Real plugin surfaces: three commands and one skill. + writeFile(t, root, "commands/abcd/ahoy.md", "# ahoy\n") + writeFile(t, root, "commands/abcd/capture.md", "# capture\n") + writeFile(t, root, "commands/abcd/orphan.md", "# orphan\n") // no registry row → coverage fires + writeFile(t, root, "commands/abcd/README.md", "# index\n") // README is not a surface, skipped + writeFile(t, root, "skills/review/SKILL.md", "# review skill\n") + + // The brief surface registry. Column order deliberately differs from the + // checked columns to prove the parser keys off the header, not fixed offsets. + registry := "# Surfaces\n\n" + + "| # | Command | Status | Purpose | File |\n" + + "|---|---|---|---|---|\n" + + "| 1 | `/abcd:ahoy` | shipped | Install | [`01-ahoy.md`](01-ahoy.md) |\n" + // ok: shipped + file + "| 2 | `/abcd:disembark` | staged | Pack | [`02.md`](02.md) |\n" + // ok: staged, no file + "| 3 | `/abcd:capture` | staged | Ledger | [`06.md`](06.md) |\n" + // staged but file exists → fires + "| 4 | `/abcd:launch` | shipped | Release | [`04.md`](04.md) |\n" + // shipped but no file → fires + "| 5 | `/abcd:review` | shipped | Review | [`05.md`](05.md) |\n" + // ok: shipped + skill dir + "| 6 | `/abcd` | shipped | Board | [`08.md`](08.md) |\n" + // bare top-level: file check skipped + "| 7 | `/abcd:weird` | bogus | Bad | [`07.md`](07.md) |\n" // unknown status → fires + writeFile(t, root, "rec/registry.md", registry) + + cfg := Config{ + Rules: map[string]RuleConfig{ + "surface_coverage": { + Enabled: true, + Severity: "blocker", + CommandsDir: filepath.Join("commands", "abcd"), + SkillsDir: "skills", + Registry: filepath.Join("rec", "registry.md"), + }, + }, + } + fs, err := Lint(cfg, root) + if err != nil { + t.Fatal(err) + } + + reg := filepath.Join("rec", "registry.md") + want := []struct { + file string + line int + desc string + }{ + {filepath.Join("commands", "abcd", "orphan.md"), 0, "command with no registry row"}, + {reg, 7, "staged row with a backing surface"}, + {reg, 8, "shipped row with no backing surface"}, + {reg, 11, "row with an unknown status"}, + } + for _, w := range want { + if !hasFinding(fs, w.file, "surface_coverage", w.line) { + t.Errorf("expected surface_coverage finding (%s) on %s:%d; got %+v", w.desc, w.file, w.line, fs) + } + } + if n := countRule(fs, "surface_coverage"); n != len(want) { + t.Fatalf("expected exactly %d surface_coverage findings, got %d: %+v", len(want), n, fs) + } + + // A well-formed registry over the real surfaces produces zero findings. + clean := "# Surfaces\n\n" + + "| # | Command | Status | File |\n" + + "|---|---|---|---|\n" + + "| 1 | `/abcd:ahoy` | shipped | [`a.md`](a.md) |\n" + + "| 2 | `/abcd:capture` | shipped | [`c.md`](c.md) |\n" + + "| 3 | `/abcd:orphan` | shipped | [`o.md`](o.md) |\n" + + "| 4 | `/abcd:review` | shipped | [`r.md`](r.md) |\n" + + "| 5 | `/abcd:disembark` | staged | [`d.md`](d.md) |\n" + + "| 6 | `/abcd` | shipped | [`x.md`](x.md) |\n" + writeFile(t, root, "rec/clean.md", clean) + cfg.Rules["surface_coverage"] = RuleConfig{ + Enabled: true, Severity: "blocker", + CommandsDir: filepath.Join("commands", "abcd"), SkillsDir: "skills", + Registry: filepath.Join("rec", "clean.md"), + } + fs, err = Lint(cfg, root) + if err != nil { + t.Fatal(err) + } + if n := countRule(fs, "surface_coverage"); n != 0 { + t.Fatalf("clean registry should yield no findings, got %d: %+v", n, fs) + } + + // A missing registry file is not an error (nothing to cross-check). + cfg.Rules["surface_coverage"] = RuleConfig{ + Enabled: true, Severity: "blocker", + CommandsDir: filepath.Join("commands", "abcd"), Registry: filepath.Join("rec", "absent.md"), + } + if _, err := Lint(cfg, root); err != nil { + t.Fatalf("missing registry must not error: %v", err) + } + + // A fenced example table before the real one must be ignored (fence mask): + // the parser keys off the real table, so the clean surfaces stay clean. Were + // the fenced header latched onto, every real surface would fire a bogus + // coverage finding. + fenced := "# Surfaces\n\n" + + "Example of the table shape:\n\n" + + "```\n" + + "| # | Command | Status | File |\n" + + "|---|---|---|---|\n" + + "| 1 | `/abcd:example` | shipped | [`x.md`](x.md) |\n" + + "```\n\n" + + "The real registry:\n\n" + + "| # | Command | Status | File |\n" + + "|---|---|---|---|\n" + + "| 1 | `/abcd:ahoy` | shipped | [`a.md`](a.md) |\n" + + "| 2 | `/abcd:capture` | shipped | [`c.md`](c.md) |\n" + + "| 3 | `/abcd:orphan` | shipped | [`o.md`](o.md) |\n" + + "| 4 | `/abcd:review` | shipped | [`r.md`](r.md) |\n" + writeFile(t, root, "rec/fenced.md", fenced) + cfg.Rules["surface_coverage"] = RuleConfig{ + Enabled: true, Severity: "blocker", + CommandsDir: filepath.Join("commands", "abcd"), SkillsDir: "skills", + Registry: filepath.Join("rec", "fenced.md"), + } + fs, err = Lint(cfg, root) + if err != nil { + t.Fatal(err) + } + if n := countRule(fs, "surface_coverage"); n != 0 { + t.Fatalf("fenced example table must be ignored, got %d findings: %+v", n, fs) + } +} + // presentTenseTokens mirrors the change-narration phrase list shipped in // .abcd/docs-lint.json (word-boundary, case-insensitive, inline-escape). func presentTenseTokens() []BannedToken {