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,414 +0,0 @@
1
- #!/usr/bin/env node
2
- // classify-edit-class — the structural edit-class classifier for a touched frozen `.feature`
3
- // (the "Structural edit-class classification (freeze integrity)" section of the SDD project spec's
4
- // `authoring/spec-gate` node, repo-only). Classifies each touched file's change against its
5
- // committed baseline so the gate can route: additive / no-content-change self-clear; narrowing /
6
- // mixed take the existing narrowing -> Clearance path. This engine only CLASSIFIES — it fires no
7
- // verdict and adds no new floor.
8
- //
9
- // The classification is STRUCTURAL, never a raw git line-diff. A raw line-diff is fooled by a
10
- // trailing step orphaned off a frozen scenario onto a newly added adjacent scenario: the orphan
11
- // shows no `-` line and reads as purely additive, so a narrowing self-clears silently and
12
- // Clearance never fires. The pinned `gherkin-cli` `diffFeatures(paths, {base})` is AST-level
13
- // and is not fooled — it reports the losing baseline scenario as `modified` (`addOnly: false`).
14
- //
15
- // The pin is load-bearing, not incidental. Through `0.0.1` the differ's scenario identity covered
16
- // step keyword + text only, so a step's DocString / DataTable could be rewritten while the scenario
17
- // still reported `unchanged` — and a `@rubric` lives wholly inside a DocString, which put every
18
- // graded scenario outside the freeze. `0.0.2` hashes what an argument SAYS (content, media type) and
19
- // still excludes how it is WRITTEN (delimiter, table padding, source locations), so a reformat does
20
- // not fire Clearance. Both directions are bound by the frozen scenarios in this engine's suite;
21
- // moving the pin backwards fails three of them.
22
- //
23
- // Classifications:
24
- // unfrozen-skip — the file carries no feature-level @frozen tag in the baseline OR the
25
- // working version; the edit-class routing does not apply to it.
26
- // additive — addOnly true and at least one whole scenario was added; no baseline
27
- // scenario was modified or removed.
28
- // no-content-change — a pure rename (git rename detection, 100% content match) or a diff with
29
- // zero added/modified/removed scenarios.
30
- // narrowing — at least one baseline scenario is modified or removed (no scenario added).
31
- // mixed — both a whole-scenario addition AND a modified/removed baseline scenario.
32
- //
33
- // Pure functions are exported for node:test; running the file directly drives the CLI.
34
-
35
- import { execFileSync } from 'node:child_process'
36
- import { readFileSync, realpathSync } from 'node:fs'
37
- import { dirname, join, relative, resolve, sep } from 'node:path'
38
- import { pathToFileURL } from 'node:url'
39
- import { GitError, diff as gherkinDiff, type ReadsGitDiff } from '../vendor/gherkin-cli.mjs'
40
-
41
- // ─── types ────────────────────────────────────────────────────────────────────
42
-
43
- export type EditClassification =
44
- | 'unfrozen-skip'
45
- | 'additive'
46
- | 'no-content-change'
47
- | 'narrowing'
48
- | 'mixed'
49
- | 'unclassifiable'
50
-
51
- export type ScenarioChangeKind = 'added' | 'modified' | 'removed' | 'unchanged'
52
-
53
- export interface ScenarioChange {
54
- name: string
55
- change: ScenarioChangeKind
56
- }
57
-
58
- export interface GherkinDiffFileResult {
59
- file: string
60
- addOnly: boolean
61
- scenarios: ScenarioChange[]
62
- // Present when the differ could not parse one side of the comparison for this file. Its
63
- // presence must be checked BEFORE reading `scenarios` — an unparseable file yields an EMPTY
64
- // `scenarios` array, and empty reads as "nothing changed" (see classifyFromDiff) whether the
65
- // file genuinely did not change or the differ simply could not see it. That emptiness is
66
- // structurally guaranteed here, not measured, so it is not evidence of anything.
67
- error?: { code: string; message: string }
68
- }
69
-
70
- export interface GherkinDiffOutput {
71
- summary: { addOnly: boolean }
72
- files: GherkinDiffFileResult[]
73
- }
74
-
75
- export interface ClassificationResult {
76
- file: string
77
- classification: EditClassification
78
- scenarios: ScenarioChange[]
79
- // Set only for `unclassifiable` — why the differ's result could not be trusted.
80
- reason?: string
81
- }
82
-
83
- // ─── feature-level @frozen tag ─────────────────────────────────────────────────
84
-
85
- // A feature-level tag sits on the contiguous block of `@tag` lines immediately above the
86
- // `Feature:` line (blank lines between tags and Feature: are tolerated). Only that block is
87
- // consulted — a scenario-level tag further down the file is not a feature-level freeze marker.
88
- export function hasFeatureFrozenTag(text: string): boolean {
89
- const lines = text.split('\n')
90
- const featureIdx = lines.findIndex((l) => /^\s*Feature:/i.test(l))
91
- if (featureIdx === -1) return false
92
- const tags: string[] = []
93
- for (let i = featureIdx - 1; i >= 0; i--) {
94
- const line = lines[i].trim()
95
- if (line === '') continue
96
- if (line.startsWith('@')) {
97
- tags.push(...line.split(/\s+/).filter(Boolean))
98
- continue
99
- }
100
- break
101
- }
102
- return tags.includes('@frozen')
103
- }
104
-
105
- // ─── structural classification from a gherkin-cli diff result ─────────────────
106
-
107
- // Classifies purely from the per-scenario `change` array a gherkin-cli diff reports — never from
108
- // a raw line count. `additive` requires at least one whole-scenario addition and zero
109
- // modified/removed baseline scenarios; `narrowing` is any modified/removed baseline scenario with
110
- // no addition; both together is `mixed`; neither is `no-content-change` (a diff against the
111
- // baseline with nothing structurally different).
112
- export function classifyFromDiff(fileResult: { addOnly: boolean; scenarios: ScenarioChange[] }): EditClassification {
113
- const added = fileResult.scenarios.some((s) => s.change === 'added')
114
- const narrowed = fileResult.scenarios.some((s) => s.change === 'modified' || s.change === 'removed')
115
- if (added && narrowed) return 'mixed'
116
- if (narrowed) return 'narrowing'
117
- if (added) return 'additive'
118
- return 'no-content-change'
119
- }
120
-
121
- // ─── the escalation boundary — an input the classifier cannot classify ────────
122
-
123
- // A file the differ reports a parse error for, or does not report at all, is not "no change" —
124
- // it is an input the classifier has no evidence about, and absence of evidence never reads as
125
- // evidence of no change. `error` is checked FIRST, before `scenarios` is read at all: an
126
- // unparseable file gives the differ nothing to compare, so it reports an empty `scenarios` array
127
- // (and a top-level `summary.addOnly: true`), which classifyFromDiff would otherwise read as
128
- // `no-content-change`. That emptiness is structurally guaranteed by the parse failure rather than
129
- // measured against the baseline — which is how a rewritten suite came to self-clear.
130
- export function classifyFromFileResult(fileResult: GherkinDiffFileResult | undefined): {
131
- classification: EditClassification
132
- reason?: string
133
- } {
134
- if (fileResult === undefined) {
135
- return { classification: 'unclassifiable', reason: 'the structural differ returned no result for this file' }
136
- }
137
- if (fileResult.error) {
138
- return {
139
- classification: 'unclassifiable',
140
- reason: `cannot parse (${fileResult.error.code}): ${fileResult.error.message}`,
141
- }
142
- }
143
- return { classification: classifyFromDiff(fileResult) }
144
- }
145
-
146
- // ─── pure rename detection (git, not gherkin-cli) ──────────────────────────────
147
- // gherkin-cli diff reads the base ref's content AT THE SAME PATH — a renamed file has no content
148
- // at its new path in the base ref, so gherkin-cli alone reports every scenario as freshly
149
- // "added" (addOnly: true) for a pure rename, indistinguishable from a genuinely new file. Git's
150
- // own rename detection (`-M`) pairs the old and new paths across the whole tree and scores their
151
- // similarity; a 100% score is a zero-content-delta pure rename.
152
-
153
- export interface RenameStatus {
154
- score: number
155
- oldPath: string
156
- newPath: string
157
- }
158
-
159
- export function parseRenameStatus(nameStatusOutput: string): RenameStatus[] {
160
- const out: RenameStatus[] = []
161
- for (const line of nameStatusOutput.split('\n')) {
162
- const m = /^R(\d+)\t([^\t]+)\t([^\t]+)$/.exec(line)
163
- if (m) out.push({ score: Number(m[1]), oldPath: m[2], newPath: m[3] })
164
- }
165
- return out
166
- }
167
-
168
- function detectPureRename(base: string, path: string, cwd: string): boolean {
169
- try {
170
- const out = execFileSync('git', ['diff', '--name-status', '-M', base], {
171
- encoding: 'utf8',
172
- cwd,
173
- stdio: ['ignore', 'pipe', 'ignore'],
174
- })
175
- return parseRenameStatus(out).some((r) => r.newPath === path && r.score === 100)
176
- } catch {
177
- return false
178
- }
179
- }
180
-
181
- // Reads a path's content at a given git ref (the committed baseline). Any failure (new file at
182
- // base, path not tracked at base, git absent) returns '' — the safe default that reads as
183
- // "no baseline", matching check-spec-state.mts's readBaselineFromGit convention.
184
- function readGitShow(base: string, path: string, cwd: string): string {
185
- try {
186
- return execFileSync('git', ['show', `${base}:${path}`], {
187
- encoding: 'utf8',
188
- cwd,
189
- stdio: ['ignore', 'pipe', 'ignore'],
190
- })
191
- } catch {
192
- return ''
193
- }
194
- }
195
-
196
- // Thin boundary around the pinned `gherkin-cli` `diff` — never a re-implemented differ.
197
- // classifyFromDiff / classifyFromFileResult carry the tested logic; this only wires the engine in.
198
- // Takes a batch of paths (`diff` is already variadic over paths) so a multi-file caller
199
- // pays for one parse pass, not one per file.
200
- export type GherkinDiffRunner = (base: string, paths: string[], cwd: string) => GherkinDiffOutput
201
-
202
- // `diff`'s default reader resolves each path via `path.resolve(file)` against
203
- // `process.cwd()` and derives git's own cwd from THAT resolved location (`dirname` of the
204
- // resolved path, then `git ls-files --full-name` to recover the repo-relative path) — it never
205
- // trusts a caller-supplied cwd for the git commands at all. That self-derivation is why the
206
- // original CLI boundary tolerated a caller's relative-path bookkeeping being off: whatever `file`
207
- // resolves to, the reader walks UP from there to find the right repo and the right relative path.
208
- // This reader is the library's own extension seam ("Injectable so tests can skip git"), replicated
209
- // verbatim with the one substitution that matters here — `resolve(cwd, file)` instead of
210
- // `resolve(file)` — so a caller's `cwd` participates without discarding that self-correction.
211
- function makeCwdReader(cwd: string): ReadsGitDiff['readDiff'] {
212
- return (file, base) => {
213
- const abs = resolve(cwd, file)
214
- const dir = dirname(abs)
215
- let head: string | undefined
216
- try {
217
- head = readFileSync(abs, 'utf8')
218
- } catch {
219
- head = undefined
220
- }
221
- const gitIo = {
222
- cwd: dir,
223
- encoding: 'utf8' as const,
224
- stdio: ['ignore', 'pipe', 'ignore'] as ['ignore', 'pipe', 'ignore'],
225
- }
226
- let rel: string
227
- try {
228
- rel = execFileSync('git', ['ls-files', '--full-name', '--', abs], gitIo).trim()
229
- } catch (err) {
230
- throw new GitError(`git ls-files failed for ${file}: ${(err as Error).message}`)
231
- }
232
- if (rel === '') {
233
- const top = execFileSync('git', ['rev-parse', '--show-toplevel'], gitIo).trim()
234
- rel = relative(top, abs).split(sep).join('/')
235
- }
236
- let baseText: string | undefined
237
- try {
238
- baseText = execFileSync('git', ['show', `${base}:${rel}`], gitIo)
239
- } catch {
240
- try {
241
- execFileSync('git', ['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd: dir, stdio: 'ignore' })
242
- baseText = undefined
243
- } catch {
244
- throw new GitError(`git could not resolve base ref '${base}'`)
245
- }
246
- }
247
- return { head, base: baseText }
248
- }
249
- }
250
-
251
- export const runGherkinDiff: GherkinDiffRunner = (base, paths, cwd) =>
252
- gherkinDiff(paths, { base, full: true }, { readDiff: makeCwdReader(cwd) })
253
-
254
- // ─── per-file classification ────────────────────────────────────────────────────
255
-
256
- // The frozen-tag and rename checks a path resolves before ever needing a diff. `early` is set when
257
- // those checks alone decide the classification (no gherkin-cli call needed); its absence means the
258
- // caller still owes this path a diff lookup.
259
- interface PreCheckedFile {
260
- path: string
261
- early?: ClassificationResult
262
- }
263
-
264
- function preCheckFile(path: string, base: string, cwd: string): PreCheckedFile {
265
- let currentText: string
266
- try {
267
- currentText = readFileSync(join(cwd, path), 'utf8')
268
- } catch {
269
- currentText = ''
270
- }
271
- const baselineText = readGitShow(base, path, cwd)
272
-
273
- // Skip files carrying no feature-level @frozen tag in EITHER the baseline or the working
274
- // version — the edit-class routing this engine feeds only applies to frozen files.
275
- if (!hasFeatureFrozenTag(currentText) && !hasFeatureFrozenTag(baselineText)) {
276
- return { path, early: { file: path, classification: 'unfrozen-skip', scenarios: [] } }
277
- }
278
-
279
- // A pure rename is not a gate-able edit regardless of what gherkin-cli would (mis)report at
280
- // the new path — check git's rename detection before ever calling gherkin-cli.
281
- if (detectPureRename(base, path, cwd)) {
282
- return { path, early: { file: path, classification: 'no-content-change', scenarios: [] } }
283
- }
284
-
285
- return { path }
286
- }
287
-
288
- // The CLI echoes each path as given, so an exact match is the expected case. A diff that reports
289
- // under a different path (rather than omitting it) still resolves for a single-file batch — but
290
- // with multiple files reported and no exact match, which one is "this file" is not knowable, so it
291
- // falls through to `undefined` and classifyFromFileResult's no-result escalation.
292
- function classifyFromDiffOutput(path: string, diff: GherkinDiffOutput): ClassificationResult {
293
- const fileResult = diff.files.find((f) => f.file === path) ?? (diff.files.length === 1 ? diff.files[0] : undefined)
294
- const { classification, reason } = classifyFromFileResult(fileResult)
295
- return { file: path, classification, scenarios: fileResult?.scenarios ?? [], reason }
296
- }
297
-
298
- // `diff` is injected so the tests can drive the differ's failure branch — a binary that exits
299
- // without a readable result is a real, reachable path (a bad base ref makes the CLI exit nonzero
300
- // with no `files` array), and it must escalate rather than resolve to a reassuring class.
301
- export function classifyFile(
302
- path: string,
303
- base: string,
304
- cwd = '.',
305
- diffWith: GherkinDiffRunner = runGherkinDiff,
306
- ): ClassificationResult {
307
- const pre = preCheckFile(path, base, cwd)
308
- if (pre.early) return pre.early
309
-
310
- let diff: GherkinDiffOutput
311
- try {
312
- diff = diffWith(base, [path], cwd)
313
- } catch {
314
- return {
315
- file: path,
316
- classification: 'unclassifiable',
317
- scenarios: [],
318
- reason: 'the structural differ produced no readable result',
319
- }
320
- }
321
- return classifyFromDiffOutput(path, diff)
322
- }
323
-
324
- // The classification is scoped to the CR's touched .feature files only — a caller passes the
325
- // explicit touched-path list, never a tree-wide sweep. Pre-checks (frozen tag, rename) run per file
326
- // with no subprocess cost; only the paths still needing a structural diff after that are batched
327
- // into a SINGLE `gherkin-cli diff` call, so an N-file touch set pays for one parse pass, not N.
328
- export function classifyFiles(
329
- paths: string[],
330
- base: string,
331
- cwd = '.',
332
- diffWith: GherkinDiffRunner = runGherkinDiff,
333
- ): ClassificationResult[] {
334
- const preChecked = paths.filter((p) => p.endsWith('.feature')).map((path) => preCheckFile(path, base, cwd))
335
- const needsDiff = preChecked.filter((r) => r.early === undefined).map((r) => r.path)
336
-
337
- let diff: GherkinDiffOutput | undefined
338
- if (needsDiff.length > 0) {
339
- try {
340
- diff = diffWith(base, needsDiff, cwd)
341
- } catch {
342
- diff = undefined
343
- }
344
- }
345
-
346
- return preChecked.map(({ path, early }) => {
347
- if (early) return early
348
- if (diff === undefined) {
349
- return {
350
- file: path,
351
- classification: 'unclassifiable',
352
- scenarios: [],
353
- reason: 'the structural differ produced no readable result',
354
- }
355
- }
356
- return classifyFromDiffOutput(path, diff)
357
- })
358
- }
359
-
360
- // ─── output ──────────────────────────────────────────────────────────────────
361
-
362
- export function formatText(results: ClassificationResult[]): string {
363
- const lines: string[] = []
364
- for (const r of results) {
365
- lines.push(`${r.classification.toUpperCase().padEnd(18)} ${r.file}`)
366
- if (r.reason) lines.push(` ${r.reason}`)
367
- for (const s of r.scenarios) {
368
- if (s.change !== 'unchanged') lines.push(` ${s.change.padEnd(10)} ${s.name}`)
369
- }
370
- }
371
- return lines.join('\n')
372
- }
373
-
374
- // ─── CLI entry ────────────────────────────────────────────────────────────────
375
-
376
- function parseFilesArg(argv: string[]): string[] {
377
- const idx = argv.indexOf('--files')
378
- if (idx === -1) return []
379
- const paths: string[] = []
380
- for (let i = idx + 1; i < argv.length; i++) {
381
- if (argv[i].startsWith('--')) break
382
- paths.push(argv[i])
383
- }
384
- return paths
385
- }
386
-
387
- export function main(argv: string[]): number {
388
- const paths = parseFilesArg(argv)
389
- if (paths.length === 0) {
390
- console.error('✗ --files requires at least one .feature path')
391
- return 1
392
- }
393
- const base = argv.includes('--base') ? argv[argv.indexOf('--base') + 1] : 'HEAD'
394
- const format = argv.includes('--format') ? argv[argv.indexOf('--format') + 1] : 'text'
395
-
396
- const results = classifyFiles(paths, base)
397
-
398
- if (format === 'json') {
399
- process.stdout.write(`${JSON.stringify(results, null, 2)}\n`)
400
- } else {
401
- process.stdout.write(`${formatText(results)}\n`)
402
- }
403
-
404
- const unclassifiable = results.filter((r) => r.classification === 'unclassifiable')
405
- if (unclassifiable.length) {
406
- for (const r of unclassifiable) console.error(`✗ ${r.file}: unclassifiable — ${r.reason}`)
407
- return 1
408
- }
409
- return 0
410
- }
411
-
412
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
413
- process.exit(main(process.argv.slice(2)))
414
- }
@@ -1,2 +0,0 @@
1
- // Types for the bundled gherkin-cli beside this file: the same module, so the same types.
2
- export * from 'gherkin-cli'