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,662 +0,0 @@
1
- #!/usr/bin/env node
2
- // collision-ladder — the finer-than-node ladder (issue #189, second bullet). A read-only, pairwise
3
- // collision CLASSIFIER: given a KNOWN node-level collision between two missions (the mission-graph
4
- // WAW-mutex already found it), descend a ladder of finer grains — file → region → semantic(scenario)
5
- // — and stop at the first rung that classifies the clash HARD (must serialize) vs SOFT (can run in
6
- // parallel, reconciled by rebase). Plus the shared-thin-file hard→soft downgrade: a file touched by
7
- // many missions (router/barrel/registry) that would over-serialize gets the region/semantic descent
8
- // to downgrade, and is flagged as an architectural smell. The collision-ladder node of the SDD
9
- // project spec (repo-only) carries the full contract.
10
- //
11
- // Architecture — pure derivation kept apart from IO, on purpose (touch-set-correction.mts's
12
- // convention it mirrors):
13
- // - isFeature / isCode / hunksDisjoint / classifyFile / classify are PURE: they take and return
14
- // plain data — no fs/network access. Tests exercise these directly over CONSTRUCTED pairwise
15
- // touched-detail — never a live git diff or the live mission-graph store.
16
- // - readFileHunks / collectMissionTouch are the thin IO SEAM: readFileHunks shells out to
17
- // `git diff -U0` for a file's touched line-ranges (the new region source); collectMissionTouch
18
- // REUSES the sibling touch-set-correction's `collectChangedFiles` (which itself composes
19
- // `resolve-governances` for artifact-type and `gherkin-cli diff` for changed scenarios — never a
20
- // reimplemented differ) and layers the hunks on top. NOT unit-tested (network/binary/fs boundary).
21
- // - main() is a thin CLI: it classifies a CONSTRUCTED ClassifyInput read as JSON (--input) or one
22
- // sourced from two git ranges (--from-git), rendering TOON by default or `--format json`.
23
- //
24
- // This tool is READ-ONLY with respect to the mission graph: it never writes to it, never detects a
25
- // collision (that is the mission-graph WAW-mutex's job — this runs only AFTER one is found), and
26
- // never schedules (it returns a verdict; the scheduler consumes it). It descends a CODE collision to
27
- // the SYMBOL rung — produced/consumed symbols classify it disjoint(soft) / write-write(hard,
28
- // `symbol-waw`) / read-after-write(hard, `symbol-raw`); when the symbols cannot be inferred it stays
29
- // hard, flagged `symbol-rung-deferred`. It does NOT do the ★ SSA-lowering doctrine (issue #189's
30
- // third-bullet capstone: partitioning a change into missions, versioning a write-write into an ordered
31
- // dependency) — that stays deferred. It descends only to DOWNGRADE a suspected false-hard —
32
- // conservative-first, relax-on-evidence — never to raise a new collision.
33
- //
34
- // No dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions are exported for
35
- // node:test; running the file directly drives the CLI.
36
-
37
- import { execFileSync } from 'node:child_process'
38
- import { realpathSync } from 'node:fs'
39
- import { dirname, join } from 'node:path'
40
- import { fileURLToPath, pathToFileURL } from 'node:url'
41
- import {
42
- collectChangedFiles,
43
- fileToNode,
44
- type ProjectLayout,
45
- } from '../../touch-set-correction/scripts/touch-set-correction.mts'
46
-
47
- // ── Types ──
48
-
49
- /** An inclusive touched line-range within a file (the region rung's grain). */
50
- export interface LineRange {
51
- start: number
52
- end: number
53
- }
54
-
55
- /** The produced (written) and consumed (read) symbol names a code file's diff yields — the symbol
56
- * rung's grain. */
57
- export interface SymbolSet {
58
- produced: string[]
59
- consumed: string[]
60
- }
61
-
62
- /** One changed file of one mission under the colliding node. `hunks: null` means the line-hunks
63
- * were not recorded (disjointness cannot be proven — the region rung must not clear it). `[]` means
64
- * recorded-but-empty. `changedScenarios` is meaningful only for a `.feature` (the semantic rung).
65
- * `symbols` is meaningful only for code (the symbol rung): `null` or `undefined` (both treated
66
- * identically — un-inferable) means the produced/consumed detail could not be extracted, so the
67
- * symbol rung stays hard, flagged `symbol-rung-deferred`. Optional so existing constructed fixtures
68
- * that predate the symbol rung stay valid. */
69
- export interface FileTouch {
70
- path: string
71
- artifactType: string
72
- hunks: LineRange[] | null
73
- changedScenarios: string[]
74
- symbols?: SymbolSet | null
75
- }
76
-
77
- /** One mission's touched detail for the single colliding node. */
78
- export interface MissionTouch {
79
- mission: string
80
- files: FileTouch[]
81
- }
82
-
83
- /** The classifier's whole input: the colliding node, the two missions' touched detail, an optional
84
- * fleet-wide touching-mission degree per path (for shared-thin detection — defaults to the pair's
85
- * 2), and the shared-thin degree threshold (default 3). */
86
- export interface ClassifyInput {
87
- node: string
88
- x: MissionTouch
89
- y: MissionTouch
90
- degrees?: Record<string, number>
91
- sharedThinThreshold?: number
92
- }
93
-
94
- export type Collision = 'hard' | 'soft'
95
- export type Rung = 'file' | 'region' | 'semantic' | 'symbol' | 'node'
96
- export type Confidence = 'high' | 'medium' | 'low'
97
-
98
- /** The per-shared-file verdict — the atom the node rollup is folded from. */
99
- export interface FileVerdict {
100
- path: string
101
- collision: Collision
102
- rung: Rung
103
- reason: string
104
- sharedThin: boolean
105
- }
106
-
107
- /** The node-level verdict — hard if ANY shared file is hard; the decisive rung + a confidence that
108
- * decays down the ladder; the per-shared-file detail; the shared-thin smells; and the files whose
109
- * downgrade is deferred to the ★ symbol rung. */
110
- export interface LadderVerdict {
111
- node: string
112
- collision: Collision
113
- rung: Rung
114
- confidence: Confidence
115
- sharedFiles: FileVerdict[]
116
- smells: string[]
117
- deferrals: string[]
118
- }
119
-
120
- // ── Pure derivations ──
121
-
122
- const DEFAULT_SHARED_THIN_THRESHOLD = 3
123
-
124
- /** The scenario-rung gate is STRUCTURAL — the `.feature` extension — never the resolved
125
- * artifact-type (matches the sibling touch-set-correction's isFeature). */
126
- export function isFeature(path: string): boolean {
127
- return path.endsWith('.feature')
128
- }
129
-
130
- const CODE_EXTENSIONS = [
131
- '.mts',
132
- '.ts',
133
- '.tsx',
134
- '.cts',
135
- '.mjs',
136
- '.js',
137
- '.jsx',
138
- '.cjs',
139
- '.py',
140
- '.go',
141
- '.rs',
142
- '.java',
143
- '.rb',
144
- '.c',
145
- '.h',
146
- '.cpp',
147
- '.hpp',
148
- '.cs',
149
- '.swift',
150
- '.kt',
151
- '.sh',
152
- ]
153
-
154
- /** A file whose semantic-rung anchor is a SYMBOL (code) rather than a scenario or prose section.
155
- * Its downgrade needs symbol-level analysis — the ★ deferred capstone — so an overlapping-region
156
- * code file stays hard. Detected by extension (structural, like isFeature); never a `.feature`. */
157
- export function isCode(path: string): boolean {
158
- if (isFeature(path)) return false
159
- return CODE_EXTENSIONS.some((ext) => path.endsWith(ext))
160
- }
161
-
162
- /** Two hunk sets are disjoint when no range in one overlaps any range in the other. A `null` (unknown)
163
- * hunk set is NOT disjoint from anything — disjointness cannot be proven, so the region rung must not
164
- * clear it. Two inclusive ranges [a,b] and [c,d] overlap iff a ≤ d and c ≤ b. */
165
- export function hunksDisjoint(a: LineRange[] | null, b: LineRange[] | null): boolean {
166
- if (a === null || b === null) return false
167
- for (const ra of a) {
168
- for (const rb of b) {
169
- if (ra.start <= rb.end && rb.start <= ra.end) return false
170
- }
171
- }
172
- return true
173
- }
174
-
175
- const RUNG_CONFIDENCE: Record<Rung, Confidence> = {
176
- file: 'high',
177
- region: 'medium',
178
- semantic: 'low',
179
- symbol: 'low',
180
- node: 'low',
181
- }
182
-
183
- const RUNG_DEPTH: Record<Rung, number> = { file: 1, region: 2, semantic: 3, symbol: 4, node: 5 }
184
- const CONFIDENCE_RANK: Record<Confidence, number> = { low: 1, medium: 2, high: 3 }
185
-
186
- /**
187
- * classifyFile — the ladder descent for ONE shared file (present in both missions). Region first: if
188
- * both sides' hunks are known and disjoint ⇒ SOFT at `region`. Else descend to the semantic rung,
189
- * split by artifact-type (keyed structurally off the path):
190
- * - `.feature` (behavioral prose) ⇒ the SCENARIO: different scenarios ⇒ SOFT, the same scenario ⇒ HARD.
191
- * - code ⇒ descend further to the SYMBOL rung (see `classifySymbols`).
192
- * - non-behavioral prose (no suite to anchor) ⇒ do NOT descend, stay node-serial: HARD, reason `no-anchor`.
193
- * The shared-thin flag rides along (degree ≥ threshold) — the descent above IS its hard→soft downgrade.
194
- */
195
- export function classifyFile(fx: FileTouch, fy: FileTouch, degree: number, threshold: number): FileVerdict {
196
- const path = fx.path
197
- const sharedThin = degree >= threshold
198
- const base = { path, sharedThin }
199
-
200
- // region rung — textual, artifact-neutral
201
- if (hunksDisjoint(fx.hunks, fy.hunks)) {
202
- return { ...base, collision: 'soft', rung: 'region', reason: 'disjoint-hunks' }
203
- }
204
-
205
- // semantic rung — artifact-type-specific
206
- if (isFeature(path)) {
207
- const ys = new Set(fy.changedScenarios)
208
- const shared = fx.changedScenarios.some((s) => ys.has(s))
209
- return shared
210
- ? { ...base, collision: 'hard', rung: 'semantic', reason: 'same-scenario' }
211
- : { ...base, collision: 'soft', rung: 'semantic', reason: 'disjoint-scenarios' }
212
- }
213
- if (isCode(path)) {
214
- return classifySymbols(fx, fy, base)
215
- }
216
- return { ...base, collision: 'hard', rung: 'node', reason: 'no-anchor' }
217
- }
218
-
219
- /**
220
- * classifySymbols — the symbol rung, a code collision's finest grain (★ #189, first half). Compares
221
- * each side's produced (written) and consumed (read) symbol names, in precedence order:
222
- * 1. either side's `symbols` is `null`/`undefined` (un-inferable) ⇒ HARD, `symbol-rung-deferred`
223
- * (conservative-first — a parse gap must never relax a real clash).
224
- * 2. the two `produced` sets intersect (both write the same symbol) ⇒ HARD, `symbol-waw`.
225
- * 3. one side's `consumed` intersects the other side's `produced` (either direction) ⇒ HARD,
226
- * `symbol-raw`.
227
- * 4. no symbol in common ⇒ SOFT, `disjoint-symbols`.
228
- * Pure — no fs/network access; deterministic.
229
- */
230
- export function classifySymbols(
231
- fx: FileTouch,
232
- fy: FileTouch,
233
- base: { path: string; sharedThin: boolean },
234
- ): FileVerdict {
235
- if (fx.symbols === null || fx.symbols === undefined || fy.symbols === null || fy.symbols === undefined) {
236
- return { ...base, collision: 'hard', rung: 'symbol', reason: 'symbol-rung-deferred' }
237
- }
238
- const xProduced = new Set(fx.symbols.produced)
239
- const yProduced = new Set(fy.symbols.produced)
240
- if (fx.symbols.produced.some((s) => yProduced.has(s))) {
241
- return { ...base, collision: 'hard', rung: 'symbol', reason: 'symbol-waw' }
242
- }
243
- const rawClash =
244
- fx.symbols.consumed.some((s) => yProduced.has(s)) || fy.symbols.consumed.some((s) => xProduced.has(s))
245
- if (rawClash) {
246
- return { ...base, collision: 'hard', rung: 'symbol', reason: 'symbol-raw' }
247
- }
248
- return { ...base, collision: 'soft', rung: 'symbol', reason: 'disjoint-symbols' }
249
- }
250
-
251
- function bySharedThenPath(a: FileVerdict, b: FileVerdict): number {
252
- return a.path < b.path ? -1 : a.path > b.path ? 1 : 0
253
- }
254
-
255
- /**
256
- * classify — the whole pure ladder. Recovers the SHARED files (same path in both missions' detail),
257
- * classifies each, and folds the node rollup: the node collision is HARD if ANY shared file is hard,
258
- * else SOFT. When no file is shared, the file rung already clears it (soft, high confidence). The
259
- * decisive rung is the deepest rung among the files that DECIDE the verdict (the hard files when hard,
260
- * the soft-cleared files when soft), and the confidence is that rung's — so it decays down the ladder.
261
- * Deterministic + stably ordered for fixed inputs; no fs/network access of its own.
262
- */
263
- export function classify(input: ClassifyInput): LadderVerdict {
264
- const threshold = input.sharedThinThreshold ?? DEFAULT_SHARED_THIN_THRESHOLD
265
- const degrees = input.degrees ?? {}
266
- const xByPath = new Map(input.x.files.map((f) => [f.path, f]))
267
- const sharedPaths = input.y.files.filter((f) => xByPath.has(f.path)).map((f) => f.path)
268
- const uniqueSharedPaths = [...new Set(sharedPaths)].sort()
269
-
270
- // file rung — disjoint files never really collide
271
- if (uniqueSharedPaths.length === 0) {
272
- return {
273
- node: input.node,
274
- collision: 'soft',
275
- rung: 'file',
276
- confidence: 'high',
277
- sharedFiles: [],
278
- smells: [],
279
- deferrals: [],
280
- }
281
- }
282
-
283
- const yByPath = new Map(input.y.files.map((f) => [f.path, f]))
284
- const sharedFiles: FileVerdict[] = uniqueSharedPaths
285
- .map((path) => {
286
- const fx = xByPath.get(path) as FileTouch
287
- const fy = yByPath.get(path) as FileTouch
288
- const degree = degrees[path] ?? 2
289
- return classifyFile(fx, fy, degree, threshold)
290
- })
291
- .sort(bySharedThenPath)
292
-
293
- const collision: Collision = sharedFiles.some((f) => f.collision === 'hard') ? 'hard' : 'soft'
294
- const deciding = sharedFiles.filter((f) => f.collision === collision)
295
- const decisive = deciding.reduce(
296
- (deepest, f) => (RUNG_DEPTH[f.rung] > RUNG_DEPTH[deepest.rung] ? f : deepest),
297
- deciding[0],
298
- )
299
-
300
- const smells = sharedFiles
301
- .filter((f) => f.sharedThin)
302
- .map((f) => f.path)
303
- .sort()
304
- const deferrals = sharedFiles
305
- .filter((f) => f.reason === 'symbol-rung-deferred')
306
- .map((f) => f.path)
307
- .sort()
308
-
309
- return {
310
- node: input.node,
311
- collision,
312
- rung: decisive.rung,
313
- confidence: RUNG_CONFIDENCE[decisive.rung],
314
- sharedFiles,
315
- smells,
316
- deferrals,
317
- }
318
- }
319
-
320
- /** Confidence ordering helper (exported for callers comparing two verdicts, e.g. the confidence-decay
321
- * scenario): higher rank = more trustworthy. */
322
- export function confidenceRank(c: Confidence): number {
323
- return CONFIDENCE_RANK[c]
324
- }
325
-
326
- // ── IO seam (thin — network/binary/fs boundary; not unit-tested, mirrors touch-set-correction.mts) ──
327
-
328
- /** A file's touched line-ranges of `base..head` via `git diff -U0` (zero context, so each hunk's
329
- * `+A,B` header is exactly the changed lines). Returns the added-side ranges (the new-file lines the
330
- * mission wrote). On any git failure (absent binary, bad refs, not a repo) returns null (unknown —
331
- * the region rung will not clear it). */
332
- export function readFileHunks(base: string, head: string, path: string, cwd: string): LineRange[] | null {
333
- let out: string
334
- try {
335
- out = execFileSync('git', ['diff', '-U0', `${base}..${head}`, '--', path], {
336
- encoding: 'utf8',
337
- cwd,
338
- stdio: ['ignore', 'pipe', 'ignore'],
339
- })
340
- } catch {
341
- return null
342
- }
343
- const ranges: LineRange[] = []
344
- for (const line of out.split('\n')) {
345
- // @@ -a,b +c,d @@ — the +c,d side is the added lines; d defaults to 1 when omitted
346
- const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line)
347
- if (!m) continue
348
- const start = Number(m[1])
349
- const count = m[2] === undefined ? 1 : Number(m[2])
350
- if (count === 0) continue // a pure deletion touches no new-file line
351
- ranges.push({ start, end: start + count - 1 })
352
- }
353
- return ranges
354
- }
355
-
356
- // TS/JS-family extensions the symbol extractor attempts — a reasonable heuristic subset of
357
- // CODE_EXTENSIONS. Any other code language (.py .go .rs etc.) always defers (returns null).
358
- const JS_TS_EXTENSIONS = ['.mts', '.ts', '.tsx', '.cts', '.mjs', '.js', '.jsx', '.cjs']
359
-
360
- const JS_KEYWORDS = new Set([
361
- 'const',
362
- 'let',
363
- 'var',
364
- 'function',
365
- 'class',
366
- 'return',
367
- 'if',
368
- 'else',
369
- 'for',
370
- 'while',
371
- 'do',
372
- 'switch',
373
- 'case',
374
- 'break',
375
- 'continue',
376
- 'default',
377
- 'new',
378
- 'this',
379
- 'super',
380
- 'import',
381
- 'export',
382
- 'from',
383
- 'as',
384
- 'async',
385
- 'await',
386
- 'try',
387
- 'catch',
388
- 'finally',
389
- 'throw',
390
- 'typeof',
391
- 'instanceof',
392
- 'in',
393
- 'of',
394
- 'null',
395
- 'undefined',
396
- 'true',
397
- 'false',
398
- 'void',
399
- 'delete',
400
- 'yield',
401
- 'extends',
402
- 'implements',
403
- 'interface',
404
- 'type',
405
- 'enum',
406
- 'namespace',
407
- 'public',
408
- 'private',
409
- 'protected',
410
- 'readonly',
411
- 'static',
412
- 'get',
413
- 'set',
414
- ])
415
-
416
- const IDENTIFIER_RE = /\b[A-Za-z_$][A-Za-z0-9_$]*\b/g
417
-
418
- /** Best-effort, CONSERVATIVE symbol extraction for a TS/JS-family code file's `base..head` diff (the
419
- * symbol rung's thin IO seam, mirroring `readFileHunks`). Shells `git diff <base>..<head> -- <path>`;
420
- * from ADDED lines (`+`) derives `produced` (declared names — `function NAME`, `class NAME`,
421
- * `(export )?(const|let|var) NAME`, a method/assignment head `NAME(` or `NAME =`, `export function
422
- * NAME`) and `consumed` (other referenced identifiers minus the produced set and minus JS keywords).
423
- * Only attempted for TS/JS-family extensions — any other code language, or any failure, returns
424
- * `null` (defer — a parse gap must never relax a real clash). Untested (fs/binary boundary). */
425
- export function extractSymbols(base: string, head: string, path: string, cwd: string): SymbolSet | null {
426
- if (!JS_TS_EXTENSIONS.some((ext) => path.endsWith(ext))) return null
427
- let out: string
428
- try {
429
- out = execFileSync('git', ['diff', `${base}..${head}`, '--', path], {
430
- encoding: 'utf8',
431
- cwd,
432
- stdio: ['ignore', 'pipe', 'ignore'],
433
- })
434
- } catch {
435
- return null
436
- }
437
-
438
- const produced = new Set<string>()
439
- const consumed = new Set<string>()
440
- const declPatterns = [
441
- /^\s*export\s+function\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
442
- /^\s*function\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
443
- /^\s*export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
444
- /^\s*class\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
445
- /^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
446
- /^\s*(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
447
- /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/, // method/function-call head
448
- /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*=[^=]/, // plain assignment head
449
- ]
450
-
451
- let sawAddedLine = false
452
- for (const rawLine of out.split('\n')) {
453
- if (!rawLine.startsWith('+') || rawLine.startsWith('+++')) continue
454
- const line = rawLine.slice(1)
455
- if (line.trim() === '') continue
456
- sawAddedLine = true
457
-
458
- let declared: string | null = null
459
- for (const re of declPatterns) {
460
- const m = re.exec(line)
461
- if (m?.[1]) {
462
- declared = m[1]
463
- break
464
- }
465
- }
466
- if (declared) produced.add(declared)
467
-
468
- const ids = line.match(IDENTIFIER_RE) ?? []
469
- for (const id of ids) {
470
- if (id === declared) continue
471
- if (JS_KEYWORDS.has(id)) continue
472
- if (/^\d/.test(id)) continue
473
- consumed.add(id)
474
- }
475
- }
476
-
477
- if (!sawAddedLine) return null
478
- for (const p of produced) consumed.delete(p)
479
- if (produced.size === 0 && consumed.size === 0) return null
480
-
481
- return { produced: [...produced].sort(), consumed: [...consumed].sort() }
482
- }
483
-
484
- /** Sources one mission's MissionTouch for the colliding node from a `base..head` range: REUSES the
485
- * sibling touch-set-correction's `collectChangedFiles` (git diff + resolve-governances + gherkin-cli
486
- * diff) for each file's artifact-type + changed scenarios, keeps only the files that map to `node`
487
- * (via the shared `fileToNode`), and layers the region hunks + (for code) produced/consumed symbols
488
- * on top. Thin IO — not unit-tested. */
489
- export function collectMissionTouch(
490
- mission: string,
491
- node: string,
492
- base: string,
493
- head: string,
494
- root: string,
495
- cwd: string,
496
- layouts: ProjectLayout[],
497
- ): MissionTouch {
498
- const changed = collectChangedFiles(base, head, root, cwd)
499
- const files: FileTouch[] = changed
500
- .filter((f) => fileToNode(f.path, layouts) === node)
501
- .map((f) => ({
502
- path: f.path,
503
- artifactType: f.artifactType,
504
- hunks: readFileHunks(base, head, f.path, cwd),
505
- changedScenarios: f.changedScenarios,
506
- symbols: isCode(f.path) ? extractSymbols(base, head, f.path, cwd) : null,
507
- }))
508
- return { mission, files }
509
- }
510
-
511
- // ── Render (TOON — the token-efficient tabular form the repo's other sdd engines emit) ──
512
-
513
- function toonQuote(v: string): string {
514
- if (v === '' || /[",;]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
515
- return v
516
- }
517
-
518
- export function renderVerdictToon(v: LadderVerdict): string {
519
- const lines: string[] = []
520
- lines.push(`node: ${toonQuote(v.node)}`)
521
- lines.push(`collision: ${v.collision}`)
522
- lines.push(`rung: ${v.rung}`)
523
- lines.push(`confidence: ${v.confidence}`)
524
- lines.push(`sharedFiles[${v.sharedFiles.length}]{path,collision,rung,reason,sharedThin}:`)
525
- for (const f of v.sharedFiles) {
526
- lines.push(` ${toonQuote(f.path)},${f.collision},${f.rung},${f.reason},${f.sharedThin}`)
527
- }
528
- lines.push(`smells[${v.smells.length}]: ${v.smells.map(toonQuote).join(';')}`)
529
- lines.push(`deferrals[${v.deferrals.length}]: ${v.deferrals.map(toonQuote).join(';')}`)
530
- return lines.join('\n')
531
- }
532
-
533
- // ── CLI ──
534
-
535
- function flag(argv: string[], name: string): string | undefined {
536
- const i = argv.indexOf(name)
537
- return i === -1 ? undefined : argv[i + 1]
538
- }
539
-
540
- function allFlags(argv: string[], name: string): string[] {
541
- const out: string[] = []
542
- for (let i = 0; i < argv.length; i++) {
543
- if (argv[i] === name && argv[i + 1] !== undefined) out.push(argv[i + 1])
544
- }
545
- return out
546
- }
547
-
548
- function splitCsv(v: string | undefined): string[] {
549
- if (v === undefined || v === '') return []
550
- return v
551
- .split(',')
552
- .map((s) => s.trim())
553
- .filter((s) => s.length > 0)
554
- }
555
-
556
- /** Parses one `--layout '<project>:<root1>,<root2>'` flag value into a ProjectLayout (same grammar as
557
- * the sibling touch-set-correction CLI). */
558
- export function parseLayoutFlag(value: string): ProjectLayout | null {
559
- const idx = value.indexOf(':')
560
- if (idx === -1) return null
561
- const project = value.slice(0, idx).trim()
562
- const roots = splitCsv(value.slice(idx + 1))
563
- if (project === '' || roots.length === 0) return null
564
- return { project, roots }
565
- }
566
-
567
- function readStdin(): string {
568
- try {
569
- return execFileSync('cat', [], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'ignore'] })
570
- } catch {
571
- return ''
572
- }
573
- }
574
-
575
- const HERE = fileURLToPath(import.meta.url)
576
- // A bundle (built for npm, where Node will not strip types) runs its sibling engines as bundles too.
577
- const ENGINE_EXT = HERE.endsWith('.mts') ? '.mts' : '.bundle.mjs'
578
- const DISCOVER_SPECS_PATH = join(dirname(HERE), '..', '..', 'discover-specs', 'scripts', `discover-specs${ENGINE_EXT}`)
579
-
580
- interface DiscoveredSpec {
581
- path: string
582
- name: string
583
- projectPath: string
584
- }
585
-
586
- /** Best-effort project-layout discovery via `discover-specs` (same convention as the sibling). Used
587
- * only by --from-git; --layout overrides it. On any failure returns []. */
588
- function discoverLayouts(root: string, cwd: string): ProjectLayout[] {
589
- try {
590
- const out = execFileSync('node', [DISCOVER_SPECS_PATH, '--root', root, '--format', 'json'], {
591
- encoding: 'utf8',
592
- cwd,
593
- stdio: ['ignore', 'pipe', 'ignore'],
594
- })
595
- const specs = JSON.parse(out) as DiscoveredSpec[]
596
- return specs.map((s) => {
597
- const roots = [s.path]
598
- const projectPath = s.projectPath ?? ''
599
- const pluginsMatch = /^plugins\/([^/]+)$/.exec(projectPath)
600
- const packagesMatch = /^packages\/([^/]+)$/.exec(projectPath)
601
- if (pluginsMatch) roots.push(`plugins/${pluginsMatch[1]}/skills`)
602
- else if (packagesMatch) roots.push(`packages/${packagesMatch[1]}/src`)
603
- else if (projectPath) roots.push(projectPath)
604
- return { project: s.name, roots }
605
- })
606
- } catch {
607
- return []
608
- }
609
- }
610
-
611
- export function main(argv: string[]): number {
612
- const format = flag(argv, '--format') === 'json' ? 'json' : 'toon'
613
- let input: ClassifyInput
614
-
615
- const fromGit = argv.includes('--from-git')
616
- if (fromGit) {
617
- const node = flag(argv, '--node')
618
- const xRange = flag(argv, '--x')
619
- const yRange = flag(argv, '--y')
620
- if (node === undefined || xRange === undefined || yRange === undefined) {
621
- process.stderr.write('collision-ladder: --from-git needs --node <id> --x <base..head> --y <base..head>\n')
622
- return 1
623
- }
624
- const root = flag(argv, '--root') ?? '.'
625
- const [xb, xh] = xRange.split('..')
626
- const [yb, yh] = yRange.split('..')
627
- const layoutFlags = allFlags(argv, '--layout')
628
- const layouts =
629
- layoutFlags.length > 0
630
- ? layoutFlags.map(parseLayoutFlag).filter((l): l is ProjectLayout => l !== null)
631
- : discoverLayouts(root, root)
632
- const x = collectMissionTouch(xRange, node, xb, xh ?? 'HEAD', root, root, layouts)
633
- const y = collectMissionTouch(yRange, node, yb, yh ?? 'HEAD', root, root, layouts)
634
- const threshold = flag(argv, '--shared-thin-threshold')
635
- input = { node, x, y, sharedThinThreshold: threshold ? Number(threshold) : undefined }
636
- } else {
637
- const inputSrc = flag(argv, '--input')
638
- if (inputSrc === undefined) {
639
- process.stderr.write('collision-ladder: --input <file|-> (a ClassifyInput JSON) or --from-git is required\n')
640
- return 1
641
- }
642
- let raw: string
643
- try {
644
- raw =
645
- inputSrc === '-'
646
- ? readStdin()
647
- : execFileSync('cat', [inputSrc], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
648
- input = JSON.parse(raw) as ClassifyInput
649
- } catch {
650
- process.stderr.write(`collision-ladder: could not read/parse ClassifyInput from ${inputSrc}\n`)
651
- return 1
652
- }
653
- }
654
-
655
- const verdict = classify(input)
656
- process.stdout.write(`${format === 'json' ? JSON.stringify(verdict, null, 2) : renderVerdictToon(verdict)}\n`)
657
- return 0
658
- }
659
-
660
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
661
- process.exit(main(process.argv.slice(2)))
662
- }