cyber-sdd 0.7.0 → 0.7.2

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 (150) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/CHANGELOG.md +320 -0
  4. package/agents/sdd-scanner.md +1 -1
  5. package/package.json +11 -12
  6. package/plugin.json +55 -0
  7. package/skills/align-spec/SKILL.md +1 -1
  8. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  9. package/skills/blast-estimate/README.md +3 -3
  10. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  11. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  12. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  13. package/skills/check-plan-safety/SKILL.md +2 -3
  14. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  15. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  16. package/skills/check-project-specs/SKILL.md +1 -1
  17. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  18. package/skills/check-retired-terms/SKILL.md +2 -3
  19. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  20. package/skills/check-scenario-overlap/SKILL.md +1 -2
  21. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  22. package/skills/check-spec-references/SKILL.md +2 -2
  23. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  24. package/skills/check-spec-structure/SKILL.md +1 -2
  25. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  26. package/skills/collision-ladder/README.md +2 -2
  27. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  28. package/skills/combat-log-governance/SKILL.md +1 -1
  29. package/skills/concept-index/SKILL.md +1 -1
  30. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  31. package/skills/discover-plans/README.md +3 -3
  32. package/skills/discover-plans/SKILL.md +1 -1
  33. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  34. package/skills/discover-specs/README.md +3 -3
  35. package/skills/discover-specs/SKILL.md +1 -1
  36. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  37. package/skills/doctrine-loop/SKILL.md +1 -1
  38. package/skills/gate-validation-governance/SKILL.md +1 -1
  39. package/skills/init/README.md +2 -2
  40. package/skills/init/scripts/wire-statusline.mjs +168 -12
  41. package/skills/manage-ignore/README.md +3 -3
  42. package/skills/manage-ignore/SKILL.md +1 -2
  43. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  44. package/skills/manage-scenario-bridge/README.md +3 -3
  45. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  46. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  47. package/skills/manage-spec-anchors/README.md +3 -3
  48. package/skills/manage-spec-anchors/SKILL.md +1 -1
  49. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  50. package/skills/mission-graph/README.md +2 -2
  51. package/skills/mission-graph/SKILL.md +1 -1
  52. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  53. package/skills/pause-mission/SKILL.md +1 -1
  54. package/skills/place-node/SKILL.md +2 -2
  55. package/skills/place-node/scripts/place-node.mjs +117 -12
  56. package/skills/plan-retirement/README.md +2 -2
  57. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  58. package/skills/resolve-governances/README.md +3 -3
  59. package/skills/resolve-governances/SKILL.md +1 -1
  60. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  61. package/skills/resolve-tracking/SKILL.md +1 -1
  62. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  63. package/skills/scaffold-project-spec/SKILL.md +1 -1
  64. package/skills/spec-gate/README.md +12 -9
  65. package/skills/spec-gate/SKILL.md +2 -2
  66. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  67. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  68. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  69. package/skills/spec-producer-governance/SKILL.md +2 -2
  70. package/skills/start-mission/SKILL.md +1 -1
  71. package/skills/suite-format-governance/README.md +1 -1
  72. package/skills/suite-format-governance/SKILL.md +1 -1
  73. package/skills/touch-set-correction/README.md +5 -5
  74. package/skills/touch-set-correction/SKILL.md +1 -1
  75. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  76. package/skills/verify-scenarios/README.md +6 -6
  77. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  78. package/.plugin/pins.json +0 -3
  79. package/.plugin/plugin.json +0 -19
  80. package/dist/align-spec/scripts/align-spec.js +0 -102
  81. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  82. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  83. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  84. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  85. package/dist/concept-index/scripts/concept-index.js +0 -181
  86. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  87. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  88. package/dist/spec-gate/scripts/check-suite.js +0 -317
  89. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  90. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  91. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  92. package/skills/align-spec/scripts/align-spec.mts +0 -188
  93. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  94. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  95. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  96. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  97. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  98. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  99. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  100. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  102. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  103. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  104. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  105. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  106. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  108. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  109. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  110. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  111. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  112. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  113. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  114. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  115. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  116. package/skills/concept-index/scripts/concept-index.mts +0 -246
  117. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  118. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  119. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  120. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  121. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  122. package/skills/init/scripts/wire-statusline.mts +0 -279
  123. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  124. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  126. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  128. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  129. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  130. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  131. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  132. package/skills/place-node/scripts/place-node.mts +0 -158
  133. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  134. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  135. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  136. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  137. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  138. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  139. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  140. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  141. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  142. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  143. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  144. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  145. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  146. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  147. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  148. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  149. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  150. package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
@@ -1,250 +0,0 @@
1
- #!/usr/bin/env node
2
- // check-scenario-overlap — project-spec/scenario-overlap's concrete engine. Audits ACROSS the nodes
3
- // of one project spec and surfaces where the same behavior lives in more than one node's `.feature`:
4
- // the intra-project spec-level SSA partner of the code-side collision ladder, and the cross-node
5
- // axis check-spec-structure (intra-node node-shape) leaves uncovered (see this skill's README.md).
6
- //
7
- // Two deterministic candidate kinds, each with a severity:
8
- // - exact-duplicate (blocking) — two DISTINCT nodes whose suites each carry a scenario with an
9
- // identical normalized step fingerprint. `--check` fails on it.
10
- // - title-overlap (advisory) — two distinct nodes sharing a normalized scenario title but with
11
- // DIFFERING step fingerprints. A weaker hint; never fails `--check`.
12
- // Confirming real behavioral overlap and assigning the owning node is Warden judgment (the @rubric
13
- // scenario) — no engine code here.
14
- //
15
- // The fingerprint is computed from step BODIES only (title/tags/comments/prose never reach it), so
16
- // the signal is behavior-shaped, not cosmetic. For a `Scenario Outline` the steps are a template —
17
- // every canonical `@trigger` outline shares byte-identical steps by construction — so the outline's
18
- // `Examples` table (header + rows, normalized) is folded into the fingerprint too: two outlines are
19
- // an exact-duplicate only when their steps AND their rows match. Title and tags stay excluded from
20
- // both — a plain `Scenario` and an outline's Examples rows are the only places distinguishing
21
- // content can live. Detection is cross-node only: a within-node duplicate and a once-corpus-wide
22
- // scenario both raise nothing. Pure derivation, writes nothing, no deps (the repo's node->=23.6 /
23
- // no-deps convention). Pure functions are exported for node:test; running the file directly drives
24
- // the CLI.
25
-
26
- import { readdirSync, readFileSync, realpathSync } from 'node:fs'
27
- import { join } from 'node:path'
28
- import { pathToFileURL } from 'node:url'
29
-
30
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
31
-
32
- export type CandidateKind = 'exact-duplicate' | 'title-overlap'
33
- export type Severity = 'blocking' | 'advisory'
34
-
35
- export interface Scenario {
36
- title: string
37
- /** Normalized ordered step bodies, plus an outline's Examples rows — the behavior fingerprint. */
38
- fingerprint: string
39
- /** Normalized title — the weaker signal. */
40
- titleKey: string
41
- }
42
-
43
- export interface NodeSuite {
44
- /** Display form of the owning node — its folder path. */
45
- node: string
46
- scenarios: Scenario[]
47
- }
48
-
49
- export interface Candidate {
50
- kind: CandidateKind
51
- severity: Severity
52
- nodes: [string, string]
53
- scenario: string
54
- detail: string
55
- }
56
-
57
- // ── Normalization ──
58
- export function normalize(s: string): string {
59
- return s.trim().replace(/\s+/g, ' ').toLowerCase()
60
- }
61
-
62
- // ── Parse a .feature into its scenarios (steps + an outline's Examples rows reach the fingerprint) ──
63
- const STEP = /^\s*(Given|When|Then|And|But)\s+(.*\S)\s*$/
64
- const SCENARIO = /^\s*Scenario(?: Outline)?:\s*(.*\S)\s*$/
65
- const TABLE_ROW = /^\s*\|(.*)\|\s*$/
66
-
67
- // Normalize one `| cell | cell |` row cell-by-cell so a row survives reordered
68
- // whitespace inside a cell but not a reordered or differing cell.
69
- function normalizeRow(line: string): string {
70
- const inner = TABLE_ROW.exec(line)?.[1] ?? line
71
- return inner
72
- .split('|')
73
- .map((cell) => normalize(cell))
74
- .join('|')
75
- }
76
-
77
- export function parseFeature(featureText: string): Scenario[] {
78
- const scenarios: Scenario[] = []
79
- let title: string | null = null
80
- let steps: string[] = []
81
- let exampleRows: string[] = []
82
- const flush = () => {
83
- if (title !== null) {
84
- const fingerprint =
85
- exampleRows.length === 0
86
- ? steps.map(normalize).join('\n')
87
- : `${steps.map(normalize).join('\n')}\n EXAMPLES \n${exampleRows.join('\n')}`
88
- scenarios.push({ title, titleKey: normalize(title), fingerprint })
89
- }
90
- title = null
91
- steps = []
92
- exampleRows = []
93
- }
94
- for (const line of featureText.split('\n')) {
95
- const sc = SCENARIO.exec(line)
96
- if (sc) {
97
- flush()
98
- title = sc[1]
99
- continue
100
- }
101
- if (title === null) continue
102
- const st = STEP.exec(line)
103
- if (st) {
104
- steps.push(st[2])
105
- continue
106
- }
107
- if (TABLE_ROW.test(line)) exampleRows.push(normalizeRow(line))
108
- }
109
- flush()
110
- return scenarios
111
- }
112
-
113
- function displayPath(relPath: string): string {
114
- const p = relPath.replace(/\\/g, '/')
115
- return p.endsWith('/README.md') ? p.slice(0, -'README.md'.length) : p
116
- }
117
-
118
- // ── Scan the project-spec — one suite per node folder that carries a .feature ──
119
- export function scanSuites(specDir: string): NodeSuite[] {
120
- const suites: NodeSuite[] = []
121
- walk(specDir, specDir, suites)
122
- return suites.sort((a, b) => a.node.localeCompare(b.node))
123
- }
124
-
125
- function walk(dir: string, specDir: string, out: NodeSuite[]): void {
126
- const entries = readdirSync(dir, { withFileTypes: true })
127
- for (const entry of entries) {
128
- if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
129
- const full = join(dir, entry.name)
130
- if (entry.isDirectory()) {
131
- walk(full, specDir, out)
132
- continue
133
- }
134
- if (!entry.name.endsWith('.feature')) continue
135
- const scenarios = parseFeature(readFileSync(full, 'utf8'))
136
- if (scenarios.length === 0) continue
137
- const relDir = dir.slice(specDir.length + 1).replace(/\\/g, '/')
138
- out.push({ node: displayPath(`${relDir}/`), scenarios })
139
- }
140
- }
141
-
142
- // ── Detect — cross-node only (distinct nodes), fingerprint then title ──
143
- export function detect(suites: NodeSuite[]): Candidate[] {
144
- // fingerprint -> (node -> first scenario title in that node)
145
- const byFingerprint = new Map<string, Map<string, string>>()
146
- // titleKey -> (node -> { fingerprints, display title })
147
- const byTitle = new Map<string, Map<string, { fps: Set<string>; title: string }>>()
148
- for (const suite of suites) {
149
- for (const sc of suite.scenarios) {
150
- if (sc.fingerprint !== '') {
151
- let m = byFingerprint.get(sc.fingerprint)
152
- if (!m) byFingerprint.set(sc.fingerprint, (m = new Map()))
153
- if (!m.has(suite.node)) m.set(suite.node, sc.title)
154
- }
155
- let t = byTitle.get(sc.titleKey)
156
- if (!t) byTitle.set(sc.titleKey, (t = new Map()))
157
- let e = t.get(suite.node)
158
- if (!e) t.set(suite.node, (e = { fps: new Set(), title: sc.title }))
159
- e.fps.add(sc.fingerprint)
160
- }
161
- }
162
- const candidates: Candidate[] = []
163
- // exact-duplicate: same fingerprint across >=2 distinct nodes
164
- for (const [, nodeMap] of byFingerprint) {
165
- if (nodeMap.size < 2) continue
166
- const nodes = [...nodeMap.keys()].sort()
167
- for (let i = 0; i < nodes.length; i++) {
168
- for (let j = i + 1; j < nodes.length; j++) {
169
- candidates.push({
170
- kind: 'exact-duplicate',
171
- severity: 'blocking',
172
- nodes: [nodes[i], nodes[j]],
173
- scenario: nodeMap.get(nodes[i]) ?? '',
174
- detail:
175
- 'identical step fingerprint — the same behavior is specified in both nodes; one behavior = one owning node',
176
- })
177
- }
178
- }
179
- }
180
- // title-overlap: same normalized title across >=2 distinct nodes with DIFFERING fingerprints
181
- for (const [, nodeMap] of byTitle) {
182
- if (nodeMap.size < 2) continue
183
- const nodes = [...nodeMap.keys()].sort()
184
- for (let i = 0; i < nodes.length; i++) {
185
- for (let j = i + 1; j < nodes.length; j++) {
186
- const a = nodeMap.get(nodes[i])
187
- const b = nodeMap.get(nodes[j])
188
- if (!a || !b) continue
189
- const shareFp = [...a.fps].some((fp) => b.fps.has(fp))
190
- if (shareFp) continue // this title pair is already an exact-duplicate
191
- candidates.push({
192
- kind: 'title-overlap',
193
- severity: 'advisory',
194
- nodes: [nodes[i], nodes[j]],
195
- scenario: a.title,
196
- detail: 'same scenario title, differing steps — a weaker overlap hint the Warden judges',
197
- })
198
- }
199
- }
200
- }
201
- return candidates
202
- }
203
-
204
- export function hasBlocking(candidates: Candidate[]): boolean {
205
- return candidates.some((c) => c.severity === 'blocking')
206
- }
207
-
208
- // ── Render ──
209
- export function renderCandidates(candidates: Candidate[], specDir: string): string {
210
- const blocking = candidates.filter((c) => c.severity === 'blocking')
211
- const advisory = candidates.filter((c) => c.severity === 'advisory')
212
- const lines: string[] = [`check-scenario-overlap: spec-dir=${specDir}`]
213
- lines.push(`blocking[${blocking.length}]:`)
214
- for (const c of blocking) lines.push(` ${c.nodes[0]} <-> ${c.nodes[1]} — ${c.kind}: "${c.scenario}" — ${c.detail}`)
215
- lines.push(`advisory[${advisory.length}]:`)
216
- for (const c of advisory) lines.push(` ${c.nodes[0]} <-> ${c.nodes[1]} — ${c.kind}: "${c.scenario}" — ${c.detail}`)
217
- lines.push('note: advisory — candidates feed the Warden formation pass; the engine writes nothing')
218
- return lines.join('\n')
219
- }
220
-
221
- // ── CLI ──
222
- export function main(argv: string[]): number {
223
- let specDir = '.'
224
- let mode: 'audit' | 'check' = 'audit'
225
- let format: 'toon' | 'json' = 'toon'
226
- for (let i = 0; i < argv.length; i++) {
227
- const a = argv[i]
228
- if (a === '--spec-dir') specDir = argv[++i] ?? '.'
229
- else if (a === '--check') mode = 'check'
230
- else if (a === '--format') format = (argv[++i] as 'toon' | 'json') ?? 'toon'
231
- }
232
- const candidates = detect(scanSuites(specDir))
233
- if (mode === 'check') {
234
- if (hasBlocking(candidates)) {
235
- process.stderr.write(
236
- `check-scenario-overlap: ${candidates.filter((c) => c.severity === 'blocking').length} exact-duplicate candidate(s)\n`,
237
- )
238
- return 1
239
- }
240
- process.stdout.write('check-scenario-overlap: no exact-duplicate candidates\n')
241
- return 0
242
- }
243
- if (format === 'json') process.stdout.write(`${JSON.stringify(candidates)}\n`)
244
- else process.stdout.write(`${renderCandidates(candidates, specDir)}\n`)
245
- return 0
246
- }
247
-
248
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
249
- process.exit(main(process.argv.slice(2)))
250
- }
@@ -1,285 +0,0 @@
1
- #!/usr/bin/env node
2
- import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
- import { dirname, join, relative, resolve } from "node:path";
4
- import { pathToFileURL } from "node:url";
5
- //#region skills/check-spec-references/scripts/check-spec-references.mts
6
- /** Suppresses every reference on the line it appears on. Matched as a COMPLETE html comment, not
7
- * as a substring: a marker whose name merely starts with this one's (a typo, a future sibling)
8
- * must not silently inherit its power to hide a reference. The optional `: reason` is convention,
9
- * not syntax — the marker is recognized with or without one. */
10
- const IGNORE_MARKER_RE = /<!--\s*spec-ref-ignore\s*(?::[^>]*)?-->/;
11
- /** A markdown inline link whose target is explicitly relative — covering the plain form, an
12
- * angle-bracket-wrapped target, and an optional title in any of markdown's three quotings. Run only
13
- * over the parts of a line that are NOT inside a code span: inside one, markup is literal text on
14
- * display, not a link. */
15
- const LINK_RE = /\]\(\s*(?:<([^>\n]*)>|(\.{1,2}\/[^)\s]*))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
16
- /** A reference-style link definition (`[label]: ../path`) whose target is explicitly relative. It
17
- * is a link target like any other — the label form changes where the path is written, not what it
18
- * points at. */
19
- const LINK_DEF_RE = /^\s{0,3}\[.+\]:\s*(?:<([^>\n]*)>|(\S+))/;
20
- /** An explicitly-relative path, and nothing else. The trailing class excludes whitespace and
21
- * backticks so that "the span's WHOLE content is the path" means what it says: a span holding
22
- * `../a` plus commentary is prose that starts with a path, not a citation. */
23
- const RELATIVE_RE = /^\.{1,2}\/[^\s`]*$/;
24
- /** The same, minus the no-whitespace rule — for an ANGLE-BRACKET link target, whose whole reason
25
- * for existing is to carry a path with a space in it. */
26
- const RELATIVE_IN_ANGLES_RE = /^\.{1,2}\/[^`]*$/;
27
- /**
28
- * The fence a line opens, or `undefined`. CommonMark: three or more backticks or tildes, indented
29
- * fewer than four spaces.
30
- */
31
- function fenceOpenedBy(line) {
32
- const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
33
- if (m === null) return void 0;
34
- const run = m[1];
35
- return {
36
- char: run[0],
37
- length: run.length
38
- };
39
- }
40
- /**
41
- * Whether `line` CLOSES `fence`: the same character, at least as long, and nothing but whitespace
42
- * after it.
43
- *
44
- * Matching the delimiter rather than toggling a boolean on any fence-shaped line is what keeps the
45
- * exclusion honest. A bare toggle flips on a `~~~` line inside a backtick fence — so a reference
46
- * genuinely inside a fence gets extracted, and, worse, a fence-shaped line inside another fence
47
- * leaves the parity inverted and every genuinely broken reference AFTER the block is silently
48
- * swallowed. That is this engine's own failure class, reached through the one rule meant to
49
- * suppress noise.
50
- */
51
- function fenceClosedBy(line, fence) {
52
- const m = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
53
- if (m === null) return false;
54
- const run = m[1];
55
- return run[0] === fence.char && run.length >= fence.length;
56
- }
57
- /**
58
- * A line that starts a new block: blank, an ATX heading, a blockquote, a list item, or a thematic
59
- * break. Inline parsing happens WITHIN a block, so a code span cannot reach across one of these —
60
- * and the boundary matters in the under-reporting direction. An unclosed backtick left by a typo
61
- * would otherwise pair with the first backtick of the next block, swallowing the span that was
62
- * about to open and every reference after it in the flow.
63
- */
64
- function startsNewBlock(line) {
65
- if (/^[ \t]*$/.test(line)) return true;
66
- if (/^ {0,3}#{1,6}(\s|$)/.test(line)) return true;
67
- if (/^ {0,3}>/.test(line)) return true;
68
- if (/^ {0,3}([-+*]|\d{1,9}[.)])(\s|$)/.test(line)) return true;
69
- if (/^ {0,3}((\*[ \t]*){3,}|(-[ \t]*){3,}|(_[ \t]*){3,})$/.test(line)) return true;
70
- return false;
71
- }
72
- /** Same length, same line breaks, no content — so blanking a region shifts no offset and moves no
73
- * finding to another line. */
74
- function blankOut(region) {
75
- return region.replace(/[^\n]/g, " ");
76
- }
77
- /**
78
- * The inline-code spans on one line, scanned the way CommonMark delimits them: a run of N backticks
79
- * opens, the next run of EXACTLY N closes, and one leading + trailing space is stripped when both
80
- * are present.
81
- *
82
- * Scanning properly rather than matching a single-backtick pair is what makes the reference rule —
83
- * "a code span is a reference when its WHOLE content is the path" — mean one thing everywhere. A
84
- * doubled span written around another span (`` `x` ``) has content that still carries backticks, so
85
- * it is not a path and not a reference; a doubled span written around a bare path has the path as
86
- * its content, so it IS one. Neither is a special case, and neither leaves a broken reference
87
- * anywhere to hide.
88
- */
89
- function scanCodeSpans(text, blockStarts = []) {
90
- const out = [];
91
- let i = 0;
92
- while (i < text.length) {
93
- if (text[i] !== "`") {
94
- i++;
95
- continue;
96
- }
97
- const open = i;
98
- while (text[i] === "`") i++;
99
- const runLength = i - open;
100
- const limit = blockStarts.find((b) => b > open) ?? text.length;
101
- let j = i;
102
- let closeStart = -1;
103
- while (j < limit) {
104
- if (text[j] !== "`") {
105
- j++;
106
- continue;
107
- }
108
- const runStart = j;
109
- while (text[j] === "`") j++;
110
- if (j - runStart === runLength) {
111
- closeStart = runStart;
112
- break;
113
- }
114
- }
115
- if (closeStart === -1) {
116
- i = open + runLength;
117
- continue;
118
- }
119
- let content = text.slice(open + runLength, closeStart).replace(/\r?\n/g, " ");
120
- if (content.length > 1 && content.startsWith(" ") && content.endsWith(" ") && content.trim() !== "") content = content.slice(1, -1);
121
- out.push({
122
- content,
123
- start: open,
124
- end: j
125
- });
126
- i = j;
127
- }
128
- return out;
129
- }
130
- /**
131
- * Every explicitly-relative reference in `text`, in document order.
132
- *
133
- * Skipped: lines inside a fenced code block, and lines carrying the ignore marker. Extraction is
134
- * line-scoped on purpose — it is what gives each finding a line number, and what lets the marker's
135
- * scope be exactly the line it appears on rather than the whole file.
136
- */
137
- function extractReferences(text) {
138
- const lines = text.split("\n");
139
- let fence;
140
- const unfenced = lines.map((line) => {
141
- if (fence !== void 0) {
142
- if (fenceClosedBy(line, fence)) fence = void 0;
143
- return blankOut(line);
144
- }
145
- const opened = fenceOpenedBy(line);
146
- if (opened !== void 0) {
147
- fence = opened;
148
- return blankOut(line);
149
- }
150
- return line;
151
- });
152
- const scannable = unfenced.join("\n");
153
- const lineStarts = [];
154
- let acc = 0;
155
- for (const line of lines) {
156
- lineStarts.push(acc);
157
- acc += line.length + 1;
158
- }
159
- const blockStarts = lineStarts.filter((_start, i) => i > 0 && startsNewBlock(unfenced[i]));
160
- const lineOf = (offset) => {
161
- let lo = 0;
162
- let hi = lineStarts.length - 1;
163
- while (lo < hi) {
164
- const mid = lo + hi + 1 >> 1;
165
- if (lineStarts[mid] <= offset) lo = mid;
166
- else hi = mid - 1;
167
- }
168
- return lo;
169
- };
170
- const spans = scanCodeSpans(scannable, blockStarts);
171
- let outsideSpans = scannable;
172
- for (const span of spans) outsideSpans = outsideSpans.slice(0, span.start) + blankOut(outsideSpans.slice(span.start, span.end)) + outsideSpans.slice(span.end);
173
- const outsideLines = outsideSpans.split("\n");
174
- const marked = outsideLines.map((line) => IGNORE_MARKER_RE.test(line));
175
- const perLine = /* @__PURE__ */ new Map();
176
- const add = (line, ref) => {
177
- if (marked[line] === true) return;
178
- const set = perLine.get(line) ?? /* @__PURE__ */ new Set();
179
- set.add(ref);
180
- perLine.set(line, set);
181
- };
182
- for (const span of spans) {
183
- const content = span.content.trim();
184
- if (RELATIVE_RE.test(content)) add(lineOf(span.start), content);
185
- }
186
- outsideLines.forEach((line, i) => {
187
- const addTarget = (angled, bare) => {
188
- if (angled !== void 0) {
189
- if (RELATIVE_IN_ANGLES_RE.test(angled)) add(i, angled);
190
- return;
191
- }
192
- if (bare !== void 0 && RELATIVE_RE.test(bare)) add(i, bare);
193
- };
194
- for (const m of line.matchAll(LINK_RE)) addTarget(m[1], m[2]);
195
- const def = LINK_DEF_RE.exec(line);
196
- if (def) addTarget(def[1], def[2]);
197
- });
198
- const out = [];
199
- for (const line of [...perLine.keys()].sort((a, b) => a - b)) for (const ref of perLine.get(line)) out.push({
200
- line: line + 1,
201
- ref
202
- });
203
- return out;
204
- }
205
- /**
206
- * Where `ref` points, resolved against `fileDir` — the directory of the file that CARRIES it,
207
- * never the spec root and never the repo root. A trailing `#fragment` is stripped first; a
208
- * trailing slash is immaterial (`resolve` drops it), so a directory reference resolves either way.
209
- */
210
- function resolveReference(fileDir, ref) {
211
- const withoutFragment = ref.replace(/#.*$/, "");
212
- return resolve(fileDir, withoutFragment);
213
- }
214
- /** Every `.md` file under `dir`, at any depth, absolute and sorted so the report is stable. */
215
- function listMarkdownFiles(dir) {
216
- const out = [];
217
- const walk = (d) => {
218
- for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
219
- const p = join(d, e.name);
220
- if (e.isDirectory()) walk(p);
221
- else if (e.name.endsWith(".md")) out.push(p);
222
- }
223
- };
224
- walk(dir);
225
- return out;
226
- }
227
- /**
228
- * Every unresolved reference under `specDir`, ordered by file, then line, then reference — so two
229
- * runs over an unchanged tree render byte-identically.
230
- *
231
- * EVERY finding, never the first: a single off-by-one lands as a whole family of broken
232
- * references, and reporting one at a time would take as many runs to clear as there are levels
233
- * wrong.
234
- */
235
- function audit(specDir, options = {}) {
236
- const list = options.listMarkdownFiles ?? listMarkdownFiles;
237
- const read = options.readFile ?? ((p) => readFileSync(p, "utf8"));
238
- const has = options.exists ?? existsSync;
239
- const findings = [];
240
- for (const file of list(specDir)) {
241
- const fileDir = dirname(file);
242
- for (const { line, ref } of extractReferences(read(file))) {
243
- const resolved = resolveReference(fileDir, ref);
244
- if (!has(resolved)) findings.push({
245
- file,
246
- line,
247
- ref,
248
- resolved
249
- });
250
- }
251
- }
252
- 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);
253
- }
254
- /** Renders each finding as `file:line: <ref> -> <resolved>`, both paths relative to `base` so the
255
- * report reads the same from any checkout. */
256
- function formatFindings(findings, base) {
257
- if (findings.length === 0) return "check-spec-references: every relative reference resolves\n";
258
- const lines = findings.map((f) => ` ${relative(base, f.file)}:${f.line}: \`${f.ref}\` -> ${relative(base, f.resolved)} — no file or directory there\n`);
259
- lines.push(`check-spec-references: ${findings.length} unresolved reference(s)\n`);
260
- return lines.join("");
261
- }
262
- function main(argv, cwd = process.cwd()) {
263
- const i = argv.indexOf("--spec-dir");
264
- const specDir = i === -1 ? "" : argv[i + 1] ?? "";
265
- if (specDir === "") {
266
- process.stderr.write("check-spec-references: --spec-dir <dir> is required\n");
267
- return 1;
268
- }
269
- const dir = resolve(cwd, specDir);
270
- if (!existsSync(dir)) {
271
- process.stderr.write(`check-spec-references: no directory at ${relative(cwd, dir)}\n`);
272
- return 1;
273
- }
274
- const findings = audit(dir);
275
- const report = formatFindings(findings, cwd);
276
- if (findings.length === 0) {
277
- process.stdout.write(report);
278
- return 0;
279
- }
280
- process.stderr.write(report);
281
- return 1;
282
- }
283
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
284
- //#endregion
285
- export { audit, extractReferences, formatFindings, listMarkdownFiles, main, resolveReference, scanCodeSpans, startsNewBlock };