cyber-sdd 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,11 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "sdd",
4
4
  "description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
5
- "author": { "name": "unional" },
6
- "homepage": "https://github.com/cyberuni/cyberplace",
7
- "repository": "https://github.com/cyberuni/cyberplace",
5
+ "author": {
6
+ "name": "unional"
7
+ },
8
+ "homepage": "https://github.com/cyberuni/cyber-sdd",
9
+ "repository": "https://github.com/cyberuni/cyber-sdd",
8
10
  "version": "0.0.0",
9
11
  "skills": "./skills",
10
12
  "agents": [
@@ -2,9 +2,11 @@
2
2
  "$schema": "https://raw.githubusercontent.com/cyberuni/marketplace/main/schema/claude/plugin.json",
3
3
  "name": "sdd",
4
4
  "description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
5
- "author": { "name": "unional" },
6
- "homepage": "https://github.com/cyberuni/cyberplace",
7
- "repository": "https://github.com/cyberuni/cyberplace",
5
+ "author": {
6
+ "name": "unional"
7
+ },
8
+ "homepage": "https://github.com/cyberuni/cyber-sdd",
9
+ "repository": "https://github.com/cyberuni/cyber-sdd",
8
10
  "version": "0.0.0",
9
11
  "skills": "./skills",
10
12
  "agents": [
@@ -2,9 +2,11 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "sdd",
4
4
  "description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
5
- "author": { "name": "unional" },
6
- "homepage": "https://github.com/cyberuni/cyberplace",
7
- "repository": "https://github.com/cyberuni/cyberplace",
5
+ "author": {
6
+ "name": "unional"
7
+ },
8
+ "homepage": "https://github.com/cyberuni/cyber-sdd",
9
+ "repository": "https://github.com/cyberuni/cyber-sdd",
8
10
  "version": "0.0.0",
9
11
  "skills": "./skills",
10
12
  "agents": [
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "cyber-sdd",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/cyberuni/cyberplace.git",
7
+ "url": "git+https://github.com/cyberuni/cyber-sdd.git",
8
8
  "directory": "plugins/sdd"
9
9
  },
10
10
  "license": "MIT",
@@ -23,8 +23,9 @@
23
23
  "dependencies": {
24
24
  "gherkin-cli": "0.0.2"
25
25
  },
26
+ "homepage": "https://github.com/cyberuni/cyber-sdd",
26
27
  "scripts": {
27
- "check:spec": "sdd-check-specs",
28
+ "check:spec": "node ./skills/check-project-specs/scripts/check-project-specs.mts",
28
29
  "test": "node --test \"skills/*/scripts/*.test.mts\"",
29
30
  "typecheck": "tsc -p tsconfig.json"
30
31
  }
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: check-plugin-manifests
3
+ description: "Partial Skill: invoke by name only — plugin/check-plugin-manifests' guard engine against a manifest declaring a component the package does not ship — the CI guard, not triggered by users directly."
4
+ user-invocable: false
5
+ metadata:
6
+ internal: true
7
+ ---
8
+
9
+ # Check Plugin Manifests
10
+
11
+ The concrete engine for the rule that **a plugin manifest declares only components the package
12
+ ships**. A pointer naming something the package does not carry is valid JSON, satisfies the schema,
13
+ and is copied into every generated vendor manifest — it fails only on an installer's machine, after
14
+ publish, as a component the host runtime cannot load. Nothing else in the repo compares a manifest's
15
+ pointers against what ships.
16
+
17
+ Spec: [`.agents/specs/sdd/plugin/check-plugin-manifests/`](../../../../.agents/specs/sdd/plugin/check-plugin-manifests/README.md).
18
+
19
+ ## What it checks
20
+
21
+ Two sub-checks, neither subsuming the other:
22
+
23
+ - **the disk check** — does the pointer resolve to a path that exists?
24
+ - **the publish check** — for a package that publishes, is the pointer inside its `files` allowlist?
25
+
26
+ A directory can exist and be excluded from the tarball; a `files` entry can name a directory nobody
27
+ created. The disk check **short-circuits**: a pointer dead on disk is reported once, as unresolved,
28
+ and is not also asked about `files`.
29
+
30
+ **A pointer is any `./`-prefixed string value**, at any depth, under any key — not a fixed key list.
31
+ The manifest format grows new component keys, and a guard hardcoding today's set fails open on the
32
+ next one added.
33
+
34
+ **A pointer resolves against the plugin root, not the manifest's own directory.** A vendor manifest
35
+ sits one level down; a guard resolving relative to the file it just read reports every vendor
36
+ manifest as entirely broken.
37
+
38
+ **A package that declares no `files` at all ships everything**, so there is no allowlist to be
39
+ outside of. Reading an absent allowlist as an empty one inverts npm's semantic and manufactures a
40
+ finding against every pointer in the package.
41
+
42
+ ## Run it
43
+
44
+ ```bash
45
+ node "<skill>/scripts/check-plugin-manifests.mts" [--root <dir>]
46
+ ```
47
+
48
+ `--root` defaults to the working directory. A finding always exits non-zero — there is deliberately
49
+ no report-only mode, because a chain that forgets the strict flag reports green over a defect, and
50
+ green is what the repo treats as clearance to commit. An unrecognized flag is an error, never an
51
+ input to ignore.
52
+
53
+ It joins the root check chain as **`check:plugins`**, so it runs on every `pnpm verify` and in CI.
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // check-plugin-manifests — a plugin manifest must declare only components the package ships.
3
+ //
4
+ // A manifest pointer naming something the package does not carry is valid JSON, satisfies the
5
+ // schema, and is copied into every generated vendor manifest. It fails only on an installer's
6
+ // machine, after publish, as a component the host runtime cannot load. Nothing else in the repo
7
+ // compares a manifest's pointers against what ships, so this engine does.
8
+ //
9
+ // Two sub-checks, neither subsuming the other (spec: .agents/specs/sdd/plugin/check-plugin-manifests/):
10
+ // disk — does the pointer resolve to a path that exists?
11
+ // publish — for a package that publishes, is the pointer inside its `files` allowlist?
12
+ // A directory can exist and be excluded from the tarball; a `files` entry can name a directory
13
+ // nobody created. The disk check short-circuits: a pointer dead on disk is reported once, as
14
+ // unresolved, and is not also asked about `files`.
15
+ //
16
+ // A pointer is any `./`-prefixed string value, at any depth, under any key — NOT a fixed key list.
17
+ // The manifest format grows new component keys, and a guard hardcoding today's set fails open on
18
+ // the next one added.
19
+ //
20
+ // Pure functions are exported for node:test; running the file directly drives the CLI. No
21
+ // dependencies (the repo's node-≥23.6 / no-deps convention).
22
+
23
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
24
+ import { dirname, join, relative, sep } from 'node:path'
25
+ import { pathToFileURL } from 'node:url'
26
+
27
+ /**
28
+ * Directories that hold a manifest one level below the plugin root.
29
+ *
30
+ * This is a path-shape list, not a provenance one: `.plugin/` holds the **canonical** manifest and
31
+ * the other two hold its generated vendor copies, but all three sit one level down, so a pointer
32
+ * inside any of them resolves against the parent. Naming it for vendors would contradict the
33
+ * spec's own vocabulary and has already misled one reader.
34
+ */
35
+ const MANIFEST_DIRS = ['.plugin', '.claude-plugin', '.codex-plugin']
36
+ const MANIFEST_NAME = 'plugin.json'
37
+ const SKIP_DIRS = new Set(['node_modules', '.git'])
38
+
39
+ export type FindingKind = 'unreadable' | 'unresolved' | 'unpublished'
40
+
41
+ export interface Finding {
42
+ kind: FindingKind
43
+ /** Repo-relative path of the manifest carrying the defect. */
44
+ manifest: string
45
+ /** The manifest key the pointer sits under. Absent for an unreadable manifest. */
46
+ key?: string
47
+ /** The pointer value. Absent for an unreadable manifest. */
48
+ pointer?: string
49
+ detail: string
50
+ }
51
+
52
+ // ── Discovery ──
53
+
54
+ /**
55
+ * Every manifest at a conventional location under `root`, repo-relative and path-sorted.
56
+ *
57
+ * A manifest is a `plugin.json` either inside a vendor directory, or sitting beside a
58
+ * `package.json` (a package-root manifest). Sorted so a sweep's report order is deterministic.
59
+ * A symlink is included: it resolves to a real manifest, and a broken one must surface as
60
+ * unreadable rather than vanish from the sweep.
61
+ */
62
+ /** Directory entries, or none where the directory cannot be read. */
63
+ function safeReaddir(dir: string) {
64
+ try {
65
+ return readdirSync(dir, { withFileTypes: true })
66
+ } catch {
67
+ return []
68
+ }
69
+ }
70
+
71
+ export function discoverManifests(root: string): string[] {
72
+ const found: string[] = []
73
+ const walk = (dir: string): void => {
74
+ for (const e of safeReaddir(dir)) {
75
+ const full = join(dir, e.name)
76
+ if (e.isDirectory()) {
77
+ if (!SKIP_DIRS.has(e.name)) walk(full)
78
+ continue
79
+ }
80
+ if (e.name !== MANIFEST_NAME) continue
81
+ const parent = dirname(full)
82
+ const inVendorDir = MANIFEST_DIRS.includes(parent.split(sep).pop() ?? '')
83
+ if (inVendorDir || existsSync(join(parent, 'package.json'))) found.push(full)
84
+ }
85
+ }
86
+ walk(root)
87
+ return found.map((f) => relative(root, f).split(sep).join('/')).sort()
88
+ }
89
+
90
+ /**
91
+ * The plugin root a manifest's pointers resolve against — NOT the manifest's own directory.
92
+ *
93
+ * A vendor manifest sits one level down, and its `./skills` still names the plugin's skills
94
+ * directory. A guard resolving relative to the file it just read reports every vendor manifest as
95
+ * entirely broken.
96
+ */
97
+ export function pluginRootOf(manifestRelPath: string): string {
98
+ const parts = manifestRelPath.split('/')
99
+ const parentName = parts[parts.length - 2]
100
+ const drop = parentName !== undefined && MANIFEST_DIRS.includes(parentName) ? 2 : 1
101
+ return parts.slice(0, parts.length - drop).join('/')
102
+ }
103
+
104
+ // ── Pointer extraction ──
105
+
106
+ /** Every `./`-prefixed string value in the manifest, paired with the key it sits under. */
107
+ export function extractPointers(manifest: unknown): Array<{ key: string; pointer: string }> {
108
+ const out: Array<{ key: string; pointer: string }> = []
109
+ const visit = (value: unknown, key: string): void => {
110
+ if (typeof value === 'string') {
111
+ if (value.startsWith('./')) out.push({ key, pointer: value })
112
+ return
113
+ }
114
+ if (Array.isArray(value)) {
115
+ for (const item of value) visit(item, key)
116
+ return
117
+ }
118
+ if (value !== null && typeof value === 'object') {
119
+ for (const [k, v] of Object.entries(value)) visit(v, k)
120
+ }
121
+ }
122
+ visit(manifest, '')
123
+ return out
124
+ }
125
+
126
+ // ── The publish sub-check ──
127
+
128
+ export interface OwningPackage {
129
+ path: string
130
+ publishes: boolean
131
+ files: string[]
132
+ }
133
+
134
+ /**
135
+ * The nearest `package.json` at or above the plugin root, and whether it publishes.
136
+ *
137
+ * A package publishes when it is not `private` AND declares a `files` allowlist. A `private`
138
+ * package ships no tarball, so its `files` constrains nothing. A package declaring no `files` at
139
+ * all ships everything, so there is no allowlist to be outside of — npm's own semantic, and
140
+ * inverting it manufactures a finding against every pointer in the package.
141
+ */
142
+ export function owningPackage(root: string, pluginRoot: string): OwningPackage | null {
143
+ let dir = pluginRoot
144
+ for (;;) {
145
+ const candidate = dir === '' ? 'package.json' : `${dir}/package.json`
146
+ const abs = join(root, candidate)
147
+ if (existsSync(abs)) {
148
+ try {
149
+ const pkg = JSON.parse(readFileSync(abs, 'utf8')) as { private?: unknown; files?: unknown }
150
+ const files = Array.isArray(pkg.files) ? pkg.files.filter((f): f is string => typeof f === 'string') : null
151
+ return { path: candidate, publishes: pkg.private !== true && files !== null, files: files ?? [] }
152
+ } catch {
153
+ return { path: candidate, publishes: false, files: [] }
154
+ }
155
+ }
156
+ if (dir === '') return null
157
+ const up = dir.split('/').slice(0, -1).join('/')
158
+ dir = up
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Whether a `files` allowlist covers a pointer, compared on the leading path segment.
164
+ *
165
+ * Both sides are normalized past a leading `./`, because an allowlist may legitimately spell an
166
+ * entry either way and a pointer always carries the prefix — comparing them raw reports a shipped
167
+ * component as unpublished.
168
+ *
169
+ * A `!`-prefixed entry is an exclusion and needs no special case: its leading `!` is part of its
170
+ * first segment, so it can never equal a real segment and is never read as an inclusion.
171
+ */
172
+ export function filesCovers(files: string[], pointer: string): boolean {
173
+ const head = (path: string): string | undefined => path.replace(/^\.\//, '').split('/')[0]
174
+ const segment = head(pointer)
175
+ if (segment === undefined || segment === '') return true
176
+ return files.some((entry) => {
177
+ const h = head(entry)
178
+ // A wildcard head ships everything below it, so it covers any segment.
179
+ return h === '*' || h === '**' || h === segment
180
+ })
181
+ }
182
+
183
+ // ── The sweep ──
184
+
185
+ export function sweep(root: string, manifests: string[]): Finding[] {
186
+ const findings: Finding[] = []
187
+ for (const rel of manifests) {
188
+ let parsed: unknown
189
+ try {
190
+ parsed = JSON.parse(readFileSync(join(root, rel), 'utf8'))
191
+ } catch {
192
+ findings.push({ kind: 'unreadable', manifest: rel, detail: 'not readable as JSON' })
193
+ continue
194
+ }
195
+ const pluginRoot = pluginRootOf(rel)
196
+ const pkg = owningPackage(root, pluginRoot)
197
+ for (const { key, pointer } of extractPointers(parsed)) {
198
+ const target = join(root, pluginRoot, pointer)
199
+ if (!existsSync(target)) {
200
+ findings.push({ kind: 'unresolved', manifest: rel, key, pointer, detail: 'names no path on disk' })
201
+ continue
202
+ }
203
+ if (pkg?.publishes && !filesCovers(pkg.files, pointer)) {
204
+ findings.push({
205
+ kind: 'unpublished',
206
+ manifest: rel,
207
+ key,
208
+ pointer,
209
+ detail: `declared but not published — ${pkg.path} files omits it`,
210
+ })
211
+ }
212
+ }
213
+ }
214
+ return findings
215
+ }
216
+
217
+ // ── Report ──
218
+
219
+ export function formatReport(manifests: string[], findings: Finding[]): string {
220
+ if (manifests.length === 0) return 'check-plugin-manifests: no plugin manifest found\n'
221
+ if (findings.length === 0) {
222
+ return `check-plugin-manifests: ${manifests.length} manifest(s) OK\n`
223
+ }
224
+ const rows = findings
225
+ .map((f) => ` ${f.kind.padEnd(11)} ${f.manifest}${f.key ? ` [${f.key}] ${f.pointer}` : ''} — ${f.detail}`)
226
+ .join('\n')
227
+ return `${rows}\ncheck-plugin-manifests: ${findings.length} finding(s) across ${manifests.length} manifest(s)\n`
228
+ }
229
+
230
+ // ── CLI ──
231
+
232
+ export function main(argv: string[]): number {
233
+ let root = '.'
234
+ for (let i = 0; i < argv.length; i++) {
235
+ const a = argv[i]
236
+ if (a === '--root') {
237
+ const next = argv[++i]
238
+ if (next === undefined) {
239
+ process.stderr.write('check-plugin-manifests: --root needs a directory\n')
240
+ return 1
241
+ }
242
+ root = next
243
+ continue
244
+ }
245
+ process.stderr.write(`check-plugin-manifests: unrecognized flag ${a}\n`)
246
+ return 1
247
+ }
248
+ const manifests = discoverManifests(root)
249
+ const findings = sweep(root, manifests)
250
+ process.stdout.write(formatReport(manifests, findings))
251
+ return findings.length === 0 ? 0 : 1
252
+ }
253
+
254
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
255
+ process.exit(main(process.argv.slice(2)))
256
+ }
@@ -1,14 +1,20 @@
1
1
  # check-project-specs
2
2
 
3
- Runs every project-spec check against the one spec governing the invoking package.
3
+ Runs every project-spec check — over the whole corpus, or over one project's spec.
4
4
 
5
5
  ```bash
6
- node scripts/check-project-specs.mts # resolve from cwd
7
- node scripts/check-project-specs.mts --project <dir> # resolve an explicit project dir
6
+ node scripts/check-project-specs.mts --corpus # every project-spec + the coverage guard
7
+ node scripts/check-project-specs.mts # one project, resolved from cwd
8
+ node scripts/check-project-specs.mts --project <dir> # one project, explicit dir
8
9
  ```
9
10
 
10
- Wired as each project's `check:spec` script through the `sdd-check-specs` bin, so every project —
11
- `plugins/*` and `packages/*` alike — runs the identical, path-free command.
11
+ The **corpus** scope is the repo's commit floor, wired as the root `check:specs`; it is total by
12
+ construction — the engine sweep and the coverage guard together, because neither alone sees a spec
13
+ the other misses. The **project** scope is each project's `check:spec` script through the
14
+ `sdd-check-specs` bin, so every project — `plugins/*` and `packages/*` alike — runs the identical,
15
+ path-free command.
16
+
17
+ A flag the harness does not define is an error, never a default scope.
12
18
 
13
19
  Resolution is spec-first: the spec's own `project-path` names the project dir, and
14
20
  `check-project-specs` inverts that map. The reverse map cannot be derived by name
@@ -16,4 +22,5 @@ Resolution is spec-first: the spec's own `project-path` names the project dir, a
16
22
 
17
23
  A project no spec governs prints a skip and exits zero. Two specs claiming one project is an error.
18
24
 
19
- See `SKILL.md` for the engine set and the cwd contract.
25
+ See `SKILL.md` for the engine set and the cwd contract, and
26
+ `.agents/specs/sdd/corpus/spec-floor/` for the spec.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: check-project-specs
3
- description: "Partial Skill: invoke by name only — project-spec/check-project-specs' engine that runs every project-spec check against the one spec governing the invoking package — the per-project CI entrypoint, not triggered by users directly."
3
+ description: "Partial Skill: invoke by name only — corpus/spec-floor's engine that runs every project-spec check, over the whole corpus or over one project — the commit-floor and CI entrypoint, not triggered by users directly."
4
4
  user-invocable: false
5
5
  metadata:
6
6
  internal: true
@@ -8,10 +8,27 @@ metadata:
8
8
 
9
9
  # Check Project Specs
10
10
 
11
- The **per-project entrypoint** for the project-spec checks. It resolves the one spec that governs
12
- the invoking package and runs each project-spec engine against it, so a project's spec checks are a
13
- task **the project owns** rather than a path some root script hardcodes. It carries a self-contained
14
- `.mts` script (the repo's node-≥23.6 / no-deps convention).
11
+ The **entrypoint** for the project-spec checks — the harness behind `corpus/spec-floor`. It resolves
12
+ which spec governs which project and runs each project-spec engine against it. It carries a
13
+ self-contained `.mts` script (the repo's node-≥23.6 / no-deps convention).
14
+
15
+ Two scopes:
16
+
17
+ | Scope | Flag | Who runs it |
18
+ |---|---|---|
19
+ | **corpus** — every project-spec, plus the coverage guard | `--corpus` | the repo's `check:specs` chain, so every commit and every CI run |
20
+ | **project** — one project-spec | `--project <dir>`, or no flag (the cwd) | a maintainer inside one project; each project's own `check:spec` |
21
+
22
+ **The corpus scope is total, and that is the point.** It runs *both* the engine sweep and the
23
+ coverage guard, because neither subsumes the other: the sweep only visits the specs discovery
24
+ **recognizes**, so a spec whose lifecycle `status` is a typo is invisible to it and the sweep would
25
+ report clean; the coverage guard sees that file on disk and escalates it, but says nothing about
26
+ whether the engines pass. Run either alone and a whole project-spec leaves the floor silently.
27
+
28
+ **An unrecognized flag is an error.** The scope is chosen by a flag, so a flag that falls through to
29
+ a default silently downgrades the run — and project scope, which is what a fall-through reaches,
30
+ resolves no governing spec at a repo root and exits **0**. That is exactly how a coverage-only flag
31
+ guarded this repo's commits and CI while running no engine at all (issue #5).
15
32
 
16
33
  ## Resolution — spec-first, never by name
17
34
 
@@ -31,13 +48,26 @@ mapping and no path is ever written into a package's scripts.
31
48
  ## Run it
32
49
 
33
50
  ```bash
34
- node "<skill>/scripts/check-project-specs.mts" [--project <dir>]
51
+ node "<skill>/scripts/check-project-specs.mts" --corpus # the whole corpus — the commit floor
52
+ node "<skill>/scripts/check-project-specs.mts" [--project <dir>] # one project-spec
35
53
  ```
36
54
 
37
- Wired as each project's `check:spec` script, via the `sdd-check-specs` bin.
55
+ The corpus scope is wired as the repo's root `check:specs`; the project scope is wired as each
56
+ project's `check:spec` script, via the `sdd-check-specs` bin. `--corpus` and `--project` name
57
+ contradictory scopes and are refused together.
38
58
 
39
59
  ## Outcomes
40
60
 
61
+ **Corpus scope**
62
+
63
+ - **Clean** — every recognized project-spec passed every engine and every spec file is covered; exits zero.
64
+ - **A coverage gap** — reports each gap with its reason, **then sweeps anyway**, and exits non-zero.
65
+ - **A failing engine** — names the engine and the project-spec, **continues to the next**, exits non-zero.
66
+ - **An empty corpus** — reports that plainly and exits zero. A repo with no project-spec is not a defect.
67
+ - **No repo root** — names the missing workspace marker and exits non-zero; it never falls back to the cwd.
68
+
69
+ **Project scope**
70
+
41
71
  - **Resolved** — runs every engine against the spec dir, reports `ok` / `FAIL` per engine, and exits
42
72
  non-zero if any failed.
43
73
  - **No spec governs this project** — prints that and exits **zero**. The script is uniform across
@@ -48,13 +78,14 @@ Wired as each project's `check:spec` script, via the `sdd-check-specs` bin.
48
78
  ## The engines it runs
49
79
 
50
80
  `check-spec-state` and `check-suite` (each `--root <specDir>`), then `concept-index`,
51
- `check-spec-structure`, and `align-spec` (each `--spec-dir <specDir> --check`).
81
+ `check-spec-structure`, `align-spec`, and `check-scenario-overlap` (each `--spec-dir <specDir>
82
+ --check`), and `check-spec-references` (`--spec-dir <specDir>`).
52
83
 
53
- **`check-scenario-overlap` is not in this set yet.** Per-project it reports pre-existing
54
- exact-duplicate scenarios that are `@trigger` sibling-deference rows; resolving one deletes a frozen
55
- scenario from its non-owning node, which is a **narrowing** and Clearance-bound — not a call this
56
- engine may force. It still runs corpus-wide at the root, so no coverage is lost, and it joins this
57
- set in the CR that resolves those duplicates under a granted clearance.
84
+ **`check-spec-references` takes no `--check`.** Every other engine here has a second mode a `--check`
85
+ selects between — write-vs-verify, or audit-vs-gate. This one does not: it is read-only and
86
+ single-severity, so a report mode would differ from the guard by exit code alone. A path that is
87
+ correct but not resolvable from the file carrying it (prose quoting a symlink target, say) is
88
+ excused inline by the engine's own marker, not by a severity.
58
89
 
59
90
  Every engine is spawned with **cwd = the repo root**, never the project dir — they resolve
60
91
  repo-root-relative references against the cwd.
@@ -62,6 +93,9 @@ repo-root-relative references against the cwd.
62
93
  The two `--root` engines are corpus-shaped (they read the first path segment under root as a project
63
94
  slug), but a single project-spec dir is a legal root: the slug is only a message tag.
64
95
 
96
+ A failing engine never stops the ones after it, at either scope: a floor that returns at the first
97
+ defect reports one defect per invocation, so an author fixes the tree one run at a time.
98
+
65
99
  ## Boundaries
66
100
 
67
101
  It owns no checks of its own — it resolves and delegates. It writes nothing, and it never decides
@@ -48,6 +48,12 @@ export const ENGINES: Engine[] = [
48
48
  args: (d) => ['--spec-dir', d, '--check'],
49
49
  },
50
50
  { name: 'align-spec', script: 'align-spec/scripts/align-spec.mts', args: (d) => ['--spec-dir', d, '--check'] },
51
+ {
52
+ name: 'check-spec-references',
53
+ script: 'check-spec-references/scripts/check-spec-references.mts',
54
+ // No --check: the engine has one mode, because every finding it makes is a defect.
55
+ args: (d) => ['--spec-dir', d],
56
+ },
51
57
  {
52
58
  name: 'check-scenario-overlap',
53
59
  script: 'check-scenario-overlap/scripts/check-scenario-overlap.mts',
@@ -143,7 +149,7 @@ export function findCoverageGaps(
143
149
  * Without this, a per-project run resolves such a spec to `none` and prints "no spec
144
150
  * governs <project> — skipped" with exit 0: a status typo silently exempts the whole
145
151
  * project from every engine. A spec that exists but cannot be classified is escalated,
146
- * not exempted — the same call the corpus-level `--check-coverage` guard makes.
152
+ * not exempted — the same call the corpus scope’s coverage guard makes.
147
153
  */
148
154
  export function findDroppedSpecFor(
149
155
  specFiles: string[],
@@ -181,6 +187,32 @@ function readTextOrNull(path: string): string | null {
181
187
  }
182
188
  }
183
189
 
190
+ // ─── the engine run ───────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Run the whole engine set against one project-spec dir. Every engine runs even
194
+ * after one fails: a run that stops at the first failure reports one defect per
195
+ * invocation, and the corpus scope exists to report the tree's defects in one go.
196
+ */
197
+ function runEngines(repoRoot: string, specDir: string): number {
198
+ let failed = 0
199
+ for (const e of ENGINES) {
200
+ // cwd is the repo root, not the project dir: the engines resolve
201
+ // repo-root-relative references against process.cwd().
202
+ try {
203
+ execFileSync('node', [join(SKILLS_DIR, e.script), ...e.args(specDir)], {
204
+ cwd: repoRoot,
205
+ stdio: 'inherit',
206
+ })
207
+ process.stdout.write(` ok ${e.name}\n`)
208
+ } catch {
209
+ process.stderr.write(` FAIL ${e.name}\n`)
210
+ failed++
211
+ }
212
+ }
213
+ return failed
214
+ }
215
+
184
216
  function checkCoverage(root: string): number {
185
217
  const gaps = findCoverageGaps(root, discoverSpecFiles(root), collectSpecs(root), (p) => {
186
218
  try {
@@ -200,18 +232,80 @@ function checkCoverage(root: string): number {
200
232
 
201
233
  // ─── run ──────────────────────────────────────────────────────────────────────
202
234
 
203
- export function main(argv: string[]): number {
204
- if (argv.includes('--check-coverage')) {
205
- const root = findRepoRoot(process.cwd())
206
- if (!root) {
207
- process.stderr.write('check-project-specs: no pnpm-workspace.yaml found above the cwd\n')
208
- return 1
235
+ /** The flags the harness defines. Anything else is an error, never a default. */
236
+ export const KNOWN_FLAGS = ['--corpus', '--project'] as const
237
+
238
+ /**
239
+ * The scope is chosen by a flag, so an unrecognized one must not fall through to a
240
+ * default. It used to: project scope at a repo root resolves no governing spec and
241
+ * exits 0, so a misspelled or retired flag reported success having checked nothing.
242
+ * That is how `--check-coverage` guarded commits and CI while running no engine.
243
+ */
244
+ export function unknownFlags(argv: string[]): string[] {
245
+ const out: string[] = []
246
+ for (let i = 0; i < argv.length; i++) {
247
+ const a = argv[i] as string
248
+ if (!a.startsWith('-')) continue
249
+ if (a === '--project') {
250
+ i++ // its value is not a flag
251
+ continue
209
252
  }
210
- return checkCoverage(root)
253
+ if (!(KNOWN_FLAGS as readonly string[]).includes(a)) out.push(a)
254
+ }
255
+ return out
256
+ }
257
+
258
+ export function main(argv: string[]): number {
259
+ const unknown = unknownFlags(argv)
260
+ if (unknown.length) {
261
+ process.stderr.write(
262
+ `check-project-specs: unrecognized flag(s) ${unknown.join(', ')} — ` +
263
+ `the scope is ${KNOWN_FLAGS.join(' | ')} (or none, meaning the cwd's project)\n`,
264
+ )
265
+ return 1
266
+ }
267
+ if (argv.includes('--corpus') && argv.includes('--project')) {
268
+ process.stderr.write('check-project-specs: --corpus and --project name contradictory scopes — pass one\n')
269
+ return 1
211
270
  }
271
+ if (argv.includes('--corpus')) return checkCorpus()
212
272
  return checkProject(argv)
213
273
  }
214
274
 
275
+ /**
276
+ * Corpus scope — the commit floor. Total by definition: every project-spec the
277
+ * corpus holds is checked, and a spec that cannot be classified fails rather than
278
+ * being skipped.
279
+ *
280
+ * It runs BOTH sub-checks because neither subsumes the other. The sweep iterates
281
+ * the specs discovery *recognizes*, so a spec whose lifecycle `status` is a typo is
282
+ * invisible to it — and would report clean. The coverage guard sees that file on
283
+ * disk and escalates it, but says nothing about whether the engines pass. Run either
284
+ * alone and a whole project-spec leaves the floor silently.
285
+ */
286
+ function checkCorpus(): number {
287
+ const root = findRepoRoot(process.cwd())
288
+ if (!root) {
289
+ process.stderr.write('check-project-specs: no pnpm-workspace.yaml found above the cwd\n')
290
+ return 1
291
+ }
292
+
293
+ // Coverage first, but it never short-circuits the sweep: one run reports every
294
+ // defect it can see, so an author fixes them in one pass rather than one per run.
295
+ let failed = checkCoverage(root) === 0 ? 0 : 1
296
+
297
+ const specs = collectSpecs(root)
298
+ if (specs.length === 0) {
299
+ process.stdout.write('check-project-specs: the corpus holds no project-spec\n')
300
+ return failed
301
+ }
302
+ for (const s of specs) {
303
+ process.stdout.write(`check-project-specs: ${s.path}\n`)
304
+ if (runEngines(root, join(root, s.path)) > 0) failed = 1
305
+ }
306
+ return failed
307
+ }
308
+
215
309
  function checkProject(argv: string[]): number {
216
310
  const projectArg = argv.includes('--project') ? argv[argv.indexOf('--project') + 1] : undefined
217
311
  const projectDir = resolve(projectArg ?? process.cwd())
@@ -256,22 +350,7 @@ function checkProject(argv: string[]): number {
256
350
  const specDir = join(repoRoot, res.spec.path)
257
351
  process.stdout.write(`check-project-specs: ${projectRel} -> ${res.spec.path}\n`)
258
352
 
259
- let failed = 0
260
- for (const e of ENGINES) {
261
- // cwd is the repo root, not the project dir: the engines resolve
262
- // repo-root-relative references against process.cwd().
263
- try {
264
- execFileSync('node', [join(SKILLS_DIR, e.script), ...e.args(specDir)], {
265
- cwd: repoRoot,
266
- stdio: 'inherit',
267
- })
268
- process.stdout.write(` ok ${e.name}\n`)
269
- } catch {
270
- process.stderr.write(` FAIL ${e.name}\n`)
271
- failed++
272
- }
273
- }
274
- return failed === 0 ? 0 : 1
353
+ return runEngines(repoRoot, specDir) === 0 ? 0 : 1
275
354
  }
276
355
 
277
356
  if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
@@ -0,0 +1,22 @@
1
+ # check-spec-references
2
+
3
+ Internal SDD skill — the concrete engine for the **spec-reference resolution check**. Walks every
4
+ `.md` under one project spec, resolves each explicitly-relative reference against the file's own
5
+ directory, and reports the ones that resolve to nothing.
6
+
7
+ ```bash
8
+ node scripts/check-spec-references.mts --spec-dir <specDir>
9
+ ```
10
+
11
+ Exits non-zero on any unresolved reference, reporting each as `file:line: <ref> -> <resolved path>` — naming the resolved path is the
12
+ point, since the reference as written is exactly what read as plausible during review. Two forms
13
+ are extracted: markdown link targets and inline-code spans, and only when the path begins `./` or
14
+ `../`. Bare paths, URLs, `~/`-relative paths, and anything inside a fenced code block are prose,
15
+ not references — which is what makes a repo-root-relative reference pass by construction. A
16
+ directory target resolves, trailing slash and all. A line carrying `<!-- spec-ref-ignore: why -->`
17
+ has none of its references extracted, for the one real false-positive class: prose quoting a path
18
+ relative to something other than the file it sits in.
19
+
20
+ Read-only; writes nothing. Run from `check-project-specs`' engine set. See [`SKILL.md`](./SKILL.md)
21
+ for the full contract; the `project-spec/check-spec-references` node of the SDD project spec
22
+ (repo-only) carries the frozen spec. Not user-invocable.
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: check-spec-references
3
+ description: "Partial Skill: invoke by name only — project-spec/check-spec-references' engine that resolves every relative reference in one project spec's markdown and fails on the ones pointing at nothing — run from the per-project CI entrypoint, not triggered by users directly."
4
+ user-invocable: false
5
+ metadata:
6
+ internal: true
7
+ ---
8
+
9
+ # Check Spec References
10
+
11
+ The concrete engine for the **spec-reference resolution check**. It walks every `.md` under one
12
+ project spec, extracts each **explicitly-relative** reference, resolves it **against the file's own
13
+ directory**, and fails on every one that resolves to nothing. Read-only, deterministic, and — unlike
14
+ its `check-spec-structure` sibling — single-severity: a reference that does not resolve is always a
15
+ defect, so there is one mode, not a report mode and a gate mode. It carries a self-contained `.mts`
16
+ script (the repo's node-≥23.6 / no-deps convention).
17
+
18
+ It closes a failure **review cannot catch**: a consistent off-by-one reads as entirely plausible —
19
+ right filename, right-looking depth, wrong level — so re-reading a reference by eye is not a check
20
+ on it. The `project-spec/check-spec-references` node of the SDD project spec (repo-only) carries the
21
+ full design rationale, the actors, and the control-flow graph the scenarios derive from.
22
+
23
+ ## What counts as a reference
24
+
25
+ Two forms, and only when the path is **explicitly relative** (`./` or `../`):
26
+
27
+ | Form | Where |
28
+ |---|---|
29
+ | a markdown link target — plain, angle-bracket-wrapped, titled, or a `[label]: path` definition | outside any code span |
30
+ | an inline-code span **whose whole content is the path** — a path followed by further text is prose | anywhere but a fenced block |
31
+
32
+ **Everything else is prose, not a reference** — which is what makes a repo-root-relative reference
33
+ (`.research/<topic>/`) pass by construction rather than by exception. Never extracted: a bare path,
34
+ a URL, an absolute path, a `~/`-relative path, and anything inside a **fenced code block** (tracked
35
+ by the character and run length that opened it, so a fence-shaped line of the other delimiter inside
36
+ a block neither ends it nor inverts the parity for everything after).
37
+
38
+ Code spans are read the way CommonMark delimits them — a run of N backticks opens, the next run of
39
+ exactly N closes, a run that never closes is literal text the scan resumes after (a stray backtick
40
+ never swallows what comes after it), and a span may **wrap across a line break** — the line ending
41
+ folds to a space, so a long path in wrapped prose is still one span, reported against the line it
42
+ opens on. A span is bounded by its **block** — it reaches no further than the next blank line,
43
+ heading, list item, blockquote, thematic break, or fence — which is what keeps one unclosed backtick
44
+ from pairing into the next block and swallowing the references there. One rule settles the exhibit case with no exception: a span written around another
45
+ span has content that still carries backticks, so it is not a path (this is how a spec shows the
46
+ reference form it specifies without firing on itself), while a span written around a bare path has
47
+ that path as its content and **is** a reference however many backticks opened it. A markdown link
48
+ inside a code span is text on display, not a link.
49
+
50
+ ## Resolution
51
+
52
+ Against the directory of the file that **carries** the reference — never the spec root, never the
53
+ repo root. A trailing `#fragment` is stripped first. A reference resolves when the result exists as
54
+ a **file or a directory**; a trailing slash is immaterial.
55
+
56
+ Each finding names **both** the reference as written and the path it resolved to — the second is the
57
+ half a reader cannot supply by eye.
58
+
59
+ ## The escape hatch
60
+
61
+ A line carrying `<!-- spec-ref-ignore -->` has **none of its references** extracted. It takes an
62
+ optional reason (`<!-- spec-ref-ignore: quoting the bridge file's own content -->`) and the
63
+ convention is to write one. Its scope is exactly the line it appears on; it is matched as a
64
+ **complete comment** (a marker whose name merely starts with this one's does not suppress) and read
65
+ from **outside the code spans**, so a line that merely quotes the marker keeps its references.
66
+
67
+ > Use it for a path that is **correct but not resolvable from here** — prose quoting a symlink
68
+ > target, or a bridge file's own content. A genuinely broken reference is fixed, never marked.
69
+
70
+ ## Run the check
71
+
72
+ ```bash
73
+ node "<skill>/scripts/check-spec-references.mts" --spec-dir <specDir>
74
+ ```
75
+
76
+ - Exits **zero** on a spec whose every reference resolves, printing a definitive clean line.
77
+ - Exits **non-zero** on any unresolved reference, printing **every** one as
78
+ `file:line: <ref> -> <resolved>` plus a count — every finding, never the first, because a single
79
+ off-by-one lands as a whole family and one-at-a-time would take as many runs to clear as there are
80
+ levels wrong.
81
+ - Findings are ordered by file, then line, then reference, so two runs over an unchanged tree are
82
+ byte-identical. Paths render relative to the cwd.
83
+ - A missing `--spec-dir` is refused rather than defaulted.
84
+ - Run from `check-project-specs`' engine set, with cwd = the repo root.
85
+
86
+ When `node` is absent, an agent performs the same derivation by hand: list the `.md` files under the
87
+ spec dir, pull each `./`/`../` markdown link target and inline-code path (skipping fenced blocks,
88
+ displayed spans, and marked lines), resolve each against its own file's directory, and check the
89
+ path exists.
90
+
91
+ ## Boundaries
92
+
93
+ Read-only — it writes nothing and fixes no reference (an author edits, or a formation pass does at
94
+ corpus scale). It never judges whether a reference is the *right* artifact to cite, only that the
95
+ cited path exists; it never follows a reference's content, never checks link text, and never reaches
96
+ outside the spec dir it is given.
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env node
2
+ // check-spec-references — resolve every explicitly-relative reference a project spec's .md files
3
+ // carry, and report the ones that resolve to nothing.
4
+ //
5
+ // The recurrence this closes was not a typo but a CONSISTENT OFF-BY-ONE: every `../../../src/…`
6
+ // reference in a spec corpus was one directory level short, and every one of them read as entirely
7
+ // plausible — right filename, right-looking depth, wrong level. Re-reading a reference by eye is
8
+ // not a check on it; only resolving it is. So the finding names BOTH the reference as written and
9
+ // the path it actually resolved to — the second is the half review cannot supply.
10
+ //
11
+ // Scope is deliberately narrow. Only an EXPLICITLY-RELATIVE path (`./` or `../`) is a reference; a
12
+ // bare path in inline code is prose. A spec corpus is full of bare paths that are illustrative or
13
+ // relative to somewhere other than the repo (`cli/`, `skills/doctor/`, `.claude/skills`,
14
+ // `~/.codex/config.toml`), and resolving those would reject nearly all of them. The prefix rule is
15
+ // what makes the repo-root-relative case (`.research/agentic-configuration-standards/`) pass by
16
+ // construction rather than by exception.
17
+ //
18
+ // There is exactly ONE escape hatch, deliberately. A second, implicit one was built and then cut:
19
+ // excluding whatever a doubled-backtick span holds, on the theory that such a span exhibits markup
20
+ // rather than citing it. It was unconditional, so a genuinely broken reference written that way
21
+ // escaped silently — reproducing, inside the exclusion, the exact "looks anchored, isn't" failure
22
+ // this engine exists to close. An escape must be explicit, reasoned, and visible where it applies.
23
+ //
24
+ // One false-positive class is genuine and recurs: prose QUOTING a path relative to something other
25
+ // than the file it sits in — the text held inside a bridge file, a symlink target relative to
26
+ // `.cursor/`. That is structurally indistinguishable from a real anchor, so an inline
27
+ // `<!-- spec-ref-ignore: why -->` marker suppresses its own line, keeping the justification beside
28
+ // the prose it excuses instead of in a registry that drifts away from what it covers.
29
+ //
30
+ // Pure functions are exported for node:test; running the file directly drives the CLI. No
31
+ // dependencies (the repo's node-≥23.6 / no-deps convention).
32
+
33
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
34
+ import { dirname, join, relative, resolve } from 'node:path'
35
+ import { pathToFileURL } from 'node:url'
36
+
37
+ // ── Extraction ──
38
+
39
+ /** A reference as written, and the 1-based line it sits on. */
40
+ export interface Reference {
41
+ line: number
42
+ ref: string
43
+ }
44
+
45
+ /** Suppresses every reference on the line it appears on. Matched as a COMPLETE html comment, not
46
+ * as a substring: a marker whose name merely starts with this one's (a typo, a future sibling)
47
+ * must not silently inherit its power to hide a reference. The optional `: reason` is convention,
48
+ * not syntax — the marker is recognized with or without one. */
49
+ const IGNORE_MARKER_RE = /<!--\s*spec-ref-ignore\s*(?::[^>]*)?-->/
50
+
51
+ /** A markdown inline link whose target is explicitly relative — covering the plain form, an
52
+ * angle-bracket-wrapped target, and an optional title in any of markdown's three quotings. Run only
53
+ * over the parts of a line that are NOT inside a code span: inside one, markup is literal text on
54
+ * display, not a link. */
55
+ const LINK_RE = /\]\(\s*(?:<([^>\n]*)>|(\.{1,2}\/[^)\s]*))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g
56
+
57
+ /** A reference-style link definition (`[label]: ../path`) whose target is explicitly relative. It
58
+ * is a link target like any other — the label form changes where the path is written, not what it
59
+ * points at. */
60
+ const LINK_DEF_RE = /^\s{0,3}\[.+\]:\s*(?:<([^>\n]*)>|(\S+))/
61
+
62
+ /** An explicitly-relative path, and nothing else. The trailing class excludes whitespace and
63
+ * backticks so that "the span's WHOLE content is the path" means what it says: a span holding
64
+ * `../a` plus commentary is prose that starts with a path, not a citation. */
65
+ const RELATIVE_RE = /^\.{1,2}\/[^\s`]*$/
66
+
67
+ /** The same, minus the no-whitespace rule — for an ANGLE-BRACKET link target, whose whole reason
68
+ * for existing is to carry a path with a space in it. */
69
+ const RELATIVE_IN_ANGLES_RE = /^\.{1,2}\/[^`]*$/
70
+
71
+ /** An open fence, remembered so the close can be matched against it. */
72
+ interface Fence {
73
+ char: '`' | '~'
74
+ length: number
75
+ }
76
+
77
+ /**
78
+ * The fence a line opens, or `undefined`. CommonMark: three or more backticks or tildes, indented
79
+ * fewer than four spaces.
80
+ */
81
+ function fenceOpenedBy(line: string): Fence | undefined {
82
+ const m = /^ {0,3}(`{3,}|~{3,})/.exec(line)
83
+ if (m === null) return undefined
84
+ const run = m[1] as string
85
+ return { char: run[0] as '`' | '~', length: run.length }
86
+ }
87
+
88
+ /**
89
+ * Whether `line` CLOSES `fence`: the same character, at least as long, and nothing but whitespace
90
+ * after it.
91
+ *
92
+ * Matching the delimiter rather than toggling a boolean on any fence-shaped line is what keeps the
93
+ * exclusion honest. A bare toggle flips on a `~~~` line inside a backtick fence — so a reference
94
+ * genuinely inside a fence gets extracted, and, worse, a fence-shaped line inside another fence
95
+ * leaves the parity inverted and every genuinely broken reference AFTER the block is silently
96
+ * swallowed. That is this engine's own failure class, reached through the one rule meant to
97
+ * suppress noise.
98
+ */
99
+ function fenceClosedBy(line: string, fence: Fence): boolean {
100
+ const m = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)
101
+ if (m === null) return false
102
+ const run = m[1] as string
103
+ return run[0] === fence.char && run.length >= fence.length
104
+ }
105
+
106
+ /**
107
+ * A line that starts a new block: blank, an ATX heading, a blockquote, a list item, or a thematic
108
+ * break. Inline parsing happens WITHIN a block, so a code span cannot reach across one of these —
109
+ * and the boundary matters in the under-reporting direction. An unclosed backtick left by a typo
110
+ * would otherwise pair with the first backtick of the next block, swallowing the span that was
111
+ * about to open and every reference after it in the flow.
112
+ */
113
+ export function startsNewBlock(line: string): boolean {
114
+ if (/^[ \t]*$/.test(line)) return true
115
+ if (/^ {0,3}#{1,6}(\s|$)/.test(line)) return true // ATX heading
116
+ if (/^ {0,3}>/.test(line)) return true // blockquote
117
+ if (/^ {0,3}([-+*]|\d{1,9}[.)])(\s|$)/.test(line)) return true // list item
118
+ if (/^ {0,3}((\*[ \t]*){3,}|(-[ \t]*){3,}|(_[ \t]*){3,})$/.test(line)) return true // thematic break
119
+ return false
120
+ }
121
+
122
+ interface CodeSpan {
123
+ /** The span's content, line endings folded to spaces and CommonMark's one-space padding stripped. */
124
+ content: string
125
+ /** Offset into the scanned text where the opening run begins. */
126
+ start: number
127
+ end: number
128
+ }
129
+
130
+ /** Same length, same line breaks, no content — so blanking a region shifts no offset and moves no
131
+ * finding to another line. */
132
+ function blankOut(region: string): string {
133
+ return region.replace(/[^\n]/g, ' ')
134
+ }
135
+
136
+ /**
137
+ * The inline-code spans on one line, scanned the way CommonMark delimits them: a run of N backticks
138
+ * opens, the next run of EXACTLY N closes, and one leading + trailing space is stripped when both
139
+ * are present.
140
+ *
141
+ * Scanning properly rather than matching a single-backtick pair is what makes the reference rule —
142
+ * "a code span is a reference when its WHOLE content is the path" — mean one thing everywhere. A
143
+ * doubled span written around another span (`` `x` ``) has content that still carries backticks, so
144
+ * it is not a path and not a reference; a doubled span written around a bare path has the path as
145
+ * its content, so it IS one. Neither is a special case, and neither leaves a broken reference
146
+ * anywhere to hide.
147
+ */
148
+ export function scanCodeSpans(text: string, blockStarts: readonly number[] = []): CodeSpan[] {
149
+ const out: CodeSpan[] = []
150
+ let i = 0
151
+ while (i < text.length) {
152
+ if (text[i] !== '`') {
153
+ i++
154
+ continue
155
+ }
156
+ const open = i
157
+ while (text[i] === '`') i++
158
+ const runLength = i - open
159
+ // find the next run of exactly runLength backticks, stopping at the next block boundary — a
160
+ // code span lives inside one block and cannot reach past it
161
+ const limit = blockStarts.find((b) => b > open) ?? text.length
162
+ let j = i
163
+ let closeStart = -1
164
+ while (j < limit) {
165
+ if (text[j] !== '`') {
166
+ j++
167
+ continue
168
+ }
169
+ const runStart = j
170
+ while (text[j] === '`') j++
171
+ if (j - runStart === runLength) {
172
+ closeStart = runStart
173
+ break
174
+ }
175
+ }
176
+ if (closeStart === -1) {
177
+ // An unmatched run is literal text, and the scan RESUMES after it — CommonMark's own
178
+ // recovery. Abandoning the rest of the text instead would let one stray backtick
179
+ // silently swallow every reference after it, which is this engine's own failure class
180
+ // wearing a different hat.
181
+ i = open + runLength
182
+ continue
183
+ }
184
+ // Line endings inside a span are spaces, per CommonMark — which is what lets a span that
185
+ // WRAPS still be one span. Prose here hard-wraps, so a long path in backticks landing
186
+ // across a line break is ordinary, not exotic; scanning line by line would have left every
187
+ // wrapped reference unread.
188
+ let content = text.slice(open + runLength, closeStart).replace(/\r?\n/g, ' ')
189
+ if (content.length > 1 && content.startsWith(' ') && content.endsWith(' ') && content.trim() !== '') {
190
+ content = content.slice(1, -1)
191
+ }
192
+ out.push({ content, start: open, end: j })
193
+ i = j
194
+ }
195
+ return out
196
+ }
197
+
198
+ /**
199
+ * Every explicitly-relative reference in `text`, in document order.
200
+ *
201
+ * Skipped: lines inside a fenced code block, and lines carrying the ignore marker. Extraction is
202
+ * line-scoped on purpose — it is what gives each finding a line number, and what lets the marker's
203
+ * scope be exactly the line it appears on rather than the whole file.
204
+ */
205
+ export function extractReferences(text: string): Reference[] {
206
+ const lines = text.split('\n')
207
+
208
+ // 1. Blank the fenced blocks, keeping every line's length and every line break, so offsets and
209
+ // line numbers below still mean what they say.
210
+ let fence: Fence | undefined
211
+ const unfenced = lines.map((line) => {
212
+ if (fence !== undefined) {
213
+ if (fenceClosedBy(line, fence)) fence = undefined
214
+ return blankOut(line)
215
+ }
216
+ const opened = fenceOpenedBy(line)
217
+ if (opened !== undefined) {
218
+ fence = opened
219
+ return blankOut(line)
220
+ }
221
+ return line
222
+ })
223
+
224
+ // 2. Scan code spans across the WHOLE text, not line by line: a span that wraps is still one
225
+ // span, and reading it as two would leave a wrapped reference unread. Bounded at block
226
+ // starts, so widening the window past a line break does not let a stray backtick reach into
227
+ // the next block.
228
+ const scannable = unfenced.join('\n')
229
+ const lineStarts: number[] = []
230
+ let acc = 0
231
+ for (const line of lines) {
232
+ lineStarts.push(acc)
233
+ acc += line.length + 1
234
+ }
235
+ // A blanked fence line is all spaces, so it reads as a block start here too — the fence bounds
236
+ // a span by the same rule as everything else rather than by a happy accident.
237
+ const blockStarts = lineStarts.filter((_start, i) => i > 0 && startsNewBlock(unfenced[i] as string))
238
+ const lineOf = (offset: number): number => {
239
+ let lo = 0
240
+ let hi = lineStarts.length - 1
241
+ while (lo < hi) {
242
+ const mid = (lo + hi + 1) >> 1
243
+ if ((lineStarts[mid] as number) <= offset) lo = mid
244
+ else hi = mid - 1
245
+ }
246
+ return lo
247
+ }
248
+
249
+ const spans = scanCodeSpans(scannable, blockStarts)
250
+ let outsideSpans = scannable
251
+ for (const span of spans) {
252
+ outsideSpans =
253
+ outsideSpans.slice(0, span.start) +
254
+ blankOut(outsideSpans.slice(span.start, span.end)) +
255
+ outsideSpans.slice(span.end)
256
+ }
257
+ const outsideLines = outsideSpans.split('\n')
258
+
259
+ // 3. The marker is read from OUTSIDE the code spans — the same rule, not an exception for the
260
+ // escape hatch. Read from the raw line it would fire on a line that merely QUOTES it, which
261
+ // is how this very node documents it, and that line's real references would vanish: an
262
+ // escape hatch a description of the escape hatch can trigger hides exactly what this engine
263
+ // exists to find. A wrapped span is judged by the line it OPENS on — the line a reader
264
+ // would put the marker beside.
265
+ const marked = outsideLines.map((line) => IGNORE_MARKER_RE.test(line))
266
+
267
+ const perLine = new Map<number, Set<string>>()
268
+ const add = (line: number, ref: string) => {
269
+ if (marked[line] === true) return
270
+ const set = perLine.get(line) ?? new Set<string>()
271
+ set.add(ref)
272
+ perLine.set(line, set)
273
+ }
274
+
275
+ for (const span of spans) {
276
+ const content = span.content.trim()
277
+ if (RELATIVE_RE.test(content)) add(lineOf(span.start), content)
278
+ }
279
+
280
+ outsideLines.forEach((line, i) => {
281
+ const addTarget = (angled: string | undefined, bare: string | undefined) => {
282
+ if (angled !== undefined) {
283
+ if (RELATIVE_IN_ANGLES_RE.test(angled)) add(i, angled)
284
+ return
285
+ }
286
+ if (bare !== undefined && RELATIVE_RE.test(bare)) add(i, bare)
287
+ }
288
+ for (const m of line.matchAll(LINK_RE)) addTarget(m[1], m[2])
289
+ const def = LINK_DEF_RE.exec(line)
290
+ if (def) addTarget(def[1], def[2])
291
+ })
292
+
293
+ const out: Reference[] = []
294
+ for (const line of [...perLine.keys()].sort((a, b) => a - b)) {
295
+ for (const ref of perLine.get(line) as Set<string>) out.push({ line: line + 1, ref })
296
+ }
297
+ return out
298
+ }
299
+
300
+ // ── Resolution ──
301
+
302
+ /**
303
+ * Where `ref` points, resolved against `fileDir` — the directory of the file that CARRIES it,
304
+ * never the spec root and never the repo root. A trailing `#fragment` is stripped first; a
305
+ * trailing slash is immaterial (`resolve` drops it), so a directory reference resolves either way.
306
+ */
307
+ export function resolveReference(fileDir: string, ref: string): string {
308
+ const withoutFragment = ref.replace(/#.*$/, '')
309
+ return resolve(fileDir, withoutFragment)
310
+ }
311
+
312
+ // ── The walk ──
313
+
314
+ /** Every `.md` file under `dir`, at any depth, absolute and sorted so the report is stable. */
315
+ export function listMarkdownFiles(dir: string): string[] {
316
+ const out: string[] = []
317
+ const walk = (d: string) => {
318
+ for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
319
+ const p = join(d, e.name)
320
+ if (e.isDirectory()) walk(p)
321
+ else if (e.name.endsWith('.md')) out.push(p)
322
+ }
323
+ }
324
+ walk(dir)
325
+ return out
326
+ }
327
+
328
+ // ── Audit ──
329
+
330
+ export interface Finding {
331
+ /** Absolute path of the file carrying the reference. */
332
+ file: string
333
+ line: number
334
+ /** The reference exactly as written. */
335
+ ref: string
336
+ /** The absolute path it resolved to — the half a reader cannot supply by eye. */
337
+ resolved: string
338
+ }
339
+
340
+ export interface AuditOptions {
341
+ /** Substitute the markdown walk (tests assert which files are visited). */
342
+ listMarkdownFiles?: (dir: string) => string[]
343
+ /** Substitute the file reader (tests assert nothing outside the walk is read). */
344
+ readFile?: (path: string) => string
345
+ /** Substitute the existence probe. */
346
+ exists?: (path: string) => boolean
347
+ }
348
+
349
+ /**
350
+ * Every unresolved reference under `specDir`, ordered by file, then line, then reference — so two
351
+ * runs over an unchanged tree render byte-identically.
352
+ *
353
+ * EVERY finding, never the first: a single off-by-one lands as a whole family of broken
354
+ * references, and reporting one at a time would take as many runs to clear as there are levels
355
+ * wrong.
356
+ */
357
+ export function audit(specDir: string, options: AuditOptions = {}): Finding[] {
358
+ const list = options.listMarkdownFiles ?? listMarkdownFiles
359
+ const read = options.readFile ?? ((p: string) => readFileSync(p, 'utf8'))
360
+ const has = options.exists ?? existsSync
361
+
362
+ const findings: Finding[] = []
363
+ for (const file of list(specDir)) {
364
+ const fileDir = dirname(file)
365
+ for (const { line, ref } of extractReferences(read(file))) {
366
+ const resolved = resolveReference(fileDir, ref)
367
+ if (!has(resolved)) findings.push({ file, line, ref, resolved })
368
+ }
369
+ }
370
+ return findings.sort((a, b) =>
371
+ a.file !== b.file ? (a.file < b.file ? -1 : 1) : a.line !== b.line ? a.line - b.line : a.ref < b.ref ? -1 : 1,
372
+ )
373
+ }
374
+
375
+ // ── Report ──
376
+
377
+ /** Renders each finding as `file:line: <ref> -> <resolved>`, both paths relative to `base` so the
378
+ * report reads the same from any checkout. */
379
+ export function formatFindings(findings: Finding[], base: string): string {
380
+ if (findings.length === 0) return 'check-spec-references: every relative reference resolves\n'
381
+ const lines = findings.map(
382
+ (f) =>
383
+ ` ${relative(base, f.file)}:${f.line}: \`${f.ref}\` -> ${relative(base, f.resolved)} — no file or directory there\n`,
384
+ )
385
+ lines.push(`check-spec-references: ${findings.length} unresolved reference(s)\n`)
386
+ return lines.join('')
387
+ }
388
+
389
+ // ── CLI ──
390
+
391
+ // One mode, deliberately. A report-only mode would differ from the guard by exit code alone —
392
+ // the same findings, printed the same way — and no actor wants the list without wanting it fixed.
393
+ // Every finding is a defect, so every finding fails the run.
394
+ export function main(argv: string[], cwd: string = process.cwd()): number {
395
+ const i = argv.indexOf('--spec-dir')
396
+ const specDir = i === -1 ? '' : (argv[i + 1] ?? '')
397
+ if (specDir === '') {
398
+ process.stderr.write('check-spec-references: --spec-dir <dir> is required\n')
399
+ return 1
400
+ }
401
+
402
+ const dir = resolve(cwd, specDir)
403
+ // A spec dir that is not there is refused by name, never as a raw stack trace: a mistyped path
404
+ // must not read like a corpus that has no markdown in it.
405
+ if (!existsSync(dir)) {
406
+ process.stderr.write(`check-spec-references: no directory at ${relative(cwd, dir)}\n`)
407
+ return 1
408
+ }
409
+
410
+ const findings = audit(dir)
411
+ const report = formatFindings(findings, cwd)
412
+ if (findings.length === 0) {
413
+ process.stdout.write(report)
414
+ return 0
415
+ }
416
+ process.stderr.write(report)
417
+ return 1
418
+ }
419
+
420
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
421
+ process.exit(main(process.argv.slice(2)))
422
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 unional
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.