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.
- package/.claude-plugin/plugin.json +1 -2
- package/.codex-plugin/plugin.json +1 -2
- package/CHANGELOG.md +320 -0
- package/agents/sdd-scanner.md +1 -1
- package/package.json +11 -12
- package/plugin.json +55 -0
- package/skills/align-spec/SKILL.md +1 -1
- package/skills/align-spec/scripts/align-spec.mjs +6755 -12
- package/skills/blast-estimate/README.md +3 -3
- package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
- package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
- package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
- package/skills/check-plan-safety/SKILL.md +2 -3
- package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
- package/skills/check-project-specs/SKILL.md +1 -1
- package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
- package/skills/check-retired-terms/SKILL.md +2 -3
- package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
- package/skills/check-scenario-overlap/SKILL.md +1 -2
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
- package/skills/check-spec-references/SKILL.md +2 -2
- package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
- package/skills/check-spec-structure/SKILL.md +1 -2
- package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
- package/skills/collision-ladder/README.md +2 -2
- package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
- package/skills/combat-log-governance/SKILL.md +1 -1
- package/skills/concept-index/SKILL.md +1 -1
- package/skills/concept-index/scripts/concept-index.mjs +183 -12
- package/skills/discover-plans/README.md +3 -3
- package/skills/discover-plans/SKILL.md +1 -1
- package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
- package/skills/discover-specs/README.md +3 -3
- package/skills/discover-specs/SKILL.md +1 -1
- package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
- package/skills/doctrine-loop/SKILL.md +1 -1
- package/skills/gate-validation-governance/SKILL.md +1 -1
- package/skills/init/README.md +2 -2
- package/skills/init/scripts/wire-statusline.mjs +168 -12
- package/skills/manage-ignore/README.md +3 -3
- package/skills/manage-ignore/SKILL.md +1 -2
- package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
- package/skills/manage-scenario-bridge/README.md +3 -3
- package/skills/manage-scenario-bridge/SKILL.md +1 -1
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
- package/skills/manage-spec-anchors/README.md +3 -3
- package/skills/manage-spec-anchors/SKILL.md +1 -1
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
- package/skills/mission-graph/README.md +2 -2
- package/skills/mission-graph/SKILL.md +1 -1
- package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
- package/skills/pause-mission/SKILL.md +1 -1
- package/skills/place-node/SKILL.md +2 -2
- package/skills/place-node/scripts/place-node.mjs +117 -12
- package/skills/plan-retirement/README.md +2 -2
- package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
- package/skills/resolve-governances/README.md +3 -3
- package/skills/resolve-governances/SKILL.md +1 -1
- package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
- package/skills/resolve-tracking/SKILL.md +1 -1
- package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
- package/skills/scaffold-project-spec/SKILL.md +1 -1
- package/skills/spec-gate/README.md +12 -9
- package/skills/spec-gate/SKILL.md +2 -2
- package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
- package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
- package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
- package/skills/spec-producer-governance/SKILL.md +2 -2
- package/skills/start-mission/SKILL.md +1 -1
- package/skills/suite-format-governance/README.md +1 -1
- package/skills/suite-format-governance/SKILL.md +1 -1
- package/skills/touch-set-correction/README.md +5 -5
- package/skills/touch-set-correction/SKILL.md +1 -1
- package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
- package/skills/verify-scenarios/README.md +6 -6
- package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
- package/.plugin/pins.json +0 -3
- package/.plugin/plugin.json +0 -19
- package/dist/align-spec/scripts/align-spec.js +0 -102
- package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
- package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
- package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
- package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
- package/dist/concept-index/scripts/concept-index.js +0 -181
- package/dist/discover-specs/scripts/discover-specs.js +0 -318
- package/dist/spec-gate/scripts/check-spec-state.js +0 -420
- package/dist/spec-gate/scripts/check-suite.js +0 -317
- package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
- package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
- package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
- package/skills/align-spec/scripts/align-spec.mts +0 -188
- package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
- package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
- package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
- package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
- package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
- package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
- package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
- package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
- package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
- package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
- package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
- package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
- package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
- package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
- package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
- package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
- package/skills/concept-index/scripts/concept-index.mts +0 -246
- package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
- package/skills/discover-plans/scripts/discover-plans.mts +0 -215
- package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
- package/skills/discover-specs/scripts/discover-specs.mts +0 -420
- package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
- package/skills/init/scripts/wire-statusline.mts +0 -279
- package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
- package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
- package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
- package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
- package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
- package/skills/place-node/scripts/place-node.mts +0 -158
- package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
- package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
- package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
- package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
- package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
- package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
- package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
- package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
- package/skills/spec-gate/scripts/check-suite.mts +0 -538
- package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
- package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
- package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
- package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
- package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
- package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
- 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
|
-
}
|