cyber-sdd 0.2.0 → 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.
@@ -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.