cyber-sdd 0.4.0 → 0.4.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.plugin/plugin.json +1 -1
- package/agents/sdd-automaton.md +5 -1
- package/dist/align-spec/scripts/align-spec.js +102 -0
- package/dist/check-project-specs/scripts/check-project-specs.js +311 -0
- package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +175 -0
- package/dist/check-spec-references/scripts/check-spec-references.js +285 -0
- package/dist/check-spec-structure/scripts/check-spec-structure.js +241 -0
- package/dist/concept-index/scripts/concept-index.js +181 -0
- package/dist/discover-specs/scripts/discover-specs.js +318 -0
- package/dist/spec-gate/scripts/check-spec-state.js +420 -0
- package/dist/spec-gate/scripts/check-suite.js +317 -0
- package/dist/spec-gate/scripts/classify-edit-class.js +263 -0
- package/license +21 -0
- package/package.json +11 -3
- package/skills/check-project-specs/scripts/check-project-specs.mts +13 -2
- package/skills/concept-index/scripts/concept-index.mts +2 -2
- package/skills/spec-gate/scripts/check-suite.mts +2 -2
- package/skills/spec-gate/scripts/classify-edit-class.mts +7 -7
- package/skills/start-mission/SKILL.md +1 -0
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +7 -7
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +2 -2
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { validate } from "gherkin-cli";
|
|
6
|
+
//#region skills/spec-gate/scripts/check-suite.mts
|
|
7
|
+
const ADVERB_PATTERNS = [
|
|
8
|
+
"sometimes",
|
|
9
|
+
"usually",
|
|
10
|
+
"often",
|
|
11
|
+
"occasionally"
|
|
12
|
+
].map((w) => new RegExp(`\\b${w}\\b`, "i"));
|
|
13
|
+
const RUBRIC_PATTERNS = [...[
|
|
14
|
+
"score",
|
|
15
|
+
"threshold",
|
|
16
|
+
"rubric"
|
|
17
|
+
].map((w) => new RegExp(`\\b${w}\\b`, "i")), /\b1[-–]5\b/];
|
|
18
|
+
const ASSERTION_RE = /^(Then|And|But)\b/i;
|
|
19
|
+
const RUBRIC_EXEMPT_RE = /\b(no|not|never|without|nor|passing|failing|pass|fail|boolean|true|false|verdict)\b/i;
|
|
20
|
+
function parseSuite(text) {
|
|
21
|
+
const lines = text.split("\n");
|
|
22
|
+
let hasFeatureLine = false;
|
|
23
|
+
let sectionCommentCount = 0;
|
|
24
|
+
const scenarios = [];
|
|
25
|
+
let current = null;
|
|
26
|
+
let pendingTags = [];
|
|
27
|
+
let inDocString = false;
|
|
28
|
+
let docStringLines = [];
|
|
29
|
+
for (const raw of lines) {
|
|
30
|
+
const line = raw.trimStart();
|
|
31
|
+
if (inDocString) {
|
|
32
|
+
if (line.startsWith("\"\"\"")) {
|
|
33
|
+
inDocString = false;
|
|
34
|
+
if (current) current.docStrings.push(docStringLines.join("\n"));
|
|
35
|
+
docStringLines = [];
|
|
36
|
+
} else docStringLines.push(raw);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (current && line.startsWith("\"\"\"")) {
|
|
40
|
+
inDocString = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (/^Feature:/i.test(line)) {
|
|
44
|
+
hasFeatureLine = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (/^#/.test(line) && (/──/.test(line) || /--/.test(line))) {
|
|
48
|
+
sectionCommentCount++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (/^@/.test(line)) {
|
|
52
|
+
pendingTags.push(...line.split(/\s+/).filter((t) => t.startsWith("@")));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (/^Scenario:/i.test(line) || /^Scenario Outline:/i.test(line)) {
|
|
56
|
+
if (current) scenarios.push(current);
|
|
57
|
+
const isOutline = /^Scenario Outline:/i.test(line);
|
|
58
|
+
current = {
|
|
59
|
+
name: line.replace(/^Scenario(?: Outline)?:/i, "").trim(),
|
|
60
|
+
steps: [],
|
|
61
|
+
tags: pendingTags,
|
|
62
|
+
isOutline,
|
|
63
|
+
placeholders: [],
|
|
64
|
+
examples: null,
|
|
65
|
+
docStrings: []
|
|
66
|
+
};
|
|
67
|
+
pendingTags = [];
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (current && /^Examples:/i.test(line)) {
|
|
71
|
+
current.examples = {
|
|
72
|
+
header: [],
|
|
73
|
+
rows: []
|
|
74
|
+
};
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (current?.examples && line.startsWith("|")) {
|
|
78
|
+
const cells = line.split("|").slice(1, -1).map((c) => c.trim());
|
|
79
|
+
if (current.examples.header.length === 0) current.examples.header = cells;
|
|
80
|
+
else current.examples.rows.push(cells);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (current && /^(Given|When|Then|And|But)\b/i.test(line)) {
|
|
84
|
+
current.steps.push(line);
|
|
85
|
+
for (const m of line.matchAll(/<([^>]+)>/g)) current.placeholders.push(m[1]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (current) scenarios.push(current);
|
|
89
|
+
return {
|
|
90
|
+
hasFeatureLine,
|
|
91
|
+
scenarios,
|
|
92
|
+
sectionCommentCount
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function runGherkinValidate(paths) {
|
|
96
|
+
const { files } = validate(paths);
|
|
97
|
+
const out = /* @__PURE__ */ new Map();
|
|
98
|
+
for (const f of files) out.set(f.file, f.errors.map((e) => ({
|
|
99
|
+
line: e.line,
|
|
100
|
+
message: e.message
|
|
101
|
+
})));
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function findDeadRubric(docString) {
|
|
105
|
+
let dimensionsTotal = 0;
|
|
106
|
+
let sawMax = false;
|
|
107
|
+
let threshold = null;
|
|
108
|
+
for (const raw of docString.split("\n")) {
|
|
109
|
+
const line = raw.trim();
|
|
110
|
+
const maxMatch = /^max:\s*(-?\d+(?:\.\d+)?)\s*$/.exec(line);
|
|
111
|
+
if (maxMatch) {
|
|
112
|
+
sawMax = true;
|
|
113
|
+
dimensionsTotal += Number(maxMatch[1]);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const thresholdMatch = /^threshold:\s*(-?\d+(?:\.\d+)?)\s*$/.exec(line);
|
|
117
|
+
if (thresholdMatch) threshold = Number(thresholdMatch[1]);
|
|
118
|
+
}
|
|
119
|
+
if (!sawMax || threshold === null) return null;
|
|
120
|
+
if (dimensionsTotal >= threshold) return null;
|
|
121
|
+
return {
|
|
122
|
+
dimensionsTotal,
|
|
123
|
+
threshold
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function checkSuite(slug, file, text, parseErrors = []) {
|
|
127
|
+
const tag = (msg) => `${slug}/${file}: ${msg}`;
|
|
128
|
+
if (parseErrors.length > 0) return parseErrors.map((e) => tag(`cannot parse as Gherkin at line ${e.line} — ${e.message}`));
|
|
129
|
+
const v = [];
|
|
130
|
+
const ref = parseSuite(text);
|
|
131
|
+
if (!ref.hasFeatureLine) v.push(tag("missing Feature: line"));
|
|
132
|
+
for (const scenario of ref.scenarios) {
|
|
133
|
+
const steps = scenario.steps;
|
|
134
|
+
const label = scenario.name ? `Scenario "${scenario.name}"` : "unnamed scenario";
|
|
135
|
+
const isRubric = scenario.tags.includes("@rubric");
|
|
136
|
+
if (isRubric) for (const docString of scenario.docStrings) {
|
|
137
|
+
const dead = findDeadRubric(docString);
|
|
138
|
+
if (dead) v.push(tag(`${label}: rubric cannot be passed — dimensions total ${dead.dimensionsTotal}, threshold ${dead.threshold}`));
|
|
139
|
+
}
|
|
140
|
+
if (steps.length === 0) {
|
|
141
|
+
v.push(tag(`${label}: has no steps`));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!steps.some((s) => /^(Given|When)\b/i.test(s))) v.push(tag(`${label}: missing Given or When step`));
|
|
145
|
+
if (!steps.some((s) => /^Then\b/i.test(s))) v.push(tag(`${label}: missing Then step — scenario has no assertion`));
|
|
146
|
+
for (const step of steps) {
|
|
147
|
+
const adverb = ADVERB_PATTERNS.find((p) => p.test(step));
|
|
148
|
+
if (adverb) {
|
|
149
|
+
v.push(tag(`${label}: step contains non-boolean hedge — "${step.trim()}" (matched ${adverb.source})`));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (isRubric || !ASSERTION_RE.test(step) || RUBRIC_EXEMPT_RE.test(step)) continue;
|
|
153
|
+
const rubric = RUBRIC_PATTERNS.find((p) => p.test(step));
|
|
154
|
+
if (rubric) v.push(tag(`${label}: step embeds a rubric/score in its assertion — "${step.trim()}" (matched ${rubric.source})`));
|
|
155
|
+
}
|
|
156
|
+
if (scenario.isOutline) {
|
|
157
|
+
const ex = scenario.examples;
|
|
158
|
+
if (!ex || ex.header.length === 0 || ex.rows.length === 0) v.push(tag(`${label}: Scenario Outline has no non-empty Examples table`));
|
|
159
|
+
else {
|
|
160
|
+
const missing = [...new Set(scenario.placeholders)].filter((p) => !ex.header.includes(p));
|
|
161
|
+
if (missing.length) v.push(tag(`${label}: Examples table missing column(s) for placeholder(s): ${missing.join(", ")}`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (ref.scenarios.length > 6 && ref.sectionCommentCount === 0) v.push(tag(`${ref.scenarios.length} scenarios but no section comments (add # ── Stage comments to group lifecycle stages)`));
|
|
166
|
+
return v;
|
|
167
|
+
}
|
|
168
|
+
function discoverSuiteDirs(root) {
|
|
169
|
+
const out = [];
|
|
170
|
+
const walk = (dir, rel) => {
|
|
171
|
+
let entries;
|
|
172
|
+
try {
|
|
173
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
174
|
+
} catch {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const files = entries.filter((e) => e.isFile() && e.name.endsWith(".feature")).map((e) => e.name);
|
|
178
|
+
if (files.length) out.push({
|
|
179
|
+
slug: rel,
|
|
180
|
+
files
|
|
181
|
+
});
|
|
182
|
+
for (const e of entries) {
|
|
183
|
+
if (!e.isDirectory() || e.name === "node_modules" || e.name.startsWith(".")) continue;
|
|
184
|
+
walk(join(dir, e.name), rel ? join(rel, e.name) : e.name);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
walk(root, "");
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
function checkFilePaths(paths, validate = runGherkinValidate) {
|
|
191
|
+
const violations = [];
|
|
192
|
+
const readable = [];
|
|
193
|
+
for (const p of paths) try {
|
|
194
|
+
readable.push({
|
|
195
|
+
path: p,
|
|
196
|
+
text: readFileSync(p, "utf8")
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
violations.push(`${p}: cannot read file`);
|
|
200
|
+
}
|
|
201
|
+
if (readable.length === 0) return violations;
|
|
202
|
+
let parseErrorsByPath;
|
|
203
|
+
try {
|
|
204
|
+
parseErrorsByPath = validate(readable.map((r) => r.path));
|
|
205
|
+
} catch (err) {
|
|
206
|
+
for (const { path } of readable) violations.push(`${path}: cannot verify Gherkin validity — ${err.message}`);
|
|
207
|
+
return violations;
|
|
208
|
+
}
|
|
209
|
+
for (const { path: p, text } of readable) {
|
|
210
|
+
const errs = parseErrorsByPath.get(p);
|
|
211
|
+
if (errs === void 0) {
|
|
212
|
+
violations.push(`${p}: the Gherkin parser returned no result for this file`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
violations.push(...checkSuite(dirname(p), basename(p), text, errs));
|
|
216
|
+
if (errs.length === 0) {
|
|
217
|
+
const specPath = join(dirname(p), "README.md");
|
|
218
|
+
let specText;
|
|
219
|
+
try {
|
|
220
|
+
specText = readFileSync(specPath, "utf8");
|
|
221
|
+
} catch {
|
|
222
|
+
specText = void 0;
|
|
223
|
+
}
|
|
224
|
+
if (specText !== void 0) violations.push(...checkScenarioMap(dirname(p), basename(p), text, specText));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return violations;
|
|
228
|
+
}
|
|
229
|
+
function parseScenarioMap(specText) {
|
|
230
|
+
const heading = /^## Scenario map[ \t]*$/m.exec(specText);
|
|
231
|
+
if (heading === null) return void 0;
|
|
232
|
+
const after = specText.slice(heading.index + heading[0].length);
|
|
233
|
+
const nextHeading = after.search(/\n## /);
|
|
234
|
+
const body = nextHeading === -1 ? after : after.slice(0, nextHeading);
|
|
235
|
+
const rows = [];
|
|
236
|
+
const unparseable = [];
|
|
237
|
+
let blockRow = -1;
|
|
238
|
+
for (const line of body.split("\n")) {
|
|
239
|
+
const t = line.trim();
|
|
240
|
+
if (!t.startsWith("|")) {
|
|
241
|
+
blockRow = -1;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
blockRow++;
|
|
245
|
+
if (blockRow < 2) continue;
|
|
246
|
+
const cells = t.split("|").slice(1, -1).map((c) => c.trim());
|
|
247
|
+
const m = (cells.length === 3 ? cells[2] ?? "" : "").match(/^`(.+)`$/);
|
|
248
|
+
if (m === null) {
|
|
249
|
+
unparseable.push(t);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
rows.push({
|
|
253
|
+
edge: cells[0] ?? "",
|
|
254
|
+
path: cells[1] ?? "",
|
|
255
|
+
scenario: m[1] ?? ""
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
rows,
|
|
260
|
+
unparseable
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function checkScenarioMap(slug, file, featureText, specText) {
|
|
264
|
+
const map = parseScenarioMap(specText);
|
|
265
|
+
if (map === void 0) return [];
|
|
266
|
+
const tag = (msg) => `${slug}/${file}: ${msg}`;
|
|
267
|
+
const v = [];
|
|
268
|
+
for (const raw of map.unparseable) v.push(tag(`scenario-map data row has no backtick-wrapped Scenario cell — ${raw}`));
|
|
269
|
+
const titles = [...featureText.matchAll(/^\s*Scenario(?: Outline)?:\s*(.+?)\s*$/gm)].map((m) => m[1] ?? "");
|
|
270
|
+
const mapped = new Set(map.rows.map((r) => r.scenario));
|
|
271
|
+
for (const t of titles) if (!mapped.has(t)) v.push(tag(`scenario is not on the scenario map — "${t}"`));
|
|
272
|
+
const titleSet = new Set(titles);
|
|
273
|
+
for (const r of map.rows) if (!titleSet.has(r.scenario)) v.push(tag(`scenario map row names no such scenario — "${r.scenario}"`));
|
|
274
|
+
const seen = /* @__PURE__ */ new Map();
|
|
275
|
+
for (const r of map.rows) {
|
|
276
|
+
const key = `${r.edge}\u0000${r.path}`;
|
|
277
|
+
const prior = seen.get(key);
|
|
278
|
+
if (prior !== void 0) v.push(tag(`duplicate map pair — edge "${r.edge}" and path "${r.path}" cover both "${prior}" and "${r.scenario}"; a repeated edge needs a DIFFERENT path class`));
|
|
279
|
+
else seen.set(key, r.scenario);
|
|
280
|
+
}
|
|
281
|
+
return v;
|
|
282
|
+
}
|
|
283
|
+
function parseFilesArg(argv) {
|
|
284
|
+
const idx = argv.indexOf("--files");
|
|
285
|
+
if (idx === -1) return [];
|
|
286
|
+
const paths = [];
|
|
287
|
+
for (let i = idx + 1; i < argv.length; i++) {
|
|
288
|
+
if (argv[i].startsWith("--")) break;
|
|
289
|
+
paths.push(argv[i]);
|
|
290
|
+
}
|
|
291
|
+
return paths;
|
|
292
|
+
}
|
|
293
|
+
function main(argv) {
|
|
294
|
+
let violations = [];
|
|
295
|
+
if (argv.includes("--files")) {
|
|
296
|
+
const paths = parseFilesArg(argv);
|
|
297
|
+
if (paths.length === 0) {
|
|
298
|
+
console.error("✗ --files requires at least one .feature path");
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
violations = checkFilePaths(paths);
|
|
302
|
+
} else {
|
|
303
|
+
const root = argv.includes("--root") ? argv[argv.indexOf("--root") + 1] : ".agents/specs";
|
|
304
|
+
const paths = [];
|
|
305
|
+
for (const { slug, files } of discoverSuiteDirs(root)) for (const file of files) paths.push(join(root, slug, file));
|
|
306
|
+
violations = checkFilePaths(paths);
|
|
307
|
+
}
|
|
308
|
+
if (violations.length) {
|
|
309
|
+
for (const line of violations) console.error(`✗ ${line}`);
|
|
310
|
+
return 1;
|
|
311
|
+
}
|
|
312
|
+
process.stdout.write("suite checks OK\n");
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
|
|
316
|
+
//#endregion
|
|
317
|
+
export { checkFilePaths, checkScenarioMap, checkSuite, discoverSuiteDirs, findDeadRubric, main, parseFilesArg, parseScenarioMap, parseSuite, runGherkinValidate };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { GitError, diff } from "gherkin-cli";
|
|
6
|
+
//#region skills/spec-gate/scripts/classify-edit-class.mts
|
|
7
|
+
function hasFeatureFrozenTag(text) {
|
|
8
|
+
const lines = text.split("\n");
|
|
9
|
+
const featureIdx = lines.findIndex((l) => /^\s*Feature:/i.test(l));
|
|
10
|
+
if (featureIdx === -1) return false;
|
|
11
|
+
const tags = [];
|
|
12
|
+
for (let i = featureIdx - 1; i >= 0; i--) {
|
|
13
|
+
const line = lines[i].trim();
|
|
14
|
+
if (line === "") continue;
|
|
15
|
+
if (line.startsWith("@")) {
|
|
16
|
+
tags.push(...line.split(/\s+/).filter(Boolean));
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
return tags.includes("@frozen");
|
|
22
|
+
}
|
|
23
|
+
function classifyFromDiff(fileResult) {
|
|
24
|
+
const added = fileResult.scenarios.some((s) => s.change === "added");
|
|
25
|
+
const narrowed = fileResult.scenarios.some((s) => s.change === "modified" || s.change === "removed");
|
|
26
|
+
if (added && narrowed) return "mixed";
|
|
27
|
+
if (narrowed) return "narrowing";
|
|
28
|
+
if (added) return "additive";
|
|
29
|
+
return "no-content-change";
|
|
30
|
+
}
|
|
31
|
+
function classifyFromFileResult(fileResult) {
|
|
32
|
+
if (fileResult === void 0) return {
|
|
33
|
+
classification: "unclassifiable",
|
|
34
|
+
reason: "the structural differ returned no result for this file"
|
|
35
|
+
};
|
|
36
|
+
if (fileResult.error) return {
|
|
37
|
+
classification: "unclassifiable",
|
|
38
|
+
reason: `cannot parse (${fileResult.error.code}): ${fileResult.error.message}`
|
|
39
|
+
};
|
|
40
|
+
return { classification: classifyFromDiff(fileResult) };
|
|
41
|
+
}
|
|
42
|
+
function parseRenameStatus(nameStatusOutput) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const line of nameStatusOutput.split("\n")) {
|
|
45
|
+
const m = /^R(\d+)\t([^\t]+)\t([^\t]+)$/.exec(line);
|
|
46
|
+
if (m) out.push({
|
|
47
|
+
score: Number(m[1]),
|
|
48
|
+
oldPath: m[2],
|
|
49
|
+
newPath: m[3]
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function detectPureRename(base, path, cwd) {
|
|
55
|
+
try {
|
|
56
|
+
return parseRenameStatus(execFileSync("git", [
|
|
57
|
+
"diff",
|
|
58
|
+
"--name-status",
|
|
59
|
+
"-M",
|
|
60
|
+
base
|
|
61
|
+
], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
cwd,
|
|
64
|
+
stdio: [
|
|
65
|
+
"ignore",
|
|
66
|
+
"pipe",
|
|
67
|
+
"ignore"
|
|
68
|
+
]
|
|
69
|
+
})).some((r) => r.newPath === path && r.score === 100);
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function readGitShow(base, path, cwd) {
|
|
75
|
+
try {
|
|
76
|
+
return execFileSync("git", ["show", `${base}:${path}`], {
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
cwd,
|
|
79
|
+
stdio: [
|
|
80
|
+
"ignore",
|
|
81
|
+
"pipe",
|
|
82
|
+
"ignore"
|
|
83
|
+
]
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function makeCwdReader(cwd) {
|
|
90
|
+
return (file, base) => {
|
|
91
|
+
const abs = resolve(cwd, file);
|
|
92
|
+
const dir = dirname(abs);
|
|
93
|
+
let head;
|
|
94
|
+
try {
|
|
95
|
+
head = readFileSync(abs, "utf8");
|
|
96
|
+
} catch {
|
|
97
|
+
head = void 0;
|
|
98
|
+
}
|
|
99
|
+
const gitIo = {
|
|
100
|
+
cwd: dir,
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
stdio: [
|
|
103
|
+
"ignore",
|
|
104
|
+
"pipe",
|
|
105
|
+
"ignore"
|
|
106
|
+
]
|
|
107
|
+
};
|
|
108
|
+
let rel;
|
|
109
|
+
try {
|
|
110
|
+
rel = execFileSync("git", [
|
|
111
|
+
"ls-files",
|
|
112
|
+
"--full-name",
|
|
113
|
+
"--",
|
|
114
|
+
abs
|
|
115
|
+
], gitIo).trim();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
throw new GitError(`git ls-files failed for ${file}: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
if (rel === "") {
|
|
120
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], gitIo).trim();
|
|
121
|
+
rel = relative(top, abs).split(sep).join("/");
|
|
122
|
+
}
|
|
123
|
+
let baseText;
|
|
124
|
+
try {
|
|
125
|
+
baseText = execFileSync("git", ["show", `${base}:${rel}`], gitIo);
|
|
126
|
+
} catch {
|
|
127
|
+
try {
|
|
128
|
+
execFileSync("git", [
|
|
129
|
+
"rev-parse",
|
|
130
|
+
"--verify",
|
|
131
|
+
"--quiet",
|
|
132
|
+
`${base}^{commit}`
|
|
133
|
+
], {
|
|
134
|
+
cwd: dir,
|
|
135
|
+
stdio: "ignore"
|
|
136
|
+
});
|
|
137
|
+
baseText = void 0;
|
|
138
|
+
} catch {
|
|
139
|
+
throw new GitError(`git could not resolve base ref '${base}'`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
head,
|
|
144
|
+
base: baseText
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const runGherkinDiff = (base, paths, cwd) => diff(paths, {
|
|
149
|
+
base,
|
|
150
|
+
full: true
|
|
151
|
+
}, { readDiff: makeCwdReader(cwd) });
|
|
152
|
+
function preCheckFile(path, base, cwd) {
|
|
153
|
+
let currentText;
|
|
154
|
+
try {
|
|
155
|
+
currentText = readFileSync(join(cwd, path), "utf8");
|
|
156
|
+
} catch {
|
|
157
|
+
currentText = "";
|
|
158
|
+
}
|
|
159
|
+
const baselineText = readGitShow(base, path, cwd);
|
|
160
|
+
if (!hasFeatureFrozenTag(currentText) && !hasFeatureFrozenTag(baselineText)) return {
|
|
161
|
+
path,
|
|
162
|
+
early: {
|
|
163
|
+
file: path,
|
|
164
|
+
classification: "unfrozen-skip",
|
|
165
|
+
scenarios: []
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
if (detectPureRename(base, path, cwd)) return {
|
|
169
|
+
path,
|
|
170
|
+
early: {
|
|
171
|
+
file: path,
|
|
172
|
+
classification: "no-content-change",
|
|
173
|
+
scenarios: []
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
return { path };
|
|
177
|
+
}
|
|
178
|
+
function classifyFromDiffOutput(path, diff) {
|
|
179
|
+
const fileResult = diff.files.find((f) => f.file === path) ?? (diff.files.length === 1 ? diff.files[0] : void 0);
|
|
180
|
+
const { classification, reason } = classifyFromFileResult(fileResult);
|
|
181
|
+
return {
|
|
182
|
+
file: path,
|
|
183
|
+
classification,
|
|
184
|
+
scenarios: fileResult?.scenarios ?? [],
|
|
185
|
+
reason
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function classifyFile(path, base, cwd = ".", diffWith = runGherkinDiff) {
|
|
189
|
+
const pre = preCheckFile(path, base, cwd);
|
|
190
|
+
if (pre.early) return pre.early;
|
|
191
|
+
let diff;
|
|
192
|
+
try {
|
|
193
|
+
diff = diffWith(base, [path], cwd);
|
|
194
|
+
} catch {
|
|
195
|
+
return {
|
|
196
|
+
file: path,
|
|
197
|
+
classification: "unclassifiable",
|
|
198
|
+
scenarios: [],
|
|
199
|
+
reason: "the structural differ produced no readable result"
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return classifyFromDiffOutput(path, diff);
|
|
203
|
+
}
|
|
204
|
+
function classifyFiles(paths, base, cwd = ".", diffWith = runGherkinDiff) {
|
|
205
|
+
const preChecked = paths.filter((p) => p.endsWith(".feature")).map((path) => preCheckFile(path, base, cwd));
|
|
206
|
+
const needsDiff = preChecked.filter((r) => r.early === void 0).map((r) => r.path);
|
|
207
|
+
let diff;
|
|
208
|
+
if (needsDiff.length > 0) try {
|
|
209
|
+
diff = diffWith(base, needsDiff, cwd);
|
|
210
|
+
} catch {
|
|
211
|
+
diff = void 0;
|
|
212
|
+
}
|
|
213
|
+
return preChecked.map(({ path, early }) => {
|
|
214
|
+
if (early) return early;
|
|
215
|
+
if (diff === void 0) return {
|
|
216
|
+
file: path,
|
|
217
|
+
classification: "unclassifiable",
|
|
218
|
+
scenarios: [],
|
|
219
|
+
reason: "the structural differ produced no readable result"
|
|
220
|
+
};
|
|
221
|
+
return classifyFromDiffOutput(path, diff);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function formatText(results) {
|
|
225
|
+
const lines = [];
|
|
226
|
+
for (const r of results) {
|
|
227
|
+
lines.push(`${r.classification.toUpperCase().padEnd(18)} ${r.file}`);
|
|
228
|
+
if (r.reason) lines.push(` ${r.reason}`);
|
|
229
|
+
for (const s of r.scenarios) if (s.change !== "unchanged") lines.push(` ${s.change.padEnd(10)} ${s.name}`);
|
|
230
|
+
}
|
|
231
|
+
return lines.join("\n");
|
|
232
|
+
}
|
|
233
|
+
function parseFilesArg(argv) {
|
|
234
|
+
const idx = argv.indexOf("--files");
|
|
235
|
+
if (idx === -1) return [];
|
|
236
|
+
const paths = [];
|
|
237
|
+
for (let i = idx + 1; i < argv.length; i++) {
|
|
238
|
+
if (argv[i].startsWith("--")) break;
|
|
239
|
+
paths.push(argv[i]);
|
|
240
|
+
}
|
|
241
|
+
return paths;
|
|
242
|
+
}
|
|
243
|
+
function main(argv) {
|
|
244
|
+
const paths = parseFilesArg(argv);
|
|
245
|
+
if (paths.length === 0) {
|
|
246
|
+
console.error("✗ --files requires at least one .feature path");
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
const base = argv.includes("--base") ? argv[argv.indexOf("--base") + 1] : "HEAD";
|
|
250
|
+
const format = argv.includes("--format") ? argv[argv.indexOf("--format") + 1] : "text";
|
|
251
|
+
const results = classifyFiles(paths, base);
|
|
252
|
+
if (format === "json") process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
|
|
253
|
+
else process.stdout.write(`${formatText(results)}\n`);
|
|
254
|
+
const unclassifiable = results.filter((r) => r.classification === "unclassifiable");
|
|
255
|
+
if (unclassifiable.length) {
|
|
256
|
+
for (const r of unclassifiable) console.error(`✗ ${r.file}: unclassifiable — ${r.reason}`);
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
|
|
262
|
+
//#endregion
|
|
263
|
+
export { classifyFile, classifyFiles, classifyFromDiff, classifyFromFileResult, formatText, hasFeatureFrozenTag, main, parseRenameStatus, runGherkinDiff };
|
package/license
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Unional
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cyber-sdd",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
12
|
"bin": {
|
|
13
|
-
"sdd-check-specs": "./
|
|
13
|
+
"sdd-check-specs": "./dist/check-project-specs/scripts/check-project-specs.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
+
"dist",
|
|
16
17
|
"skills",
|
|
17
18
|
"agents",
|
|
18
19
|
".plugin",
|
|
@@ -21,10 +22,17 @@
|
|
|
21
22
|
"!skills/**/*.test.mts"
|
|
22
23
|
],
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"gherkin-cli": "0.
|
|
25
|
+
"gherkin-cli": "0.2.1"
|
|
25
26
|
},
|
|
26
27
|
"homepage": "https://github.com/cyberuni/cyber-sdd",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"tsdown": "^0.23.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.11"
|
|
33
|
+
},
|
|
27
34
|
"scripts": {
|
|
35
|
+
"build": "tsdown",
|
|
28
36
|
"check:spec": "node ./skills/check-project-specs/scripts/check-project-specs.mts",
|
|
29
37
|
"test": "node --test \"skills/*/scripts/*.test.mts\"",
|
|
30
38
|
"typecheck": "tsc -p tsconfig.json"
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type SpecRecord,
|
|
21
21
|
} from '../../discover-specs/scripts/discover-specs.mts'
|
|
22
22
|
|
|
23
|
-
const
|
|
23
|
+
const HERE = fileURLToPath(import.meta.url)
|
|
24
24
|
|
|
25
25
|
// ─── the engine set ───────────────────────────────────────────────────────────
|
|
26
26
|
|
|
@@ -61,6 +61,17 @@ export const ENGINES: Engine[] = [
|
|
|
61
61
|
},
|
|
62
62
|
]
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Where an engine runs from. The .mts source runs its siblings in the skills tree; the
|
|
66
|
+
* published bin runs from dist/, which mirrors that tree compiled to .js (tsdown.config.ts)
|
|
67
|
+
* — Node will not strip types under node_modules, so the bin must never reach back into
|
|
68
|
+
* the .mts tree.
|
|
69
|
+
*/
|
|
70
|
+
export function enginePath(e: Engine, here: string = HERE): string {
|
|
71
|
+
const skillsDir = resolve(dirname(here), '../..')
|
|
72
|
+
return join(skillsDir, here.endsWith('.mts') ? e.script : e.script.replace(/\.mts$/, '.js'))
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
// ─── repo root ────────────────────────────────────────────────────────────────
|
|
65
76
|
|
|
66
77
|
/** Walk up from `start` for the workspace marker. Returns '' when not found. */
|
|
@@ -200,7 +211,7 @@ function runEngines(repoRoot: string, specDir: string): number {
|
|
|
200
211
|
// cwd is the repo root, not the project dir: the engines resolve
|
|
201
212
|
// repo-root-relative references against process.cwd().
|
|
202
213
|
try {
|
|
203
|
-
execFileSync('node', [
|
|
214
|
+
execFileSync('node', [enginePath(e), ...e.args(specDir)], {
|
|
204
215
|
cwd: repoRoot,
|
|
205
216
|
stdio: 'inherit',
|
|
206
217
|
})
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// are exported for node:test; running the file directly drives the CLI.
|
|
11
11
|
|
|
12
12
|
import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
13
|
-
import { join } from 'node:path'
|
|
13
|
+
import { join, relative } from 'node:path'
|
|
14
14
|
import { pathToFileURL } from 'node:url'
|
|
15
15
|
|
|
16
16
|
export const BEGIN_MARKER = '<!-- BEGIN generated: by-concept (project-spec/concept-index) -->'
|
|
@@ -120,7 +120,7 @@ function walk(dir: string, specDir: string, out: NodeRecord[]): void {
|
|
|
120
120
|
} else if (entry.name.endsWith('.md')) {
|
|
121
121
|
const fm = parseFrontmatter(readFileSync(full, 'utf8'))
|
|
122
122
|
if (!fm || fm.concepts.length === 0) continue
|
|
123
|
-
const relPath =
|
|
123
|
+
const relPath = relative(specDir, full).replace(/\\/g, '/')
|
|
124
124
|
out.push({
|
|
125
125
|
relPath,
|
|
126
126
|
display: displayPath(relPath),
|