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,586 +0,0 @@
1
- #!/usr/bin/env node
2
- // blast-estimate — the concrete engine for blast-estimate's derivation: compute a Mission's BLAST
3
- // (low/medium/high) from its touch-set + the project corpus instead of trusting the hand-typed guess,
4
- // then line the computed level up against the declared one (agrees / under-called / over-called). The
5
- // blast-estimate node of the SDD project spec (repo-only) carries the full contract this mirrors.
6
- //
7
- // Three inputs, each measured — never inferred — from the corpus:
8
- // - count — how many of the touch-set's areas resolve to a known work area
9
- // - centrality — dependency fan-in: how many OTHER work areas' files reference a touched one
10
- // - sensitivity — whether a touched area is MARKED in the opt-in `.agents/sdd/sensitive-paths.toml`
11
- // (absent file = no area sensitive, not an error; a file that fails to parse fails LOUD — the
12
- // estimate computes no level rather than silently reading it as "nothing marked")
13
- //
14
- // Two exclusions are structural, not just documented: this engine takes no "breaking"/compatibility
15
- // input at all (that dimension cannot leak in), and centrality is measured fan-in only — a work area's
16
- // NAME (e.g. "public") never enters the score.
17
- //
18
- // A work area not found in the corpus is SURFACED (`unresolved`), never silently dropped. An empty
19
- // touch-set — or one that resolves to zero known areas — computes `unknown`, never `low` ("nothing
20
- // touched is not evidence of low reach").
21
- //
22
- // Work-area recovery is NOT this engine's to invent: it REUSES the sibling touch-set-correction's
23
- // pure `fileToNode(path, layouts)` over `discoverLayouts`' declared `ProjectLayout[]` — the same
24
- // cross-skill reuse collision-ladder does (which imports `fileToNode` + `collectChangedFiles` from
25
- // the same module). This matters and is not cosmetic: a work area spans MULTIPLE declared roots — a
26
- // spec root AND an impl root (`sdd/mission-graph` lives at BOTH `.agents/specs/sdd/mission-graph/`
27
- // and `plugins/sdd/skills/mission-graph/`) — so a node's identity comes from the declared layout,
28
- // never from a path's shape. Any local walk that keyed on "first two segments" would split one node
29
- // in two, miss it entirely from the repo root, and measure fan-in over spec prose alone.
30
- //
31
- // Read-only: only readdirSync/readFileSync/statSync ever run here. It RETURNS the estimate; the
32
- // mission-graph's single writer records it. Pure functions are exported for node:test; running the
33
- // file directly drives the CLI.
34
-
35
- import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
36
- import { join, relative } from 'node:path'
37
- import { pathToFileURL } from 'node:url'
38
- import {
39
- discoverLayouts,
40
- fileToNode,
41
- type ProjectLayout,
42
- } from '../../touch-set-correction/scripts/touch-set-correction.mts'
43
-
44
- export type { ProjectLayout }
45
-
46
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
47
- const SENSITIVE_PATHS_FILE = '.agents/sdd/sensitive-paths.toml'
48
-
49
- export type BlastLevel = 'low' | 'medium' | 'high'
50
- export type DeclaredBlast = BlastLevel | 'unknown'
51
-
52
- // ── Corpus scan — work-area discovery over the DECLARED layouts (never a path-shape guess) ──
53
-
54
- /** Every repo-relative file path under `dir` (recursively), skipping build/vendor noise. Paths are
55
- * made relative with `path.relative`, never string-sliced: `join('.', x)` normalizes the `./` away,
56
- * so slicing by `root.length` silently corrupts every path under a RELATIVE root (`--root .`, the
57
- * CLI's default) while working fine under an absolute one. */
58
- function walkFiles(dir: string, root: string, out: string[]): void {
59
- let entries: import('node:fs').Dirent[]
60
- try {
61
- entries = readdirSync(dir, { withFileTypes: true })
62
- } catch {
63
- return // an undeclared or absent root contributes nothing
64
- }
65
- for (const entry of entries) {
66
- if (SKIP_DIRS.has(entry.name)) continue
67
- const full = join(dir, entry.name)
68
- if (entry.isDirectory()) walkFiles(full, root, out)
69
- else out.push(relative(root, full).replace(/\\/g, '/'))
70
- }
71
- }
72
-
73
- /**
74
- * discoverWorkAreas — the corpus's work areas, grouped by node id, over the DECLARED layouts. Walks
75
- * every root of every project, maps each file through touch-set-correction's pure `fileToNode`, and
76
- * groups by the node it names. Because a node's roots include BOTH its spec root and its impl root,
77
- * one node's file set spans both trees — which is exactly what makes fan-in measure real dependency
78
- * rather than spec-prose cross-reference. A file that maps to no node (`fileToNode` returns null —
79
- * the existing "unmapped" concept) is dropped from the area map, never invented into an atom.
80
- *
81
- * Values are repo-relative paths; `root` is the repo root they are resolved against.
82
- */
83
- export function discoverWorkAreas(layouts: ProjectLayout[], root: string): Map<string, string[]> {
84
- const byNode = new Map<string, string[]>()
85
- const seen = new Set<string>()
86
- for (const layout of layouts) {
87
- for (const rawRoot of layout.roots) {
88
- const rel = rawRoot.replace(/\/+$/, '')
89
- const files: string[] = []
90
- walkFiles(join(root, rel), root, files)
91
- for (const path of files) {
92
- if (seen.has(path)) continue // a path under two declared roots is counted once
93
- seen.add(path)
94
- const node = fileToNode(path, layouts)
95
- if (node === null) continue // unmapped — surfaced by touch-set-correction, not an atom here
96
- const bucket = byNode.get(node)
97
- if (bucket) bucket.push(path)
98
- else byNode.set(node, [path])
99
- }
100
- }
101
- }
102
- return byNode
103
- }
104
-
105
- function escapeRegExp(s: string): string {
106
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
107
- }
108
-
109
- // ── The reference matcher — the forms the corpus ACTUALLY uses to name a work area ──
110
- //
111
- // A bare `project/capability` id is what a touch-set and the ledger write, but it is NOT how prose
112
- // references an area. Real references are links and paths; matching the bare id alone scored 56 of
113
- // this repo's 62 `sdd` areas at fan-in 0 — including `sdd/spec-gate`, referenced by a dozen files —
114
- // while the few non-zero scores came from glossary rows using an id as a typographic EXAMPLE. That
115
- // measures "how often a name got used as documentation filler", not "how much of the project leans
116
- // on this area", which is the opposite of what the rubric asks for and worse than no signal at all.
117
- //
118
- // Every path form is derived from the project's DECLARED layout roots, never hardcoded, so a
119
- // re-rooted project keeps working. The `(?:[\w.-]+/)*` segment matters: a node's spec can sit NESTED
120
- // under its root (`.agents/specs/sdd/authoring/spec-gate/`), in which case `fileToNode`'s
121
- // capability-first rule maps the spec side to `sdd/authoring` and only the impl side to
122
- // `sdd/spec-gate` — so a flat `<root>/<capability>/` derivation would still miss the spec path.
123
- //
124
- // This stays MENTION-based and cheap by design. Real produced/consumed symbol dependency is
125
- // ssa-lowering / collision-ladder territory; the boundary was never the problem, only the recall.
126
-
127
- /** Left boundary: a reference must not start mid-token. */
128
- const LB = '(?:^|[^\\w/:.-])'
129
-
130
- function stripTrailingSlash(s: string): string {
131
- return s.replace(/\/+$/, '')
132
- }
133
-
134
- /** The reference forms that name `nodeId` unambiguously from ANYWHERE (each is project-qualified):
135
- * the bare id (`sdd/spec-gate`), the skill-style ref (`sdd:spec-gate`), and a path under any of the
136
- * project's declared roots, at any nesting depth (`plugins/sdd/skills/spec-gate/`,
137
- * `.agents/specs/sdd/authoring/spec-gate/`). */
138
- function globalReferencePattern(project: string, capability: string, roots: string[]): RegExp {
139
- const alts = [
140
- `${LB}${escapeRegExp(`${project}/${capability}`)}(?![\\w-])`,
141
- `${LB}${escapeRegExp(`${project}:${capability}`)}(?![\\w-])`,
142
- ]
143
- for (const root of roots) {
144
- alts.push(`${escapeRegExp(stripTrailingSlash(root))}/(?:[\\w.-]+/)*${escapeRegExp(capability)}/`)
145
- }
146
- return new RegExp(alts.join('|'))
147
- }
148
-
149
- /** The relative-link form (`../spec-gate/`, `../../authoring/spec-gate/`) — a sibling-node link. It
150
- * carries no project, so it is only counted from a file in the SAME project; otherwise two projects
151
- * sharing a capability name (`manage`, `design`) would cross-credit each other's fan-in. */
152
- function relativeReferencePattern(capability: string): RegExp {
153
- return new RegExp(`(?:\\.\\./)+(?:[\\w.-]+/)*${escapeRegExp(capability)}/`)
154
- }
155
-
156
- function projectRoots(project: string, layouts: ProjectLayout[]): string[] {
157
- return layouts.filter((l) => l.project === project).flatMap((l) => l.roots)
158
- }
159
-
160
- /**
161
- * computeFanInMap — centrality for the requested areas: area A's fan-in is the number of OTHER areas
162
- * holding at least one file that REFERENCES A in any of the forms the corpus actually uses (see
163
- * `globalReferencePattern` / `relativeReferencePattern`). Because a node's file set spans its spec
164
- * AND impl roots, a reference from an implementation file counts exactly as spec prose does — fan-in
165
- * measures the project's real lean on an area. An area's own files never count toward its own fan-in.
166
- *
167
- * `targets` defaults to every known area; passing only the areas actually being scored keeps the CLI
168
- * cheap (a one-area touch-set costs 1 pattern × N files instead of 229 × N). Each file is read at
169
- * most once, and an owner already credited for a target is skipped.
170
- */
171
- export function computeFanInMap(
172
- byNode: Map<string, string[]>,
173
- root: string,
174
- layouts: ProjectLayout[],
175
- targets?: string[],
176
- ): Map<string, number> {
177
- const ids = targets ?? [...byNode.keys()]
178
- const specs = ids.map((id) => {
179
- const slash = id.indexOf('/')
180
- const project = id.slice(0, slash)
181
- const capability = id.slice(slash + 1)
182
- return {
183
- id,
184
- project,
185
- global: globalReferencePattern(project, capability, projectRoots(project, layouts)),
186
- relative: relativeReferencePattern(capability),
187
- }
188
- })
189
- const referencers = new Map<string, Set<string>>(ids.map((id) => [id, new Set<string>()]))
190
- for (const [owner, files] of byNode) {
191
- const ownerProject = projectOf(owner)
192
- for (const rel of files) {
193
- let text: string
194
- try {
195
- text = readFileSync(join(root, rel), 'utf8')
196
- } catch {
197
- continue
198
- }
199
- for (const spec of specs) {
200
- if (spec.id === owner) continue
201
- const seen = referencers.get(spec.id)
202
- if (seen?.has(owner)) continue // this owner already counted for `spec.id`
203
- // A relative link carries no project, so it only counts within the same project.
204
- if (spec.global.test(text) || (ownerProject === spec.project && spec.relative.test(text))) {
205
- seen?.add(owner)
206
- }
207
- }
208
- }
209
- }
210
- return new Map(ids.map((id) => [id, referencers.get(id)?.size ?? 0]))
211
- }
212
-
213
- // ── Sensitivity — declared, never inferred ──
214
-
215
- /**
216
- * Parses the opt-in `.agents/sdd/sensitive-paths.toml`: a `sensitive = [ "id", ... ]` string array.
217
- *
218
- * LINE-anchored and lenient, matching manage-spec-anchors' `anchors = [ … ]` parser byte for byte in
219
- * shape — a leading comment, a trailing comment, or a neighbouring key are ordinary TOML and must
220
- * parse. An earlier whole-file-anchored regex rejected all three, so a perfectly valid file that
221
- * marked an area computed no level at all.
222
- *
223
- * An EMPTY file is `[]` (a real "nothing marked" declaration). A file with no `sensitive` array at
224
- * all throws — that is genuinely malformed, and an unreadable marking is not evidence of no
225
- * markings.
226
- */
227
- export function parseSensitivePaths(text: string): string[] {
228
- if (text.trim() === '') return []
229
- const m = /(^|\n)\s*sensitive\s*=\s*\[([\s\S]*?)\]/.exec(text)
230
- if (!m) throw new Error('expected a `sensitive = [...]` array')
231
- const out: string[] = []
232
- for (const q of m[2].matchAll(/"([^"]*)"|'([^']*)'/g)) out.push((q[1] ?? q[2]).trim())
233
- return out.filter((s) => s !== '')
234
- }
235
-
236
- export type SensitiveResult = { ok: true; marked: string[] } | { ok: false; error: string }
237
-
238
- /** Reads the opt-in sensitive-paths file. **Absent** (ENOENT) = `{ ok: true, marked: [] }` — no area
239
- * is sensitive, and that is NOT an error. Anything else — present-but-unparseable, unreadable
240
- * (permissions), or not a regular file — = `{ ok: false, error }`, and the caller computes no level.
241
- *
242
- * ONLY ENOENT is benign. A bare `catch` here would swallow EACCES and a directory-at-the-path into
243
- * "no markings", which fails in the DANGEROUS direction: the estimate silently UNDER-calls blast on
244
- * exactly the areas a project took the trouble to mark. A read that cannot classify must fail loud,
245
- * never default to the safe-looking answer — the same duty the absent-vs-unparseable scenarios draw:
246
- * an unreadable marking is not evidence of no markings. */
247
- export function readSensitivePaths(corpusRoot: string): SensitiveResult {
248
- const file = join(corpusRoot, SENSITIVE_PATHS_FILE)
249
- let text: string
250
- try {
251
- if (!statSync(file).isFile()) {
252
- return { ok: false, error: `${SENSITIVE_PATHS_FILE} is not a regular file` }
253
- }
254
- text = readFileSync(file, 'utf8')
255
- } catch (err) {
256
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ok: true, marked: [] }
257
- return { ok: false, error: `${SENSITIVE_PATHS_FILE} is unreadable: ${(err as Error).message}` }
258
- }
259
- try {
260
- return { ok: true, marked: parseSensitivePaths(text) }
261
- } catch (err) {
262
- return { ok: false, error: `${SENSITIVE_PATHS_FILE} is unreadable: ${(err as Error).message}` }
263
- }
264
- }
265
-
266
- // ── The computation — count × centrality × sensitivity ──
267
- //
268
- // The rubric fixes the three inputs and their ordering properties; the exact arithmetic is this
269
- // engine's choice (deliberately unpinned — see the node README). Buckets, not a smooth curve, keep
270
- // the mapping easy to explain and to hold constant under review:
271
- // - breadth max(countScore, coverageScore) — how much of the project is disturbed, measured
272
- // two ways, whichever says MORE:
273
- // * countScore 1 → 0; 2-3 → 1; 4+ → 3 (absolute reach: touching many areas is broad even
274
- // in a large project the touch-set nowhere near covers)
275
- // * coverageScore 3 iff the touch-set covers EVERY work area of a touched project that has
276
- // >= 2 work areas, else 0 (relative reach: a 3-area project touched entirely IS
277
- // project-wide — the barrier agreement holds at every project size >= 2, not just 4+)
278
- // - centrality 0 → 0; 1-2 → 1; 3+ → 2
279
- // - sensitivity any touched area marked → +2 (a single marking is enough to move the level)
280
- // score >= 3 → high; score >= 1 → medium; score 0 → low.
281
- //
282
- // The `>= 2 work areas` guard on coverage is load-bearing, not a nicety, and since #238 it is the
283
- // CONTRACT rather than this engine's private tiebreaker. It is keyed per PROJECT, not per corpus: a
284
- // 1-area project inside a larger multi-project corpus is still never project-wide.
285
- //
286
- // Before #238 the frozen suite was self-contradictory here — on a 1-area project "a single
287
- // peripheral work area" (→ low) and "a touch-set reaching across every work area of its project"
288
- // (→ high) described the SAME input with opposite Thens, and this guard silently picked the winner.
289
- // The suite now states the precondition itself: the project-wide scenario requires a project holding
290
- // more than one work area, and "a lone work area is its whole project but is not project-wide reach"
291
- // pins the 1-area answer to low FOR A LONE AREA WITH NO FAN-IN AND NO MARKING. Coverage is reach
292
- // RELATIVE to a project, and a project of one has none to cover, so coverage never fires there and
293
- // BREADTH rests on absolute count alone — centrality and sensitivity still score, so a lone area that
294
- // is central or marked computes medium or high.
295
-
296
- function countScore(n: number): number {
297
- if (n <= 1) return 0
298
- if (n <= 3) return 1
299
- return 3
300
- }
301
-
302
- const COVERAGE_SCORE = 3
303
-
304
- // Centrality thresholds are calibrated against REAL fan-in, which spans roughly 0..17 on a corpus of
305
- // this size. They were originally 0/1-2/3+ — tuned while the reference matcher only saw bare ids, so
306
- // the observed corpus max was 4 and "3+" read as the top of the scale. With the matcher fixed, "3+"
307
- // covered most of the distribution and no single area could ever reach `high` on reach alone, which
308
- // under-called every hub (`sdd/spec-gate`, referenced by 10 other areas, computed `medium`). The top
309
- // tier now marks a genuine hub: touching an area that a large share of the project leans on IS
310
- // project-scale reach, even at count 1.
311
- function centralityScore(fanIn: number): number {
312
- if (fanIn <= 0) return 0
313
- if (fanIn <= 2) return 1
314
- if (fanIn <= 6) return 2
315
- return 3
316
- }
317
-
318
- const SENSITIVITY_SCORE = 2
319
-
320
- export function levelFromScore(score: number): BlastLevel {
321
- if (score >= 3) return 'high'
322
- if (score >= 1) return 'medium'
323
- return 'low'
324
- }
325
-
326
- export interface Reasons {
327
- count: number
328
- maxFanIn: number
329
- sensitiveAreas: string[]
330
- /** Every touched project the touch-set covers ENTIRELY (and which has >= 2 work areas) — the
331
- * coverage half of breadth, named so a `high` on reach alone is explainable. */
332
- projectWide: string[]
333
- }
334
-
335
- /** The project a work area belongs to — the first segment of its `project/capability` id. */
336
- function projectOf(nodeId: string): string {
337
- return nodeId.split('/')[0]
338
- }
339
-
340
- /**
341
- * projectsCoveredEntirely — the touched projects whose EVERY work area is in the touch-set, among
342
- * projects holding >= 2 work areas. Coverage is relative reach: it asks what fraction of a project
343
- * is disturbed, where `count` only asks how many areas in absolute terms. A project with a single
344
- * work area never qualifies (see the guard note above).
345
- *
346
- * "Every work area of its project" means every area the LAYOUTS declare — `byNode` is the discovered
347
- * area map keyed by node id, so a project's area set is its nodes across all of its declared roots
348
- * (spec + impl), not whatever a directory walk happened to find.
349
- */
350
- export function projectsCoveredEntirely(resolved: string[], byNode: Map<string, string[]>): string[] {
351
- const areasByProject = new Map<string, string[]>()
352
- for (const node of byNode.keys()) {
353
- const p = projectOf(node)
354
- const areas = areasByProject.get(p)
355
- if (areas) areas.push(node)
356
- else areasByProject.set(p, [node])
357
- }
358
- const resolvedSet = new Set(resolved)
359
- const covered: string[] = []
360
- for (const project of new Set(resolved.map(projectOf))) {
361
- const areas = areasByProject.get(project) ?? []
362
- if (areas.length < 2) continue // a 1-area project is never "project-wide" — see the guard note
363
- if (areas.every((a) => resolvedSet.has(a))) covered.push(project)
364
- }
365
- return covered.sort()
366
- }
367
-
368
- /** The pure scoring step, once `count`/`maxFanIn`/`sensitiveAreas`/`projectWide` are known. Exported
369
- * so the ordering-property scenarios (hub-vs-leaf, marked-vs-unmarked) can be driven directly. */
370
- export function scoreBlast(
371
- count: number,
372
- maxFanIn: number,
373
- sensitiveAreas: string[],
374
- projectWide: string[] = [],
375
- ): { level: BlastLevel; reasons: Reasons } {
376
- const breadth = Math.max(countScore(count), projectWide.length > 0 ? COVERAGE_SCORE : 0)
377
- const score = breadth + centralityScore(maxFanIn) + (sensitiveAreas.length > 0 ? SENSITIVITY_SCORE : 0)
378
- return { level: levelFromScore(score), reasons: { count, maxFanIn, sensitiveAreas, projectWide } }
379
- }
380
-
381
- // ── The line-up — declared against computed ──
382
-
383
- export type LineUpOutcome = 'agrees' | 'under-called' | 'over-called' | 'no-declared'
384
-
385
- export interface LineUp {
386
- outcome: LineUpOutcome
387
- computed: BlastLevel
388
- declared?: BlastLevel
389
- }
390
-
391
- const ORDER: BlastLevel[] = ['low', 'medium', 'high']
392
-
393
- /** Lines the computed level up against the hand-typed declared one. An absent or `unknown` declared
394
- * blast is not an error — it is `no-declared`, and the computed level still stands on its own. */
395
- export function lineUp(computed: BlastLevel, declared?: DeclaredBlast): LineUp {
396
- if (declared === undefined || declared === 'unknown') return { outcome: 'no-declared', computed }
397
- const di = ORDER.indexOf(declared)
398
- const ci = ORDER.indexOf(computed)
399
- if (di === ci) return { outcome: 'agrees', computed, declared }
400
- if (di < ci) return { outcome: 'under-called', computed, declared }
401
- return { outcome: 'over-called', computed, declared }
402
- }
403
-
404
- // ── The whole estimate — pure once handed a corpus scan ──
405
-
406
- export interface EstimateResult {
407
- touchSet: string[]
408
- resolved: string[]
409
- unresolved: string[]
410
- computed: BlastLevel | 'unknown' | null
411
- reasons: Reasons | null
412
- lineUp: LineUp | null
413
- error?: string
414
- }
415
-
416
- /** What `estimateBlast` needs beyond the touch-set and the layouts. `root` is the repo root the
417
- * layouts' repo-relative roots (and the opt-in sensitive-paths file) resolve against. */
418
- export interface EstimateOptions {
419
- root?: string
420
- declared?: DeclaredBlast
421
- }
422
-
423
- /**
424
- * estimateBlast — the whole derivation, over INJECTED layouts. Discovers the corpus's work areas by
425
- * mapping every file under every declared root through touch-set-correction's pure `fileToNode`,
426
- * resolves the touch-set against those areas (unresolved areas are surfaced, never dropped), reads
427
- * the opt-in sensitive-paths file (fails loud on a malformed one — computed/reasons/lineUp all come
428
- * back null, `error` names why), and — when at least one area resolved — scores
429
- * breadth × centrality × sensitivity and lines the result up against `opts.declared`. A touch-set
430
- * that resolves to zero known areas (including the empty touch-set) computes `unknown`: nothing
431
- * touched is not evidence of low reach.
432
- *
433
- * Layouts are INJECTED rather than discovered here: `fileToNode` is pure, so tests construct layouts
434
- * as fixtures over a constructed corpus (the frozen suite's "never the live corpus" preamble holds),
435
- * while the CLI sources them from `discoverLayouts`.
436
- */
437
- export function estimateBlast(
438
- touchSet: string[],
439
- layouts: ProjectLayout[],
440
- opts: EstimateOptions = {},
441
- ): EstimateResult {
442
- const root = opts.root ?? '.'
443
- const byNode = discoverWorkAreas(layouts, root)
444
- const known = new Set(byNode.keys())
445
- // A touch-set is a SET of work areas: dedupe before counting. `count` measures how many distinct
446
- // areas are disturbed, so naming one area twice must not read as twice the reach — otherwise the
447
- // same change scores a higher level for being typed redundantly. The sibling touch-set-correction
448
- // dedupes its own output, but the mission-graph store does not, so a duplicate genuinely arrives.
449
- const unique = [...new Set(touchSet)]
450
- const resolved = unique.filter((a) => known.has(a))
451
- const unresolved = unique.filter((a) => !known.has(a))
452
-
453
- const sensitive = readSensitivePaths(root)
454
- if (!sensitive.ok) {
455
- return { touchSet, resolved, unresolved, computed: null, reasons: null, lineUp: null, error: sensitive.error }
456
- }
457
-
458
- if (resolved.length === 0) {
459
- return {
460
- touchSet,
461
- resolved,
462
- unresolved,
463
- computed: 'unknown',
464
- reasons: { count: 0, maxFanIn: 0, sensitiveAreas: [], projectWide: [] },
465
- lineUp: null,
466
- }
467
- }
468
-
469
- const markedSet = new Set(sensitive.marked)
470
- // Only the resolved areas are scored — fan-in for the rest of the corpus is never asked for.
471
- const fanInMap = computeFanInMap(byNode, root, layouts, resolved)
472
- const maxFanIn = Math.max(...resolved.map((a) => fanInMap.get(a) ?? 0))
473
- const sensitiveAreas = resolved.filter((a) => markedSet.has(a))
474
- const projectWide = projectsCoveredEntirely(resolved, byNode)
475
- const { level, reasons } = scoreBlast(resolved.length, maxFanIn, sensitiveAreas, projectWide)
476
-
477
- return { touchSet, resolved, unresolved, computed: level, reasons, lineUp: lineUp(level, opts.declared) }
478
- }
479
-
480
- // ── Render (TOON — the token-efficient tabular form the repo's other sdd engines emit) ──
481
-
482
- function toonQuote(v: string): string {
483
- if (v === '' || /[",;]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
484
- return v
485
- }
486
-
487
- export function renderResultToon(r: EstimateResult): string {
488
- if (r.error) {
489
- return [
490
- `blast-estimate: error="${r.error}"`,
491
- `unresolved[${r.unresolved.length}]: ${r.unresolved.map(toonQuote).join(';')}`,
492
- ].join('\n')
493
- }
494
- const lines: string[] = []
495
- const lu = r.lineUp
496
- const lineUpPart = lu
497
- ? lu.outcome === 'no-declared'
498
- ? 'lineup=no-declared'
499
- : `lineup=${lu.outcome}(declared=${lu.declared})`
500
- : 'lineup=n/a'
501
- lines.push(`blast-estimate: computed=${r.computed} ${lineUpPart}`)
502
- if (r.reasons) {
503
- lines.push(
504
- `reasons: count=${r.reasons.count} maxFanIn=${r.reasons.maxFanIn} sensitiveAreas=[${r.reasons.sensitiveAreas.join(';')}] projectWide=[${r.reasons.projectWide.join(';')}]`,
505
- )
506
- }
507
- lines.push(`resolved[${r.resolved.length}]: ${r.resolved.map(toonQuote).join(';')}`)
508
- lines.push(`unresolved[${r.unresolved.length}]: ${r.unresolved.map(toonQuote).join(';')}`)
509
- return lines.join('\n')
510
- }
511
-
512
- // ── CLI ──
513
-
514
- function flag(argv: string[], name: string): string | undefined {
515
- const i = argv.indexOf(name)
516
- return i === -1 ? undefined : argv[i + 1]
517
- }
518
-
519
- function allFlags(argv: string[], name: string): string[] {
520
- const out: string[] = []
521
- for (let i = 0; i < argv.length; i++) {
522
- if (argv[i] === name && argv[i + 1] !== undefined) out.push(argv[i + 1])
523
- }
524
- return out
525
- }
526
-
527
- function splitCsv(v: string | undefined): string[] {
528
- if (v === undefined || v === '') return []
529
- return v
530
- .split(',')
531
- .map((s) => s.trim())
532
- .filter((s) => s.length > 0)
533
- }
534
-
535
- /** Parses one `--layout '<project>:<root1>,<root2>'` flag value into a ProjectLayout (the same shape
536
- * and flag touch-set-correction accepts, so the two tools take identical layout overrides). */
537
- export function parseLayoutFlag(value: string): ProjectLayout | null {
538
- const idx = value.indexOf(':')
539
- if (idx === -1) return null
540
- const project = value.slice(0, idx).trim()
541
- const roots = splitCsv(value.slice(idx + 1))
542
- if (project === '' || roots.length === 0) return null
543
- return { project, roots }
544
- }
545
-
546
- /** The legal `--declared` values, exactly. Returns `null` for anything else so the CLI can fail loud
547
- * rather than let an unranked value masquerade as "below everything". Case-sensitive on purpose: the
548
- * store writes these lowercase, and quietly accepting `High` would invite a second spelling. */
549
- export function parseDeclared(raw: string): DeclaredBlast | null {
550
- return raw === 'low' || raw === 'medium' || raw === 'high' || raw === 'unknown' ? raw : null
551
- }
552
-
553
- export function main(argv: string[]): number {
554
- const root = flag(argv, '--root') ?? '.'
555
- const touchSet = splitCsv(flag(argv, '--touch-set'))
556
- const declaredRaw = flag(argv, '--declared')
557
- // Validate rather than cast. `lineUp` ranks via ORDER.indexOf, so an unrecognized value scores -1
558
- // and reads as "below every computed level" — silently fabricating the `under-called` finding this
559
- // tool exists to raise, out of nothing but a typo. Fail loud instead: the same duty the
560
- // sensitive-paths reader honors, and the dangerous direction is the one to refuse.
561
- const declared = declaredRaw === undefined ? undefined : parseDeclared(declaredRaw)
562
- if (declared === null) {
563
- process.stderr.write(
564
- `blast-estimate: --declared must be one of low, medium, high, unknown (got "${declaredRaw}")\n`,
565
- )
566
- return 1
567
- }
568
- const format = flag(argv, '--format') === 'json' ? 'json' : 'toon'
569
-
570
- // Layouts come from `--layout` when given, else from discover-specs via discoverLayouts — the
571
- // same resolution order touch-set-correction uses.
572
- const layoutFlags = allFlags(argv, '--layout')
573
- const layouts =
574
- layoutFlags.length > 0
575
- ? layoutFlags.map(parseLayoutFlag).filter((l): l is ProjectLayout => l !== null)
576
- : discoverLayouts(root, root)
577
-
578
- const result = estimateBlast(touchSet, layouts, { root, declared })
579
-
580
- process.stdout.write(`${format === 'json' ? JSON.stringify(result, null, 2) : renderResultToon(result)}\n`)
581
- return result.error ? 1 : 0
582
- }
583
-
584
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
585
- process.exit(main(process.argv.slice(2)))
586
- }