cyber-sdd 0.7.0 → 0.7.1

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.
Files changed (149) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/agents/sdd-scanner.md +1 -1
  4. package/package.json +11 -13
  5. package/plugin.json +55 -0
  6. package/skills/align-spec/SKILL.md +1 -1
  7. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  8. package/skills/blast-estimate/README.md +3 -3
  9. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  10. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  11. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  12. package/skills/check-plan-safety/SKILL.md +2 -3
  13. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  14. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  15. package/skills/check-project-specs/SKILL.md +1 -1
  16. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  17. package/skills/check-retired-terms/SKILL.md +2 -3
  18. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  19. package/skills/check-scenario-overlap/SKILL.md +1 -2
  20. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  21. package/skills/check-spec-references/SKILL.md +2 -2
  22. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  23. package/skills/check-spec-structure/SKILL.md +1 -2
  24. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  25. package/skills/collision-ladder/README.md +2 -2
  26. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  27. package/skills/combat-log-governance/SKILL.md +1 -1
  28. package/skills/concept-index/SKILL.md +1 -1
  29. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  30. package/skills/discover-plans/README.md +3 -3
  31. package/skills/discover-plans/SKILL.md +1 -1
  32. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  33. package/skills/discover-specs/README.md +3 -3
  34. package/skills/discover-specs/SKILL.md +1 -1
  35. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  36. package/skills/doctrine-loop/SKILL.md +1 -1
  37. package/skills/gate-validation-governance/SKILL.md +1 -1
  38. package/skills/init/README.md +2 -2
  39. package/skills/init/scripts/wire-statusline.mjs +168 -12
  40. package/skills/manage-ignore/README.md +3 -3
  41. package/skills/manage-ignore/SKILL.md +1 -2
  42. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  43. package/skills/manage-scenario-bridge/README.md +3 -3
  44. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  45. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  46. package/skills/manage-spec-anchors/README.md +3 -3
  47. package/skills/manage-spec-anchors/SKILL.md +1 -1
  48. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  49. package/skills/mission-graph/README.md +2 -2
  50. package/skills/mission-graph/SKILL.md +1 -1
  51. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  52. package/skills/pause-mission/SKILL.md +1 -1
  53. package/skills/place-node/SKILL.md +2 -2
  54. package/skills/place-node/scripts/place-node.mjs +117 -12
  55. package/skills/plan-retirement/README.md +2 -2
  56. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  57. package/skills/resolve-governances/README.md +3 -3
  58. package/skills/resolve-governances/SKILL.md +1 -1
  59. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  60. package/skills/resolve-tracking/SKILL.md +1 -1
  61. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  62. package/skills/scaffold-project-spec/SKILL.md +1 -1
  63. package/skills/spec-gate/README.md +12 -9
  64. package/skills/spec-gate/SKILL.md +2 -2
  65. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  66. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  67. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  68. package/skills/spec-producer-governance/SKILL.md +2 -2
  69. package/skills/start-mission/SKILL.md +1 -1
  70. package/skills/suite-format-governance/README.md +1 -1
  71. package/skills/suite-format-governance/SKILL.md +1 -1
  72. package/skills/touch-set-correction/README.md +5 -5
  73. package/skills/touch-set-correction/SKILL.md +1 -1
  74. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  75. package/skills/verify-scenarios/README.md +6 -6
  76. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  77. package/.plugin/pins.json +0 -3
  78. package/.plugin/plugin.json +0 -19
  79. package/dist/align-spec/scripts/align-spec.js +0 -102
  80. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  81. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  82. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  83. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  84. package/dist/concept-index/scripts/concept-index.js +0 -181
  85. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  86. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  87. package/dist/spec-gate/scripts/check-suite.js +0 -317
  88. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  89. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  90. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  91. package/skills/align-spec/scripts/align-spec.mts +0 -188
  92. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  93. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  94. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  95. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  96. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  97. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  98. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  99. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  100. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  102. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  103. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  104. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  105. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  106. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  108. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  109. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  110. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  111. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  112. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  113. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  114. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  115. package/skills/concept-index/scripts/concept-index.mts +0 -246
  116. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  117. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  118. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  119. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  120. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  121. package/skills/init/scripts/wire-statusline.mts +0 -279
  122. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  123. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  124. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  126. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  128. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  129. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  130. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  131. package/skills/place-node/scripts/place-node.mts +0 -158
  132. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  133. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  134. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  135. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  136. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  137. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  138. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  139. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  140. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  141. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  142. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  143. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  144. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  145. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  146. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  147. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  148. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  149. package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
@@ -1,12 +1,287 @@
1
- // Launcher for the engine of the same name. Run it as `node <skill>/scripts/<engine>.mjs`.
2
- // A checkout runs the .mts source. An npm install keeps the skill under node_modules, where Node
3
- // will not strip types, so it runs the self-contained bundle built for the package.
4
- import { spawnSync } from 'node:child_process'
5
- import { fileURLToPath } from 'node:url'
6
-
7
- const self = fileURLToPath(import.meta.url)
8
- const installed = /[\\/]node_modules[\\/]/.test(self)
9
- const target = self.replace(/\.mjs$/, installed ? '.bundle.mjs' : '.mts')
10
- const run = spawnSync(process.execPath, [...process.execArgv, target, ...process.argv.slice(2)], { stdio: 'inherit' })
11
- if (run.error) throw run.error
12
- process.exitCode = run.status ?? 1
1
+ #!/usr/bin/env node
2
+ // Compiled by `universal-plugin plugin build` from src/check-spec-references/check-spec-references.mts. Edit the source, not this file.
3
+ globalThis.__SKILL_SCRIPT__ = {"package":null,"bin":null,"version":"0.7.1","entry":"skills/check-spec-references/scripts/check-spec-references.mjs","commands":{}};
4
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ //#region src/check-spec-references/check-spec-references.mts
8
+ /** Suppresses every reference on the line it appears on. Matched as a COMPLETE html comment, not
9
+ * as a substring: a marker whose name merely starts with this one's (a typo, a future sibling)
10
+ * must not silently inherit its power to hide a reference. The optional `: reason` is convention,
11
+ * not syntax — the marker is recognized with or without one. */
12
+ const IGNORE_MARKER_RE = /<!--\s*spec-ref-ignore\s*(?::[^>]*)?-->/;
13
+ /** A markdown inline link whose target is explicitly relative — covering the plain form, an
14
+ * angle-bracket-wrapped target, and an optional title in any of markdown's three quotings. Run only
15
+ * over the parts of a line that are NOT inside a code span: inside one, markup is literal text on
16
+ * display, not a link. */
17
+ const LINK_RE = /\]\(\s*(?:<([^>\n]*)>|(\.{1,2}\/[^)\s]*))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
18
+ /** A reference-style link definition (`[label]: ../path`) whose target is explicitly relative. It
19
+ * is a link target like any other — the label form changes where the path is written, not what it
20
+ * points at. */
21
+ const LINK_DEF_RE = /^\s{0,3}\[.+\]:\s*(?:<([^>\n]*)>|(\S+))/;
22
+ /** An explicitly-relative path, and nothing else. The trailing class excludes whitespace and
23
+ * backticks so that "the span's WHOLE content is the path" means what it says: a span holding
24
+ * `../a` plus commentary is prose that starts with a path, not a citation. */
25
+ const RELATIVE_RE = /^\.{1,2}\/[^\s`]*$/;
26
+ /** The same, minus the no-whitespace rule — for an ANGLE-BRACKET link target, whose whole reason
27
+ * for existing is to carry a path with a space in it. */
28
+ const RELATIVE_IN_ANGLES_RE = /^\.{1,2}\/[^`]*$/;
29
+ /**
30
+ * The fence a line opens, or `undefined`. CommonMark: three or more backticks or tildes, indented
31
+ * fewer than four spaces.
32
+ */
33
+ function fenceOpenedBy(line) {
34
+ const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
35
+ if (m === null) return void 0;
36
+ const run = m[1];
37
+ return {
38
+ char: run[0],
39
+ length: run.length
40
+ };
41
+ }
42
+ /**
43
+ * Whether `line` CLOSES `fence`: the same character, at least as long, and nothing but whitespace
44
+ * after it.
45
+ *
46
+ * Matching the delimiter rather than toggling a boolean on any fence-shaped line is what keeps the
47
+ * exclusion honest. A bare toggle flips on a `~~~` line inside a backtick fence — so a reference
48
+ * genuinely inside a fence gets extracted, and, worse, a fence-shaped line inside another fence
49
+ * leaves the parity inverted and every genuinely broken reference AFTER the block is silently
50
+ * swallowed. That is this engine's own failure class, reached through the one rule meant to
51
+ * suppress noise.
52
+ */
53
+ function fenceClosedBy(line, fence) {
54
+ const m = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
55
+ if (m === null) return false;
56
+ const run = m[1];
57
+ return run[0] === fence.char && run.length >= fence.length;
58
+ }
59
+ /**
60
+ * A line that starts a new block: blank, an ATX heading, a blockquote, a list item, or a thematic
61
+ * break. Inline parsing happens WITHIN a block, so a code span cannot reach across one of these —
62
+ * and the boundary matters in the under-reporting direction. An unclosed backtick left by a typo
63
+ * would otherwise pair with the first backtick of the next block, swallowing the span that was
64
+ * about to open and every reference after it in the flow.
65
+ */
66
+ function startsNewBlock(line) {
67
+ if (/^[ \t]*$/.test(line)) return true;
68
+ if (/^ {0,3}#{1,6}(\s|$)/.test(line)) return true;
69
+ if (/^ {0,3}>/.test(line)) return true;
70
+ if (/^ {0,3}([-+*]|\d{1,9}[.)])(\s|$)/.test(line)) return true;
71
+ if (/^ {0,3}((\*[ \t]*){3,}|(-[ \t]*){3,}|(_[ \t]*){3,})$/.test(line)) return true;
72
+ return false;
73
+ }
74
+ /** Same length, same line breaks, no content — so blanking a region shifts no offset and moves no
75
+ * finding to another line. */
76
+ function blankOut(region) {
77
+ return region.replace(/[^\n]/g, " ");
78
+ }
79
+ /**
80
+ * The inline-code spans on one line, scanned the way CommonMark delimits them: a run of N backticks
81
+ * opens, the next run of EXACTLY N closes, and one leading + trailing space is stripped when both
82
+ * are present.
83
+ *
84
+ * Scanning properly rather than matching a single-backtick pair is what makes the reference rule —
85
+ * "a code span is a reference when its WHOLE content is the path" — mean one thing everywhere. A
86
+ * doubled span written around another span (`` `x` ``) has content that still carries backticks, so
87
+ * it is not a path and not a reference; a doubled span written around a bare path has the path as
88
+ * its content, so it IS one. Neither is a special case, and neither leaves a broken reference
89
+ * anywhere to hide.
90
+ */
91
+ function scanCodeSpans(text, blockStarts = []) {
92
+ const out = [];
93
+ let i = 0;
94
+ while (i < text.length) {
95
+ if (text[i] !== "`") {
96
+ i++;
97
+ continue;
98
+ }
99
+ const open = i;
100
+ while (text[i] === "`") i++;
101
+ const runLength = i - open;
102
+ const limit = blockStarts.find((b) => b > open) ?? text.length;
103
+ let j = i;
104
+ let closeStart = -1;
105
+ while (j < limit) {
106
+ if (text[j] !== "`") {
107
+ j++;
108
+ continue;
109
+ }
110
+ const runStart = j;
111
+ while (text[j] === "`") j++;
112
+ if (j - runStart === runLength) {
113
+ closeStart = runStart;
114
+ break;
115
+ }
116
+ }
117
+ if (closeStart === -1) {
118
+ i = open + runLength;
119
+ continue;
120
+ }
121
+ let content = text.slice(open + runLength, closeStart).replace(/\r?\n/g, " ");
122
+ if (content.length > 1 && content.startsWith(" ") && content.endsWith(" ") && content.trim() !== "") content = content.slice(1, -1);
123
+ out.push({
124
+ content,
125
+ start: open,
126
+ end: j
127
+ });
128
+ i = j;
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Every explicitly-relative reference in `text`, in document order.
134
+ *
135
+ * Skipped: lines inside a fenced code block, and lines carrying the ignore marker. Extraction is
136
+ * line-scoped on purpose — it is what gives each finding a line number, and what lets the marker's
137
+ * scope be exactly the line it appears on rather than the whole file.
138
+ */
139
+ function extractReferences(text) {
140
+ const lines = text.split("\n");
141
+ let fence;
142
+ const unfenced = lines.map((line) => {
143
+ if (fence !== void 0) {
144
+ if (fenceClosedBy(line, fence)) fence = void 0;
145
+ return blankOut(line);
146
+ }
147
+ const opened = fenceOpenedBy(line);
148
+ if (opened !== void 0) {
149
+ fence = opened;
150
+ return blankOut(line);
151
+ }
152
+ return line;
153
+ });
154
+ const scannable = unfenced.join("\n");
155
+ const lineStarts = [];
156
+ let acc = 0;
157
+ for (const line of lines) {
158
+ lineStarts.push(acc);
159
+ acc += line.length + 1;
160
+ }
161
+ const blockStarts = lineStarts.filter((_start, i) => i > 0 && startsNewBlock(unfenced[i]));
162
+ const lineOf = (offset) => {
163
+ let lo = 0;
164
+ let hi = lineStarts.length - 1;
165
+ while (lo < hi) {
166
+ const mid = lo + hi + 1 >> 1;
167
+ if (lineStarts[mid] <= offset) lo = mid;
168
+ else hi = mid - 1;
169
+ }
170
+ return lo;
171
+ };
172
+ const spans = scanCodeSpans(scannable, blockStarts);
173
+ let outsideSpans = scannable;
174
+ for (const span of spans) outsideSpans = outsideSpans.slice(0, span.start) + blankOut(outsideSpans.slice(span.start, span.end)) + outsideSpans.slice(span.end);
175
+ const outsideLines = outsideSpans.split("\n");
176
+ const marked = outsideLines.map((line) => IGNORE_MARKER_RE.test(line));
177
+ const perLine = /* @__PURE__ */ new Map();
178
+ const add = (line, ref) => {
179
+ if (marked[line] === true) return;
180
+ const set = perLine.get(line) ?? /* @__PURE__ */ new Set();
181
+ set.add(ref);
182
+ perLine.set(line, set);
183
+ };
184
+ for (const span of spans) {
185
+ const content = span.content.trim();
186
+ if (RELATIVE_RE.test(content)) add(lineOf(span.start), content);
187
+ }
188
+ outsideLines.forEach((line, i) => {
189
+ const addTarget = (angled, bare) => {
190
+ if (angled !== void 0) {
191
+ if (RELATIVE_IN_ANGLES_RE.test(angled)) add(i, angled);
192
+ return;
193
+ }
194
+ if (bare !== void 0 && RELATIVE_RE.test(bare)) add(i, bare);
195
+ };
196
+ for (const m of line.matchAll(LINK_RE)) addTarget(m[1], m[2]);
197
+ const def = LINK_DEF_RE.exec(line);
198
+ if (def) addTarget(def[1], def[2]);
199
+ });
200
+ const out = [];
201
+ for (const line of [...perLine.keys()].sort((a, b) => a - b)) for (const ref of perLine.get(line)) out.push({
202
+ line: line + 1,
203
+ ref
204
+ });
205
+ return out;
206
+ }
207
+ /**
208
+ * Where `ref` points, resolved against `fileDir` — the directory of the file that CARRIES it,
209
+ * never the spec root and never the repo root. A trailing `#fragment` is stripped first; a
210
+ * trailing slash is immaterial (`resolve` drops it), so a directory reference resolves either way.
211
+ */
212
+ function resolveReference(fileDir, ref) {
213
+ const withoutFragment = ref.replace(/#.*$/, "");
214
+ return resolve(fileDir, withoutFragment);
215
+ }
216
+ /** Every `.md` file under `dir`, at any depth, absolute and sorted so the report is stable. */
217
+ function listMarkdownFiles(dir) {
218
+ const out = [];
219
+ const walk = (d) => {
220
+ for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
221
+ const p = join(d, e.name);
222
+ if (e.isDirectory()) walk(p);
223
+ else if (e.name.endsWith(".md")) out.push(p);
224
+ }
225
+ };
226
+ walk(dir);
227
+ return out;
228
+ }
229
+ /**
230
+ * Every unresolved reference under `specDir`, ordered by file, then line, then reference — so two
231
+ * runs over an unchanged tree render byte-identically.
232
+ *
233
+ * EVERY finding, never the first: a single off-by-one lands as a whole family of broken
234
+ * references, and reporting one at a time would take as many runs to clear as there are levels
235
+ * wrong.
236
+ */
237
+ function audit(specDir, options = {}) {
238
+ const list = options.listMarkdownFiles ?? listMarkdownFiles;
239
+ const read = options.readFile ?? ((p) => readFileSync(p, "utf8"));
240
+ const has = options.exists ?? existsSync;
241
+ const findings = [];
242
+ for (const file of list(specDir)) {
243
+ const fileDir = dirname(file);
244
+ for (const { line, ref } of extractReferences(read(file))) {
245
+ const resolved = resolveReference(fileDir, ref);
246
+ if (!has(resolved)) findings.push({
247
+ file,
248
+ line,
249
+ ref,
250
+ resolved
251
+ });
252
+ }
253
+ }
254
+ return findings.sort((a, b) => a.file !== b.file ? a.file < b.file ? -1 : 1 : a.line !== b.line ? a.line - b.line : a.ref < b.ref ? -1 : 1);
255
+ }
256
+ /** Renders each finding as `file:line: <ref> -> <resolved>`, both paths relative to `base` so the
257
+ * report reads the same from any checkout. */
258
+ function formatFindings(findings, base) {
259
+ if (findings.length === 0) return "check-spec-references: every relative reference resolves\n";
260
+ const lines = findings.map((f) => ` ${relative(base, f.file)}:${f.line}: \`${f.ref}\` -> ${relative(base, f.resolved)} — no file or directory there\n`);
261
+ lines.push(`check-spec-references: ${findings.length} unresolved reference(s)\n`);
262
+ return lines.join("");
263
+ }
264
+ function main(argv, cwd = process.cwd()) {
265
+ const i = argv.indexOf("--spec-dir");
266
+ const specDir = i === -1 ? "" : argv[i + 1] ?? "";
267
+ if (specDir === "") {
268
+ process.stderr.write("check-spec-references: --spec-dir <dir> is required\n");
269
+ return 1;
270
+ }
271
+ const dir = resolve(cwd, specDir);
272
+ if (!existsSync(dir)) {
273
+ process.stderr.write(`check-spec-references: no directory at ${relative(cwd, dir)}\n`);
274
+ return 1;
275
+ }
276
+ const findings = audit(dir);
277
+ const report = formatFindings(findings, cwd);
278
+ if (findings.length === 0) {
279
+ process.stdout.write(report);
280
+ return 0;
281
+ }
282
+ process.stderr.write(report);
283
+ return 1;
284
+ }
285
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
286
+ //#endregion
287
+ export { audit, extractReferences, formatFindings, listMarkdownFiles, main, resolveReference, scanCodeSpans, startsNewBlock };
@@ -11,8 +11,7 @@ metadata:
11
11
  The concrete engine for **spec-structure checking**.
12
12
  It audits the **internal node-shape** of one project spec and returns a finding set for the
13
13
  formation **Warden** — the **intra-spec** successor to the retired cross-spec `dedupe-specs`/`split-spec`
14
- tools, now that one project is **one spec**. It carries a self-contained `.mts` script (the repo's
15
- node-≥23.6 / no-deps convention). It is the **node-shape** sibling of `align-spec` (prose↔suite
14
+ tools, now that one project is **one spec**. It carries a self-contained `.mjs` script (compiled from `src/`; no runtime deps, Node ≥22.11). It is the **node-shape** sibling of `align-spec` (prose↔suite
16
15
  alignment) and `concept-index` (the by-concept view).
17
16
 
18
17
  ## The three deterministic checks (and two judgment arms)
@@ -1,12 +1,243 @@
1
- // Launcher for the engine of the same name. Run it as `node <skill>/scripts/<engine>.mjs`.
2
- // A checkout runs the .mts source. An npm install keeps the skill under node_modules, where Node
3
- // will not strip types, so it runs the self-contained bundle built for the package.
4
- import { spawnSync } from 'node:child_process'
5
- import { fileURLToPath } from 'node:url'
6
-
7
- const self = fileURLToPath(import.meta.url)
8
- const installed = /[\\/]node_modules[\\/]/.test(self)
9
- const target = self.replace(/\.mjs$/, installed ? '.bundle.mjs' : '.mts')
10
- const run = spawnSync(process.execPath, [...process.execArgv, target, ...process.argv.slice(2)], { stdio: 'inherit' })
11
- if (run.error) throw run.error
12
- process.exitCode = run.status ?? 1
1
+ #!/usr/bin/env node
2
+ // Compiled by `universal-plugin plugin build` from src/check-spec-structure/check-spec-structure.mts. Edit the source, not this file.
3
+ globalThis.__SKILL_SCRIPT__ = {"package":null,"bin":null,"version":"0.7.1","entry":"skills/check-spec-structure/scripts/check-spec-structure.mjs","commands":{}};
4
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ //#region src/check-spec-structure/check-spec-structure.mts
8
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
9
+ "node_modules",
10
+ ".git",
11
+ "dist",
12
+ ".turbo",
13
+ ".next",
14
+ "coverage"
15
+ ]);
16
+ const DEFAULT_MAX_SCENARIOS = 40;
17
+ const REQUIRED_BEHAVIORAL_SECTIONS = [
18
+ "What",
19
+ "Use Cases",
20
+ "Control Flow",
21
+ "Scenario map"
22
+ ];
23
+ function parseNodeFrontmatter(text) {
24
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text);
25
+ if (!m) return { concepts: [] };
26
+ const fm = { concepts: [] };
27
+ const lines = m[1].split("\n").map((l) => l.replace(/\r$/, ""));
28
+ for (let i = 0; i < lines.length; i++) {
29
+ const line = lines[i];
30
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
31
+ if (line.length - line.trimStart().length !== 0) continue;
32
+ const [key, ...rest] = line.trim().split(":");
33
+ const value = rest.join(":").trim();
34
+ if (key === "spec-type") fm.specType = unquote(value);
35
+ else if (key === "concept") {
36
+ if (value === "" || value === "|" || value === ">") for (let j = i + 1; j < lines.length; j++) {
37
+ const item = lines[j];
38
+ if (item.trim() === "") continue;
39
+ if (item.length - item.trimStart().length === 0) break;
40
+ const dash = /^\s*-\s+(.*)$/.exec(item);
41
+ if (dash) fm.concepts.push(unquote(dash[1].trim()));
42
+ }
43
+ else fm.concepts.push(...parseScalarOrFlow(value));
44
+ }
45
+ }
46
+ return fm;
47
+ }
48
+ function parseScalarOrFlow(value) {
49
+ const v = value.trim();
50
+ if (v.startsWith("[") && v.endsWith("]")) return v.slice(1, -1).split(",").map((s) => unquote(s.trim())).filter((s) => s.length > 0);
51
+ const single = unquote(v);
52
+ return single.length > 0 ? [single] : [];
53
+ }
54
+ function unquote(v) {
55
+ return v.replace(/^["']|["']$/g, "");
56
+ }
57
+ function parseSectionHeadings(text) {
58
+ const headings = [];
59
+ let inFence = false;
60
+ for (const raw of text.split("\n")) {
61
+ const line = raw.replace(/\r$/, "");
62
+ if (/^\s*(```|~~~)/.test(line)) {
63
+ inFence = !inFence;
64
+ continue;
65
+ }
66
+ if (inFence) continue;
67
+ const m = /^##\s+(.+?)\s*$/.exec(line);
68
+ if (m) headings.push(m[1].replace(/`/g, "").trim());
69
+ }
70
+ return headings;
71
+ }
72
+ function countScenarios(featureText) {
73
+ let n = 0;
74
+ for (const line of featureText.split("\n")) if (/^\s*Scenario:/.test(line)) n++;
75
+ return n;
76
+ }
77
+ const SECTION_HEADER = /^\s*#\s*(?:──|-{3,})/;
78
+ function profileFeature(featureText) {
79
+ const lines = featureText.split("\n");
80
+ let scenarioCount = 0;
81
+ let taggedCount = 0;
82
+ let clusterCount = 0;
83
+ let pendingRubric = false;
84
+ for (const line of lines) {
85
+ if (/^\s*Scenario:/.test(line)) {
86
+ scenarioCount++;
87
+ if (pendingRubric) taggedCount++;
88
+ pendingRubric = false;
89
+ continue;
90
+ }
91
+ const trimmed = line.trim();
92
+ if (trimmed === "") continue;
93
+ if (trimmed.startsWith("@")) {
94
+ if (/(^|\s)@rubric(\s|$)/.test(trimmed)) pendingRubric = true;
95
+ continue;
96
+ }
97
+ if (SECTION_HEADER.test(line)) {
98
+ clusterCount++;
99
+ pendingRubric = false;
100
+ continue;
101
+ }
102
+ pendingRubric = false;
103
+ }
104
+ return {
105
+ scenarioCount,
106
+ plainCount: scenarioCount - taggedCount,
107
+ taggedCount,
108
+ clusterCount
109
+ };
110
+ }
111
+ function displayPath(relPath) {
112
+ const p = relPath.replace(/\\/g, "/");
113
+ return p.endsWith("/README.md") ? p.slice(0, -9) : p;
114
+ }
115
+ function scanProjectSpec(specDir) {
116
+ const records = [];
117
+ walk(specDir, specDir, records);
118
+ return records.sort((a, b) => a.display.localeCompare(b.display));
119
+ }
120
+ function walk(dir, specDir, out) {
121
+ const entries = readdirSync(dir, { withFileTypes: true });
122
+ for (const entry of entries) {
123
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
124
+ const full = join(dir, entry.name);
125
+ if (entry.isDirectory()) walk(full, specDir, out);
126
+ else if (entry.name === "README.md") {
127
+ const readmeText = readFileSync(full, "utf8");
128
+ const fm = parseNodeFrontmatter(readmeText);
129
+ if (fm.specType === void 0 && fm.concepts.length === 0) continue;
130
+ const sectionHeadings = parseSectionHeadings(readmeText);
131
+ const features = entries.filter((e) => e.isFile() && e.name.endsWith(".feature"));
132
+ const hasFeature = features.length > 0;
133
+ const profile = hasFeature ? profileFeature(readFileSync(join(dir, features[0].name), "utf8")) : {
134
+ scenarioCount: 0,
135
+ plainCount: 0,
136
+ taggedCount: 0,
137
+ clusterCount: 0
138
+ };
139
+ const relPath = full.slice(specDir.length + 1).replace(/\\/g, "/");
140
+ out.push({
141
+ relPath,
142
+ capability: relPath.split("/")[0],
143
+ display: displayPath(relPath),
144
+ concepts: fm.concepts,
145
+ specType: fm.specType,
146
+ sectionHeadings,
147
+ hasFeature,
148
+ scenarioCount: profile.scenarioCount,
149
+ plainCount: profile.plainCount,
150
+ taggedCount: profile.taggedCount,
151
+ clusterCount: profile.clusterCount
152
+ });
153
+ }
154
+ }
155
+ }
156
+ function checkUntagged(records) {
157
+ return records.filter((r) => r.specType !== void 0 && r.concepts.length === 0).map((r) => ({
158
+ kind: "untagged-node",
159
+ severity: "blocking",
160
+ node: r.display,
161
+ detail: `spec-type: ${r.specType} but no concept tag — orphaned from the by-concept index`
162
+ }));
163
+ }
164
+ function checkOversized(records, maxScenarios) {
165
+ return records.filter((r) => r.scenarioCount > maxScenarios).map((r) => ({
166
+ kind: "oversized-node",
167
+ severity: "advisory",
168
+ node: r.display,
169
+ detail: `${r.scenarioCount} scenarios > ${maxScenarios} — shape profile: plain ${r.plainCount}, tagged ${r.taggedCount}, clusters ${r.clusterCount} (soft breadth hint); the Warden routes breadth-vs-depth`
170
+ }));
171
+ }
172
+ function checkGlossary(specDir) {
173
+ if (existsSync(join(specDir, "glossary.md"))) return [];
174
+ return [{
175
+ kind: "missing-glossary",
176
+ severity: "advisory",
177
+ node: "glossary.md",
178
+ detail: "no root glossary.md — the project has no home for its ubiquitous language, so a term can be used without ever being defined; a root file, never a folder"
179
+ }];
180
+ }
181
+ function checkIncomplete(records) {
182
+ return records.filter((r) => r.specType === "behavioral" && r.hasFeature).map((r) => {
183
+ return {
184
+ record: r,
185
+ missing: REQUIRED_BEHAVIORAL_SECTIONS.filter((s) => !r.sectionHeadings.includes(s))
186
+ };
187
+ }).filter(({ missing }) => missing.length > 0).map(({ record, missing }) => ({
188
+ kind: "incomplete-node",
189
+ severity: "advisory",
190
+ node: record.display,
191
+ detail: `behavioral leaf spec missing required section(s): ${missing.map((s) => `## ${s}`).join(", ")} — a spec that stops at ## Use Cases never draws its CFG or scenario map (sdd:spec-format-governance)`
192
+ }));
193
+ }
194
+ function audit(records, maxScenarios, specDir) {
195
+ return [
196
+ ...checkUntagged(records),
197
+ ...checkOversized(records, maxScenarios),
198
+ ...checkIncomplete(records),
199
+ ...specDir === void 0 ? [] : checkGlossary(specDir)
200
+ ];
201
+ }
202
+ function hasBlocking(findings) {
203
+ return findings.some((f) => f.severity === "blocking");
204
+ }
205
+ function renderFindings(findings, specDir) {
206
+ const blocking = findings.filter((f) => f.severity === "blocking");
207
+ const advisory = findings.filter((f) => f.severity === "advisory");
208
+ const lines = [`check-spec-structure: spec-dir=${specDir}`];
209
+ lines.push(`blocking[${blocking.length}]:`);
210
+ for (const f of blocking) lines.push(` ${f.node} — ${f.kind}: ${f.detail}`);
211
+ lines.push(`advisory[${advisory.length}]:`);
212
+ for (const f of advisory) lines.push(` ${f.node} — ${f.kind}: ${f.detail}`);
213
+ lines.push("note: advisory — findings feed the Warden formation pass; the engine writes nothing");
214
+ return lines.join("\n");
215
+ }
216
+ function main(argv) {
217
+ let specDir = ".";
218
+ let mode = "audit";
219
+ let format = "toon";
220
+ let maxScenarios = 40;
221
+ for (let i = 0; i < argv.length; i++) {
222
+ const a = argv[i];
223
+ if (a === "--spec-dir") specDir = argv[++i] ?? ".";
224
+ else if (a === "--check") mode = "check";
225
+ else if (a === "--max-scenarios") maxScenarios = Number(argv[++i] ?? 40);
226
+ else if (a === "--format") format = argv[++i] ?? "toon";
227
+ }
228
+ const findings = audit(scanProjectSpec(specDir), maxScenarios, specDir);
229
+ if (mode === "check") {
230
+ if (hasBlocking(findings)) {
231
+ process.stderr.write(`check-spec-structure: ${findings.filter((f) => f.severity === "blocking").length} blocking finding(s)\n`);
232
+ return 1;
233
+ }
234
+ process.stdout.write("check-spec-structure: no blocking findings\n");
235
+ return 0;
236
+ }
237
+ if (format === "json") process.stdout.write(`${JSON.stringify(findings)}\n`);
238
+ else process.stdout.write(`${renderFindings(findings, specDir)}\n`);
239
+ return 0;
240
+ }
241
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
242
+ //#endregion
243
+ export { DEFAULT_MAX_SCENARIOS, REQUIRED_BEHAVIORAL_SECTIONS, audit, checkGlossary, checkIncomplete, checkOversized, checkUntagged, countScenarios, hasBlocking, main, parseNodeFrontmatter, parseScalarOrFlow, parseSectionHeadings, profileFeature, renderFindings, scanProjectSpec };
@@ -11,6 +11,6 @@ cyberplace repository, not shipped in this package) carries the authoritative be
11
11
  and the frozen 18-scenario contract.
12
12
 
13
13
  - **Skill contract:** [`SKILL.md`](./SKILL.md)
14
- - **Script:** [`scripts/collision-ladder.mts`](./scripts/collision-ladder.mts)
15
- - **Tests:** [`scripts/collision-ladder.test.mts`](./scripts/collision-ladder.test.mts)
14
+ - **Script:** [`scripts/collision-ladder.mjs`](./scripts/collision-ladder.mjs)
15
+ - **Tests:** [`src/collision-ladder/collision-ladder.test.mts`](../../src/collision-ladder/collision-ladder.test.mts)
16
16
  (`node:test`) — one test per frozen scenario, titled `scenario: <verbatim frozen scenario name>`.