|
| 1 | +// ABOUTME: Structural lint — a FO reference file must not teach a bare `spacedock` |
| 2 | +// ABOUTME: helper INVOCATION by example; post-gate helper calls use ${SPACEDOCK_BIN:-spacedock}. |
| 3 | +package contractlint |
| 4 | + |
| 5 | +import ( |
| 6 | + "os" |
| 7 | + "path/filepath" |
| 8 | + "regexp" |
| 9 | + "strings" |
| 10 | + "testing" |
| 11 | +) |
| 12 | + |
| 13 | +// The FO launcher invariant pins ONE launcher at the version gate and uses it for |
| 14 | +// every later Spacedock helper call. A bare `spacedock <helper> …` INVOCATION in a |
| 15 | +// FO reference doc teaches the drift the invariant forbids: a reader copies it and |
| 16 | +// runs a different `$PATH` binary mid-session. This is a doc-AUTHORING rule — a |
| 17 | +// defect a machine can see (a bare launcher token in a runnable example), not a |
| 18 | +// prose-grep of a behavior claim and not a code-bound consistency check. It is the |
| 19 | +// AC-2 structural arm; the behavior is proven by the SPACEDOCK_BIN-vs-PATH live |
| 20 | +// drive in internal/ensigncycle (AC-3). |
| 21 | +// |
| 22 | +// The rule discriminates a runnable INVOCATION from the legitimate bare forms: |
| 23 | +// - it names a helper verb (status/state/dispatch/merge/new) AND carries an |
| 24 | +// invocation flag (`--workflow-dir`, `--discover`, `--set`, …), so a bare |
| 25 | +// command NAME mentioned in prose ("`spacedock new <slug>` mints the id") is |
| 26 | +// not flagged — naming a command is not invoking it; |
| 27 | +// - it is NOT a `→` capability-binding line, which names the SHIPPED command |
| 28 | +// surface, not a call the FO emits; |
| 29 | +// - it does NOT already resolve the launcher (`${SPACEDOCK_BIN:-spacedock}`); |
| 30 | +// - it is NOT in a fallback/diagnostic/install context (`on $PATH`, `doctor`, |
| 31 | +// `brew install`, `go build`, `--help`), where bare `spacedock` is correct |
| 32 | +// (the version-gate fallback probe and operator hints). |
| 33 | + |
| 34 | +// foReferenceDir is the first-officer reference surface this lint walks. |
| 35 | +func foReferenceDir(t *testing.T) string { |
| 36 | + t.Helper() |
| 37 | + return filepath.Join(skillsRoot(t), "first-officer", "references") |
| 38 | +} |
| 39 | + |
| 40 | +// launcherHelperInvocation matches a bare `spacedock <helper> … --<flag>` runnable |
| 41 | +// example inside a backtick span: a helper verb followed (anywhere before the next |
| 42 | +// backtick) by an invocation flag. The leading boundary keeps `${SPACEDOCK_BIN:-spacedock}` |
| 43 | +// from matching — that form has `:-` before `spacedock`, not a span/space boundary. |
| 44 | +var launcherHelperInvocation = regexp.MustCompile( |
| 45 | + "`spacedock (?:status|state|dispatch|merge|new)\\b[^`]*?--(?:workflow-dir|boot|discover|set|next-id|validate|json|resolve|where|next|archived|name)\\b") |
| 46 | + |
| 47 | +// launcherDiagnosticContext marks a line where a bare `spacedock` is legitimate: the |
| 48 | +// version-gate PATH fallback probe, the `doctor` remedy, an install hint, or a |
| 49 | +// `--help` reference. |
| 50 | +var launcherDiagnosticContext = regexp.MustCompile("on `?\\$?PATH|\\bdoctor\\b|brew install|go build|--help") |
| 51 | + |
| 52 | +// lineHasBareLauncherHelperCall reports whether a doc line teaches a bare |
| 53 | +// `spacedock` helper INVOCATION the launcher invariant forbids. |
| 54 | +func lineHasBareLauncherHelperCall(line string) bool { |
| 55 | + if !launcherHelperInvocation.MatchString(line) { |
| 56 | + return false |
| 57 | + } |
| 58 | + if strings.Contains(line, "${SPACEDOCK_BIN:-spacedock}") { |
| 59 | + return false |
| 60 | + } |
| 61 | + if strings.HasPrefix(strings.TrimSpace(line), "- → ") { |
| 62 | + return false |
| 63 | + } |
| 64 | + if launcherDiagnosticContext.MatchString(line) { |
| 65 | + return false |
| 66 | + } |
| 67 | + return true |
| 68 | +} |
| 69 | + |
| 70 | +// TestFOReferencesUseResolvedLauncher is the AC-2 lint: no FO reference file teaches |
| 71 | +// a bare `spacedock` helper invocation by example. A flagged line must resolve the |
| 72 | +// launcher (`${SPACEDOCK_BIN:-spacedock}`) instead. |
| 73 | +func TestFOReferencesUseResolvedLauncher(t *testing.T) { |
| 74 | + dir := foReferenceDir(t) |
| 75 | + entries, err := os.ReadDir(dir) |
| 76 | + if err != nil { |
| 77 | + t.Fatalf("read FO reference dir %s: %v", dir, err) |
| 78 | + } |
| 79 | + scanned := 0 |
| 80 | + for _, ent := range entries { |
| 81 | + if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".md") { |
| 82 | + continue |
| 83 | + } |
| 84 | + path := filepath.Join(dir, ent.Name()) |
| 85 | + data, err := os.ReadFile(path) |
| 86 | + if err != nil { |
| 87 | + t.Fatalf("read %s: %v", path, err) |
| 88 | + } |
| 89 | + scanned++ |
| 90 | + for i, line := range strings.Split(string(data), "\n") { |
| 91 | + if lineHasBareLauncherHelperCall(line) { |
| 92 | + t.Errorf("%s:%d teaches a bare `spacedock` helper invocation; post-gate helper calls must resolve the pinned launcher as `${SPACEDOCK_BIN:-spacedock}` (launcher invariant): %q", path, i+1, strings.TrimSpace(line)) |
| 93 | + } |
| 94 | + } |
| 95 | + } |
| 96 | + if scanned == 0 { |
| 97 | + t.Fatal("scanned zero FO reference files — extractor bug; the lint would pass vacuously") |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +// TestBareLauncherHelperScannerDiscriminates is the DISCRIMINATOR control: it proves |
| 102 | +// the scanner flags a genuine bare-invocation and PASSES every legitimate form, so |
| 103 | +// TestFOReferencesUseResolvedLauncher can never pass vacuously (e.g. a typo'd verb |
| 104 | +// that never matches, or an exemption swallowing the real leak). |
| 105 | +func TestBareLauncherHelperScannerDiscriminates(t *testing.T) { |
| 106 | + // The real leak shape — a bare runnable helper invocation. MUST flag. |
| 107 | + leak := "otherwise `spacedock status --discover`: one path → use it" |
| 108 | + if !lineHasBareLauncherHelperCall(leak) { |
| 109 | + t.Errorf("discriminator: a bare `spacedock status --discover` invocation was NOT flagged (scanner would pass vacuously): %q", leak) |
| 110 | + } |
| 111 | + |
| 112 | + // A bare state-mutation invocation. MUST flag. |
| 113 | + leakSet := "Entity frontmatter — via `spacedock status --set` for all field updates" |
| 114 | + if !lineHasBareLauncherHelperCall(leakSet) { |
| 115 | + t.Errorf("discriminator: a bare `spacedock status --set` invocation was NOT flagged: %q", leakSet) |
| 116 | + } |
| 117 | + |
| 118 | + // A bare `--name` helper invocation — the imperative context-budget probe. MUST |
| 119 | + // flag: this is the class the lint missed at claude-fo-dispatch.md:132 because |
| 120 | + // `--name` was absent from the invocation-flag allowlist. |
| 121 | + leakName := "**Context budget check:** Run `spacedock dispatch context-budget --name {ensign-name}`. Parse the JSON output." |
| 122 | + if !lineHasBareLauncherHelperCall(leakName) { |
| 123 | + t.Errorf("discriminator: a bare `spacedock dispatch context-budget --name` invocation was NOT flagged (the line-132 escape class): %q", leakName) |
| 124 | + } |
| 125 | + |
| 126 | + // The `→`-binding TWIN of the same `dispatch context-budget --name` text at |
| 127 | + // fo-dispatch-core.md:98 — it names the SHIPPED command surface per host, not an |
| 128 | + // FO-emitted call. MUST pass, so adding `--name` does not over-flag the binding line. |
| 129 | + bindingTwin := "- → **Claude:** PRESENT — `spacedock dispatch context-budget --name {name}`. · **Codex:** ABSENT. · **Pi:** ABSENT." |
| 130 | + if lineHasBareLauncherHelperCall(bindingTwin) { |
| 131 | + t.Errorf("discriminator: the `→`-binding twin of the `--name` invocation was wrongly flagged: %q", bindingTwin) |
| 132 | + } |
| 133 | + |
| 134 | + // The resolved-launcher form. MUST pass — this is the contract-blessed invocation. |
| 135 | + resolved := "run `${SPACEDOCK_BIN:-spacedock} status --workflow-dir {workflow_dir} --discover`" |
| 136 | + if lineHasBareLauncherHelperCall(resolved) { |
| 137 | + t.Errorf("discriminator: the resolved `${SPACEDOCK_BIN:-spacedock}` form was wrongly flagged: %q", resolved) |
| 138 | + } |
| 139 | + |
| 140 | + // A bare command NAME with no invocation flag — naming, not invoking. MUST pass. |
| 141 | + nameOnly := "`spacedock new <slug>` mints the id and writes the stamped entity" |
| 142 | + if lineHasBareLauncherHelperCall(nameOnly) { |
| 143 | + t.Errorf("discriminator: a bare command-name mention (no invocation flag) was wrongly flagged: %q", nameOnly) |
| 144 | + } |
| 145 | + |
| 146 | + // A `→` capability-binding line names the SHIPPED command surface. MUST pass. |
| 147 | + shippedLine := "- → **shipped**: `` `spacedock status --boot --json` ``." |
| 148 | + if lineHasBareLauncherHelperCall(shippedLine) { |
| 149 | + t.Errorf("discriminator: a `→ shipped:` capability-binding line was wrongly flagged: %q", shippedLine) |
| 150 | + } |
| 151 | + |
| 152 | + // The version-gate PATH fallback probe — bare `spacedock` is correct here. MUST pass. |
| 153 | + fallback := "If `SPACEDOCK_BIN` is unusable, retry once with bare `spacedock status --discover` on `$PATH`" |
| 154 | + if lineHasBareLauncherHelperCall(fallback) { |
| 155 | + t.Errorf("discriminator: the version-gate PATH fallback probe was wrongly flagged: %q", fallback) |
| 156 | + } |
| 157 | +} |
0 commit comments