cyber-sdd 0.4.0 → 0.4.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 -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/dist/spec-gate/vendor/gherkin-cli.js +6369 -0
- package/license +21 -0
- package/package.json +12 -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/README.md +9 -0
- package/skills/spec-gate/scripts/check-suite.mts +3 -2
- package/skills/spec-gate/scripts/classify-edit-class.mts +7 -7
- package/skills/spec-gate/vendor/gherkin-cli.d.mts +2 -0
- package/skills/spec-gate/vendor/gherkin-cli.mjs +6545 -0
- package/skills/start-mission/SKILL.md +1 -0
- package/skills/touch-set-correction/README.md +5 -0
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +7 -7
- package/skills/verify-scenarios/README.md +6 -1
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +2 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
//#region skills/concept-index/scripts/concept-index.mts
|
|
6
|
+
const BEGIN_MARKER = "<!-- BEGIN generated: by-concept (project-spec/concept-index) -->";
|
|
7
|
+
const END_MARKER = "<!-- END generated: by-concept -->";
|
|
8
|
+
const ANCHOR_HEADING = "## Invariants";
|
|
9
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
10
|
+
"node_modules",
|
|
11
|
+
".git",
|
|
12
|
+
"dist",
|
|
13
|
+
".turbo",
|
|
14
|
+
".next",
|
|
15
|
+
"coverage"
|
|
16
|
+
]);
|
|
17
|
+
function parseFrontmatter(text) {
|
|
18
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text);
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
const fm = {
|
|
21
|
+
concepts: [],
|
|
22
|
+
model: false
|
|
23
|
+
};
|
|
24
|
+
const lines = m[1].split("\n").map((l) => l.replace(/\r$/, ""));
|
|
25
|
+
for (let i = 0; i < lines.length; i++) {
|
|
26
|
+
const line = lines[i];
|
|
27
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
28
|
+
if (line.length - line.trimStart().length !== 0) continue;
|
|
29
|
+
const [key, ...rest] = line.trim().split(":");
|
|
30
|
+
const value = rest.join(":").trim();
|
|
31
|
+
if (key === "spec-type") fm.specType = unquote(value);
|
|
32
|
+
else if (key === "model") fm.model = unquote(value) === "true";
|
|
33
|
+
else if (key === "concept") {
|
|
34
|
+
if (value === "" || value === "|" || value === ">") for (let j = i + 1; j < lines.length; j++) {
|
|
35
|
+
const item = lines[j];
|
|
36
|
+
if (item.trim() === "") continue;
|
|
37
|
+
if (item.length - item.trimStart().length === 0) break;
|
|
38
|
+
const dash = /^\s*-\s+(.*)$/.exec(item);
|
|
39
|
+
if (dash) fm.concepts.push(unquote(dash[1].trim()));
|
|
40
|
+
}
|
|
41
|
+
else fm.concepts.push(...parseScalarOrFlow(value));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return fm;
|
|
45
|
+
}
|
|
46
|
+
function parseScalarOrFlow(value) {
|
|
47
|
+
const v = value.trim();
|
|
48
|
+
if (v.startsWith("[") && v.endsWith("]")) return v.slice(1, -1).split(",").map((s) => unquote(s.trim())).filter((s) => s.length > 0);
|
|
49
|
+
const single = unquote(v);
|
|
50
|
+
return single.length > 0 ? [single] : [];
|
|
51
|
+
}
|
|
52
|
+
function unquote(v) {
|
|
53
|
+
return v.replace(/^["']|["']$/g, "");
|
|
54
|
+
}
|
|
55
|
+
function facetKind(relPath, specType) {
|
|
56
|
+
const p = relPath.replace(/\\/g, "/");
|
|
57
|
+
if (p.startsWith("design/")) return "rule";
|
|
58
|
+
if (p.startsWith("workflows/")) return "workflow";
|
|
59
|
+
if (specType === "reference") return "reference";
|
|
60
|
+
if (specType === "behavioral") return "behavior";
|
|
61
|
+
return "index";
|
|
62
|
+
}
|
|
63
|
+
function displayPath(relPath) {
|
|
64
|
+
const p = relPath.replace(/\\/g, "/");
|
|
65
|
+
return p.endsWith("/README.md") ? p.slice(0, -9) : p;
|
|
66
|
+
}
|
|
67
|
+
function scanProjectSpec(specDir) {
|
|
68
|
+
const records = [];
|
|
69
|
+
walk(specDir, specDir, records);
|
|
70
|
+
return records;
|
|
71
|
+
}
|
|
72
|
+
function walk(dir, specDir, out) {
|
|
73
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
74
|
+
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
|
|
75
|
+
const full = join(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory()) walk(full, specDir, out);
|
|
77
|
+
else if (entry.name.endsWith(".md")) {
|
|
78
|
+
const fm = parseFrontmatter(readFileSync(full, "utf8"));
|
|
79
|
+
if (!fm || fm.concepts.length === 0) continue;
|
|
80
|
+
const relPath = relative(specDir, full).replace(/\\/g, "/");
|
|
81
|
+
out.push({
|
|
82
|
+
relPath,
|
|
83
|
+
display: displayPath(relPath),
|
|
84
|
+
concepts: fm.concepts,
|
|
85
|
+
facet: facetKind(relPath, fm.specType)
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function groupByConcept(records) {
|
|
91
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
92
|
+
for (const rec of records) for (const concept of rec.concepts) {
|
|
93
|
+
const list = grouped.get(concept) ?? [];
|
|
94
|
+
list.push(rec);
|
|
95
|
+
grouped.set(concept, list);
|
|
96
|
+
}
|
|
97
|
+
return new Map([...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, [...v].sort((x, y) => x.display.localeCompare(y.display))]));
|
|
98
|
+
}
|
|
99
|
+
function renderTable(grouped) {
|
|
100
|
+
return [
|
|
101
|
+
"| Concept | Facets |",
|
|
102
|
+
"|---|---|",
|
|
103
|
+
...[...grouped.entries()].map(([concept, nodes]) => {
|
|
104
|
+
return `| \`${concept}\` | ${nodes.map((n) => `\`${n.display}\` (${n.facet})`).join(" · ")} |`;
|
|
105
|
+
})
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
function renderSection(grouped) {
|
|
109
|
+
return [
|
|
110
|
+
BEGIN_MARKER,
|
|
111
|
+
"",
|
|
112
|
+
"## By concept",
|
|
113
|
+
"",
|
|
114
|
+
"> Generated from `concept:` frontmatter by `project-spec/concept-index` — do not edit by hand.",
|
|
115
|
+
"",
|
|
116
|
+
renderTable(grouped),
|
|
117
|
+
"",
|
|
118
|
+
END_MARKER
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
function applySection(specText, section) {
|
|
122
|
+
const begin = specText.indexOf(BEGIN_MARKER);
|
|
123
|
+
const end = specText.indexOf(END_MARKER);
|
|
124
|
+
if (begin !== -1 && end !== -1 && end > begin) {
|
|
125
|
+
const before = specText.slice(0, begin);
|
|
126
|
+
const after = specText.slice(end + 34);
|
|
127
|
+
return before + section + after;
|
|
128
|
+
}
|
|
129
|
+
const anchor = specText.indexOf(ANCHOR_HEADING);
|
|
130
|
+
if (anchor !== -1) return specText.slice(0, anchor) + section + "\n\n" + specText.slice(anchor);
|
|
131
|
+
return `${specText.replace(/\s*$/, "")}\n\n${section}\n`;
|
|
132
|
+
}
|
|
133
|
+
function extractSection(specText) {
|
|
134
|
+
const begin = specText.indexOf(BEGIN_MARKER);
|
|
135
|
+
const end = specText.indexOf(END_MARKER);
|
|
136
|
+
if (begin === -1 || end === -1 || end < begin) return null;
|
|
137
|
+
return specText.slice(begin, end + 34);
|
|
138
|
+
}
|
|
139
|
+
function parseArgs(argv) {
|
|
140
|
+
let specDir = ".";
|
|
141
|
+
let mode = "print";
|
|
142
|
+
for (let i = 0; i < argv.length; i++) {
|
|
143
|
+
const a = argv[i];
|
|
144
|
+
if (a === "--spec-dir") specDir = argv[++i] ?? ".";
|
|
145
|
+
else if (a === "--write") mode = "write";
|
|
146
|
+
else if (a === "--check") mode = "check";
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
specDir,
|
|
150
|
+
mode
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function main(argv) {
|
|
154
|
+
const { specDir, mode } = parseArgs(argv);
|
|
155
|
+
const section = renderSection(groupByConcept(scanProjectSpec(specDir)));
|
|
156
|
+
const specPath = join(specDir, "spec.md");
|
|
157
|
+
if (mode === "print") {
|
|
158
|
+
process.stdout.write(`${section}\n`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
if (!existsSync(specPath)) {
|
|
162
|
+
process.stderr.write(`concept-index: no spec.md at ${specPath}\n`);
|
|
163
|
+
return 2;
|
|
164
|
+
}
|
|
165
|
+
const specText = readFileSync(specPath, "utf8");
|
|
166
|
+
if (mode === "check") {
|
|
167
|
+
if (extractSection(specText) === section) {
|
|
168
|
+
process.stdout.write("concept-index: no drift\n");
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
process.stderr.write("concept-index: drift — spec.md by-concept block is stale; run --write\n");
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
const next = applySection(specText, section);
|
|
175
|
+
if (next !== specText) writeFileSync(specPath, next);
|
|
176
|
+
process.stdout.write(`concept-index: ${next === specText ? "unchanged" : "updated"} ${specPath}\n`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
|
|
180
|
+
//#endregion
|
|
181
|
+
export { BEGIN_MARKER, END_MARKER, applySection, extractSection, facetKind, groupByConcept, main, parseFrontmatter, parseScalarOrFlow, renderSection, renderTable, scanProjectSpec };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
//#region skills/discover-specs/scripts/discover-specs.mts
|
|
5
|
+
const LIFECYCLE_STATUSES = /* @__PURE__ */ new Set([
|
|
6
|
+
"draft",
|
|
7
|
+
"approved",
|
|
8
|
+
"implemented",
|
|
9
|
+
"deprecated"
|
|
10
|
+
]);
|
|
11
|
+
const ROOT_PROJECT_NAME = "repo";
|
|
12
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
13
|
+
"node_modules",
|
|
14
|
+
".git",
|
|
15
|
+
"dist",
|
|
16
|
+
".turbo",
|
|
17
|
+
".next",
|
|
18
|
+
"coverage"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* A directory that is itself a checkout is a DIFFERENT repository's tree, not part of this corpus.
|
|
22
|
+
*
|
|
23
|
+
* A name blocklist cannot express this: it can only say "this is called X", never "this is a
|
|
24
|
+
* boundary". `SKIP_DIRS` already holds `.git`, so the walk skipped the METADATA directory while
|
|
25
|
+
* descending straight into the checkout that directory marks — one level off from where the guard
|
|
26
|
+
* was needed. Agent-harness worktree isolation checks this repo out inside itself, and the scan then
|
|
27
|
+
* found the whole corpus once per worktree: 38 spec files where 10 exist, and every corpus-wide
|
|
28
|
+
* guard silently auditing a tree nobody has.
|
|
29
|
+
*
|
|
30
|
+
* `.git` is a DIRECTORY in a clone and a FILE in a worktree or submodule, so both forms count.
|
|
31
|
+
*/
|
|
32
|
+
function isNestedCheckout(abs) {
|
|
33
|
+
return existsSync(join(abs, ".git"));
|
|
34
|
+
}
|
|
35
|
+
const ANCHORS_CONFIG = ".agents/sdd/spec-anchors.toml";
|
|
36
|
+
function parseFrontmatter(text) {
|
|
37
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text);
|
|
38
|
+
if (!m) return null;
|
|
39
|
+
const fm = { approval: {} };
|
|
40
|
+
let gate = null;
|
|
41
|
+
for (const raw of m[1].split("\n")) {
|
|
42
|
+
const line = raw.replace(/\r$/, "");
|
|
43
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
44
|
+
const indent = line.length - line.trimStart().length;
|
|
45
|
+
const [key, ...rest] = line.trim().split(":");
|
|
46
|
+
const value = rest.join(":").trim();
|
|
47
|
+
if (indent === 0) {
|
|
48
|
+
gate = null;
|
|
49
|
+
if (key === "status") fm.status = unquote(value);
|
|
50
|
+
else if (key === "project-path") fm.projectPath = unquote(value);
|
|
51
|
+
else if (key === "name") fm.name = unquote(value);
|
|
52
|
+
else if (key === "approval") gate = null;
|
|
53
|
+
} else if (indent === 2 && value === "" && insideApproval(m[1], line)) gate = key;
|
|
54
|
+
else if (indent >= 4 && key === "verdict" && gate) fm.approval[gate] = unquote(value);
|
|
55
|
+
}
|
|
56
|
+
return fm;
|
|
57
|
+
}
|
|
58
|
+
function insideApproval(block, line) {
|
|
59
|
+
const lines = block.split("\n");
|
|
60
|
+
const idx = lines.indexOf(line);
|
|
61
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
62
|
+
const l = lines[i].replace(/\r$/, "");
|
|
63
|
+
if (l.trim() === "" || l.trim().startsWith("#")) continue;
|
|
64
|
+
if (l.length - l.trimStart().length === 0) return l.trim().replace(/:.*$/, "") === "approval";
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
function unquote(v) {
|
|
69
|
+
return v.replace(/^["']|["']$/g, "");
|
|
70
|
+
}
|
|
71
|
+
function classifyLocation(relPath) {
|
|
72
|
+
const p = relPath.replace(/\\/g, "/");
|
|
73
|
+
if (p === ".agents/spec/spec.md") return {
|
|
74
|
+
pattern: "root-single",
|
|
75
|
+
locationDir: ""
|
|
76
|
+
};
|
|
77
|
+
const multi = /^\.agents\/specs\/([^/]+)\/spec\.md$/.exec(p);
|
|
78
|
+
if (multi) return {
|
|
79
|
+
pattern: "root-multi",
|
|
80
|
+
locationDir: multi[1]
|
|
81
|
+
};
|
|
82
|
+
const nested = /^(.+)\/\.agents\/spec\/spec\.md$/.exec(p);
|
|
83
|
+
if (nested) return {
|
|
84
|
+
pattern: "nested",
|
|
85
|
+
locationDir: nested[1]
|
|
86
|
+
};
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
function deriveName(loc, fm) {
|
|
90
|
+
if (fm.name) return {
|
|
91
|
+
name: fm.name,
|
|
92
|
+
nameSource: "declared"
|
|
93
|
+
};
|
|
94
|
+
if (loc.pattern === "root-single") return {
|
|
95
|
+
name: ROOT_PROJECT_NAME,
|
|
96
|
+
nameSource: "derived"
|
|
97
|
+
};
|
|
98
|
+
if (loc.pattern === "root-multi") return {
|
|
99
|
+
name: loc.locationDir,
|
|
100
|
+
nameSource: "derived"
|
|
101
|
+
};
|
|
102
|
+
if (loc.pattern === "extra" && loc.capturedName) return {
|
|
103
|
+
name: loc.capturedName,
|
|
104
|
+
nameSource: "derived"
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
name: loc.locationDir.split("/").pop() ?? loc.locationDir,
|
|
108
|
+
nameSource: "guessed"
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function discoverSpecFiles(root) {
|
|
112
|
+
const found = [];
|
|
113
|
+
const walk = (relDir) => {
|
|
114
|
+
let entries;
|
|
115
|
+
try {
|
|
116
|
+
entries = readdirSync(join(root, relDir), { withFileTypes: true });
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
|
|
122
|
+
const childRel = relDir ? `${relDir}/${e.name}` : e.name;
|
|
123
|
+
if (isNestedCheckout(join(root, childRel))) continue;
|
|
124
|
+
if (e.name === ".agents") {
|
|
125
|
+
probeAgents(root, childRel, found);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
walk(childRel);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
walk("");
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
function probeAgents(root, agentsRel, found) {
|
|
135
|
+
const single = `${agentsRel}/spec/spec.md`;
|
|
136
|
+
if (existsSync(join(root, single))) found.push(single);
|
|
137
|
+
if (agentsRel === ".agents") {
|
|
138
|
+
const specsDir = join(root, ".agents", "specs");
|
|
139
|
+
let projects;
|
|
140
|
+
try {
|
|
141
|
+
projects = readdirSync(specsDir, { withFileTypes: true });
|
|
142
|
+
} catch {
|
|
143
|
+
projects = [];
|
|
144
|
+
}
|
|
145
|
+
for (const p of projects) {
|
|
146
|
+
if (!p.isDirectory()) continue;
|
|
147
|
+
const rel = `.agents/specs/${p.name}/spec.md`;
|
|
148
|
+
if (existsSync(join(root, rel))) found.push(rel);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function parseAnchorsToml(text) {
|
|
153
|
+
const m = /(^|\n)\s*anchors\s*=\s*\[([\s\S]*?)\]/.exec(text);
|
|
154
|
+
if (!m) return [];
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const q of m[2].matchAll(/"([^"]*)"|'([^']*)'/g)) out.push((q[1] ?? q[2]).trim());
|
|
157
|
+
return out.filter((s) => s !== "");
|
|
158
|
+
}
|
|
159
|
+
function readAnchors(root) {
|
|
160
|
+
const file = join(root, ANCHORS_CONFIG);
|
|
161
|
+
if (!existsSync(file)) return [];
|
|
162
|
+
try {
|
|
163
|
+
return parseAnchorsToml(readFileSync(file, "utf8"));
|
|
164
|
+
} catch {
|
|
165
|
+
process.stderr.write(`discover-specs: ignoring unreadable ${ANCHORS_CONFIG}\n`);
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function collectDescendants(root, startDir) {
|
|
170
|
+
const out = [startDir];
|
|
171
|
+
let entries;
|
|
172
|
+
try {
|
|
173
|
+
entries = readdirSync(join(root, startDir), { withFileTypes: true });
|
|
174
|
+
} catch {
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
for (const e of entries) {
|
|
178
|
+
if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
|
|
179
|
+
const childRel = startDir ? `${startDir}/${e.name}` : e.name;
|
|
180
|
+
if (isNestedCheckout(join(root, childRel))) continue;
|
|
181
|
+
out.push(...collectDescendants(root, childRel));
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
function expandAnchor(root, pattern) {
|
|
186
|
+
const segs = pattern.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
187
|
+
let frontier = [{ dir: "" }];
|
|
188
|
+
for (const seg of segs) {
|
|
189
|
+
const next = [];
|
|
190
|
+
if (seg === "**") {
|
|
191
|
+
for (const node of frontier) for (const dir of collectDescendants(root, node.dir)) next.push({
|
|
192
|
+
dir,
|
|
193
|
+
capturedName: node.capturedName
|
|
194
|
+
});
|
|
195
|
+
frontier = next;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const isGlob = seg === "*" || seg === "<project>";
|
|
199
|
+
for (const node of frontier) if (isGlob) {
|
|
200
|
+
let entries;
|
|
201
|
+
try {
|
|
202
|
+
entries = readdirSync(join(root, node.dir), { withFileTypes: true });
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
for (const e of entries) {
|
|
207
|
+
if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
|
|
208
|
+
const childRel = node.dir ? `${node.dir}/${e.name}` : e.name;
|
|
209
|
+
if (isNestedCheckout(join(root, childRel))) continue;
|
|
210
|
+
next.push({
|
|
211
|
+
dir: childRel,
|
|
212
|
+
capturedName: seg === "<project>" ? e.name : node.capturedName
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
} else next.push({
|
|
216
|
+
dir: node.dir ? `${node.dir}/${seg}` : seg,
|
|
217
|
+
capturedName: node.capturedName
|
|
218
|
+
});
|
|
219
|
+
frontier = next;
|
|
220
|
+
}
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const node of frontier) {
|
|
223
|
+
const rel = node.dir ? `${node.dir}/spec.md` : "spec.md";
|
|
224
|
+
if (existsSync(join(root, rel))) out.push({
|
|
225
|
+
rel,
|
|
226
|
+
capturedName: node.capturedName
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
function recordFor(root, rel, loc) {
|
|
232
|
+
let fm;
|
|
233
|
+
try {
|
|
234
|
+
fm = parseFrontmatter(readFileSync(join(root, rel), "utf8"));
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (!fm?.status || !LIFECYCLE_STATUSES.has(fm.status)) return null;
|
|
239
|
+
const { name, nameSource } = deriveName(loc, fm);
|
|
240
|
+
return {
|
|
241
|
+
path: rel.replace(/\/spec\.md$/, ""),
|
|
242
|
+
name,
|
|
243
|
+
nameSource,
|
|
244
|
+
status: fm.status,
|
|
245
|
+
projectPath: fm.projectPath ?? "",
|
|
246
|
+
approvals: Object.entries(fm.approval).map(([gate, verdict]) => `${gate}:${verdict}`).join(";")
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function collectSpecs(root) {
|
|
250
|
+
const out = [];
|
|
251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
252
|
+
for (const rel of discoverSpecFiles(root)) {
|
|
253
|
+
const loc = classifyLocation(rel);
|
|
254
|
+
if (!loc) continue;
|
|
255
|
+
const rec = recordFor(root, rel, loc);
|
|
256
|
+
if (rec) {
|
|
257
|
+
out.push(rec);
|
|
258
|
+
seen.add(rel);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const pattern of readAnchors(root)) for (const { rel, capturedName } of expandAnchor(root, pattern)) {
|
|
262
|
+
if (seen.has(rel)) continue;
|
|
263
|
+
const rec = recordFor(root, rel, {
|
|
264
|
+
pattern: "extra",
|
|
265
|
+
locationDir: rel.replace(/\/spec\.md$/, ""),
|
|
266
|
+
capturedName
|
|
267
|
+
});
|
|
268
|
+
if (rec) {
|
|
269
|
+
out.push(rec);
|
|
270
|
+
seen.add(rel);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
274
|
+
}
|
|
275
|
+
function resolveByName(specs, name) {
|
|
276
|
+
const want = name.trim().toLowerCase();
|
|
277
|
+
const hits = specs.filter((s) => s.name.toLowerCase() === want);
|
|
278
|
+
if (hits.length === 0) return { kind: "none" };
|
|
279
|
+
if (hits.length === 1) return {
|
|
280
|
+
kind: "match",
|
|
281
|
+
spec: hits[0]
|
|
282
|
+
};
|
|
283
|
+
return {
|
|
284
|
+
kind: "ambiguous",
|
|
285
|
+
candidates: hits
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const COLUMNS = [
|
|
289
|
+
"path",
|
|
290
|
+
"name",
|
|
291
|
+
"nameSource",
|
|
292
|
+
"status",
|
|
293
|
+
"projectPath",
|
|
294
|
+
"approvals"
|
|
295
|
+
];
|
|
296
|
+
function toonField(v) {
|
|
297
|
+
if (v === "" || /[",]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, "\"\"")}"`;
|
|
298
|
+
return v;
|
|
299
|
+
}
|
|
300
|
+
function toToon(specs) {
|
|
301
|
+
return [`specs[${specs.length}]{${COLUMNS.join(",")}}:`, ...specs.map((s) => ` ${COLUMNS.map((c) => toonField(s[c])).join(",")}`)].join("\n");
|
|
302
|
+
}
|
|
303
|
+
function main(argv) {
|
|
304
|
+
const root = argv.includes("--root") ? argv[argv.indexOf("--root") + 1] ?? "." : ".";
|
|
305
|
+
const format = argv.includes("--format") ? argv[argv.indexOf("--format") + 1] : "toon";
|
|
306
|
+
let specs = collectSpecs(root);
|
|
307
|
+
if (argv.includes("--resolve")) {
|
|
308
|
+
const name = argv[argv.indexOf("--resolve") + 1] ?? "";
|
|
309
|
+
const r = resolveByName(specs, name);
|
|
310
|
+
specs = r.kind === "match" ? [r.spec] : r.kind === "ambiguous" ? r.candidates : [];
|
|
311
|
+
}
|
|
312
|
+
const out = format === "json" ? JSON.stringify(specs, null, 2) : toToon(specs);
|
|
313
|
+
process.stdout.write(`${out}\n`);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
|
|
317
|
+
//#endregion
|
|
318
|
+
export { LIFECYCLE_STATUSES, classifyLocation, collectSpecs, deriveName, discoverSpecFiles, expandAnchor, main, parseAnchorsToml, parseFrontmatter, readAnchors, resolveByName, toToon };
|