cyber-sdd 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/agents/sdd-scanner.md +1 -1
  4. package/package.json +11 -13
  5. package/plugin.json +55 -0
  6. package/skills/align-spec/SKILL.md +1 -1
  7. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  8. package/skills/blast-estimate/README.md +3 -3
  9. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  10. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  11. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  12. package/skills/check-plan-safety/SKILL.md +2 -3
  13. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  14. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  15. package/skills/check-project-specs/SKILL.md +1 -1
  16. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  17. package/skills/check-retired-terms/SKILL.md +2 -3
  18. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  19. package/skills/check-scenario-overlap/SKILL.md +1 -2
  20. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  21. package/skills/check-spec-references/SKILL.md +2 -2
  22. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  23. package/skills/check-spec-structure/SKILL.md +1 -2
  24. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  25. package/skills/collision-ladder/README.md +2 -2
  26. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  27. package/skills/combat-log-governance/SKILL.md +1 -1
  28. package/skills/concept-index/SKILL.md +1 -1
  29. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  30. package/skills/discover-plans/README.md +3 -3
  31. package/skills/discover-plans/SKILL.md +1 -1
  32. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  33. package/skills/discover-specs/README.md +3 -3
  34. package/skills/discover-specs/SKILL.md +1 -1
  35. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  36. package/skills/doctrine-loop/SKILL.md +1 -1
  37. package/skills/gate-validation-governance/SKILL.md +1 -1
  38. package/skills/init/README.md +2 -2
  39. package/skills/init/scripts/wire-statusline.mjs +168 -12
  40. package/skills/manage-ignore/README.md +3 -3
  41. package/skills/manage-ignore/SKILL.md +1 -2
  42. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  43. package/skills/manage-scenario-bridge/README.md +3 -3
  44. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  45. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  46. package/skills/manage-spec-anchors/README.md +3 -3
  47. package/skills/manage-spec-anchors/SKILL.md +1 -1
  48. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  49. package/skills/mission-graph/README.md +2 -2
  50. package/skills/mission-graph/SKILL.md +1 -1
  51. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  52. package/skills/pause-mission/SKILL.md +1 -1
  53. package/skills/place-node/SKILL.md +2 -2
  54. package/skills/place-node/scripts/place-node.mjs +117 -12
  55. package/skills/plan-retirement/README.md +2 -2
  56. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  57. package/skills/resolve-governances/README.md +3 -3
  58. package/skills/resolve-governances/SKILL.md +1 -1
  59. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  60. package/skills/resolve-tracking/SKILL.md +1 -1
  61. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  62. package/skills/scaffold-project-spec/SKILL.md +1 -1
  63. package/skills/spec-gate/README.md +12 -9
  64. package/skills/spec-gate/SKILL.md +2 -2
  65. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  66. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  67. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  68. package/skills/spec-producer-governance/SKILL.md +2 -2
  69. package/skills/start-mission/SKILL.md +1 -1
  70. package/skills/suite-format-governance/README.md +1 -1
  71. package/skills/suite-format-governance/SKILL.md +1 -1
  72. package/skills/touch-set-correction/README.md +5 -5
  73. package/skills/touch-set-correction/SKILL.md +1 -1
  74. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  75. package/skills/verify-scenarios/README.md +6 -6
  76. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  77. package/.plugin/pins.json +0 -3
  78. package/.plugin/plugin.json +0 -19
  79. package/dist/align-spec/scripts/align-spec.js +0 -102
  80. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  81. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  82. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  83. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  84. package/dist/concept-index/scripts/concept-index.js +0 -181
  85. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  86. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  87. package/dist/spec-gate/scripts/check-suite.js +0 -317
  88. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  89. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  90. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  91. package/skills/align-spec/scripts/align-spec.mts +0 -188
  92. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  93. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  94. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  95. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  96. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  97. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  98. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  99. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  100. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  102. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  103. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  104. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  105. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  106. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  108. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  109. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  110. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  111. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  112. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  113. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  114. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  115. package/skills/concept-index/scripts/concept-index.mts +0 -246
  116. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  117. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  118. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  119. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  120. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  121. package/skills/init/scripts/wire-statusline.mts +0 -279
  122. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  123. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  124. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  126. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  128. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  129. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  130. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  131. package/skills/place-node/scripts/place-node.mts +0 -158
  132. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  133. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  134. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  135. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  136. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  137. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  138. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  139. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  140. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  141. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  142. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  143. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  144. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  145. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  146. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  147. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  148. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  149. package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
@@ -1,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'