cyber-sdd 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/CHANGELOG.md +320 -0
  4. package/agents/sdd-scanner.md +1 -1
  5. package/package.json +11 -12
  6. package/plugin.json +55 -0
  7. package/skills/align-spec/SKILL.md +1 -1
  8. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  9. package/skills/blast-estimate/README.md +3 -3
  10. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  11. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  12. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  13. package/skills/check-plan-safety/SKILL.md +2 -3
  14. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  15. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  16. package/skills/check-project-specs/SKILL.md +1 -1
  17. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  18. package/skills/check-retired-terms/SKILL.md +2 -3
  19. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  20. package/skills/check-scenario-overlap/SKILL.md +1 -2
  21. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  22. package/skills/check-spec-references/SKILL.md +2 -2
  23. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  24. package/skills/check-spec-structure/SKILL.md +1 -2
  25. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  26. package/skills/collision-ladder/README.md +2 -2
  27. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  28. package/skills/combat-log-governance/SKILL.md +1 -1
  29. package/skills/concept-index/SKILL.md +1 -1
  30. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  31. package/skills/discover-plans/README.md +3 -3
  32. package/skills/discover-plans/SKILL.md +1 -1
  33. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  34. package/skills/discover-specs/README.md +3 -3
  35. package/skills/discover-specs/SKILL.md +1 -1
  36. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  37. package/skills/doctrine-loop/SKILL.md +1 -1
  38. package/skills/gate-validation-governance/SKILL.md +1 -1
  39. package/skills/init/README.md +2 -2
  40. package/skills/init/scripts/wire-statusline.mjs +168 -12
  41. package/skills/manage-ignore/README.md +3 -3
  42. package/skills/manage-ignore/SKILL.md +1 -2
  43. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  44. package/skills/manage-scenario-bridge/README.md +3 -3
  45. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  46. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  47. package/skills/manage-spec-anchors/README.md +3 -3
  48. package/skills/manage-spec-anchors/SKILL.md +1 -1
  49. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  50. package/skills/mission-graph/README.md +2 -2
  51. package/skills/mission-graph/SKILL.md +1 -1
  52. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  53. package/skills/pause-mission/SKILL.md +1 -1
  54. package/skills/place-node/SKILL.md +2 -2
  55. package/skills/place-node/scripts/place-node.mjs +117 -12
  56. package/skills/plan-retirement/README.md +2 -2
  57. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  58. package/skills/resolve-governances/README.md +3 -3
  59. package/skills/resolve-governances/SKILL.md +1 -1
  60. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  61. package/skills/resolve-tracking/SKILL.md +1 -1
  62. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  63. package/skills/scaffold-project-spec/SKILL.md +1 -1
  64. package/skills/spec-gate/README.md +12 -9
  65. package/skills/spec-gate/SKILL.md +2 -2
  66. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  67. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  68. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  69. package/skills/spec-producer-governance/SKILL.md +2 -2
  70. package/skills/start-mission/SKILL.md +1 -1
  71. package/skills/suite-format-governance/README.md +1 -1
  72. package/skills/suite-format-governance/SKILL.md +1 -1
  73. package/skills/touch-set-correction/README.md +5 -5
  74. package/skills/touch-set-correction/SKILL.md +1 -1
  75. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  76. package/skills/verify-scenarios/README.md +6 -6
  77. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  78. package/.plugin/pins.json +0 -3
  79. package/.plugin/plugin.json +0 -19
  80. package/dist/align-spec/scripts/align-spec.js +0 -102
  81. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  82. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  83. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  84. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  85. package/dist/concept-index/scripts/concept-index.js +0 -181
  86. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  87. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  88. package/dist/spec-gate/scripts/check-suite.js +0 -317
  89. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  90. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  91. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  92. package/skills/align-spec/scripts/align-spec.mts +0 -188
  93. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  94. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  95. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  96. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  97. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  98. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  99. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  100. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  102. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  103. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  104. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  105. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  106. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  108. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  109. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  110. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  111. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  112. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  113. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  114. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  115. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  116. package/skills/concept-index/scripts/concept-index.mts +0 -246
  117. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  118. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  119. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  120. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  121. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  122. package/skills/init/scripts/wire-statusline.mts +0 -279
  123. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  124. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  126. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  128. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  129. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  130. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  131. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  132. package/skills/place-node/scripts/place-node.mts +0 -158
  133. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  134. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  135. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  136. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  137. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  138. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  139. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  140. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  141. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  142. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  143. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  144. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  145. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  146. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  147. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  148. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  149. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  150. package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
@@ -1,251 +0,0 @@
1
- #!/usr/bin/env node
2
- import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
3
- import { join, relative, sep } from "node:path";
4
- import { pathToFileURL } from "node:url";
5
- //#region skills/check-field-mandates/scripts/check-field-mandates.mts
6
- /** UPPER_CASE, or snake_case with at least one underscore — a bare lowercase word is prose. */
7
- const TOKEN = "(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)";
8
- const TOKEN_ITEM = `${TOKEN}(?:\\(s\\))?`;
9
- /** A block line opening with a comma list of tokens, then a colon, a trailing comment, or nothing. */
10
- const BLOCK_DECLARATION_RE = new RegExp(`^\\s*(${TOKEN_ITEM}(?:\\s*,\\s*${TOKEN_ITEM})*)\\s*(:.*|#.*)?$`);
11
- /** A list item opening with one or more bold tokens joined by `+`. */
12
- const LIST_DECLARATION_RE = new RegExp(`^\\s*[-*+]\\s+(\\*\\*${TOKEN}\\*\\*(?:\\s*\\+\\s*\\*\\*${TOKEN}\\*\\*)*)(.*)$`);
13
- const BOLD_TOKEN_RE = new RegExp(`\\*\\*(${TOKEN})\\*\\*`, "g");
14
- const WORD_RE = new RegExp(`(?<![A-Za-z0-9_])${TOKEN}(?![A-Za-z0-9_])`, "g");
15
- const CODE_SPAN_RE = /`([^`\n]+)`/g;
16
- const MANDATE_LEAD_RE = new RegExp(`^<?(${TOKEN})(?=$|[\\s.:\\[\\]>(,=])`);
17
- const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
18
- /** The marker must carry a reason — a bare marker excuses nothing. */
19
- const IGNORE_MARKER_RE = /<!--\s*field-mandate-ignore\s*:\s*[^\s>-][^>]*-->/;
20
- /** A field token is two or more characters: a lone capital is a word, not a field. */
21
- function isField(token) {
22
- return token.length >= 2;
23
- }
24
- /** A token's case-folded form: the spelling-independent name of the field. */
25
- function fold(token) {
26
- return token.toUpperCase();
27
- }
28
- /** The folded forms a token may take and still be the same field — equal, or one trailing `S` apart. */
29
- function sameFieldForms(token) {
30
- const t = fold(token);
31
- const forms = [t, `${t}S`];
32
- if (t.endsWith("S") && t.length > 2) forms.push(t.slice(0, -1));
33
- return forms;
34
- }
35
- /** Does the set of folded tokens hold the same field as `token`? */
36
- function hasSameField(folded, token) {
37
- return sameFieldForms(token).some((f) => folded.has(f));
38
- }
39
- /** Is the token spelled lowercase (snake_case) rather than uppercase? */
40
- function isLower(token) {
41
- return token !== fold(token);
42
- }
43
- function parseDefinition(text) {
44
- const lines = text.split(/\r?\n/);
45
- const declarations = [];
46
- const mandates = [];
47
- const proseWords = /* @__PURE__ */ new Set();
48
- let i = 0;
49
- if (lines[0]?.trim() === "---") {
50
- const end = lines.findIndex((l, n) => n > 0 && l.trim() === "---");
51
- if (end > 0) i = end + 1;
52
- }
53
- let fence = null;
54
- let group = [];
55
- /** Record a span of text as prose: its field-shaped words, and any code-span mandate. */
56
- function scanProse(text, lineNo) {
57
- for (const m of text.matchAll(WORD_RE)) if (isField(m[0])) proseWords.add(m[0]);
58
- if (IGNORE_MARKER_RE.test(text)) return;
59
- for (const m of text.matchAll(CODE_SPAN_RE)) {
60
- const token = (m[1] ?? "").trim().match(MANDATE_LEAD_RE)?.[1];
61
- if (token && isField(token)) mandates.push({
62
- token,
63
- line: lineNo
64
- });
65
- }
66
- }
67
- for (; i < lines.length; i++) {
68
- const line = lines[i] ?? "";
69
- const lineNo = i + 1;
70
- const fenceMatch = line.match(FENCE_RE);
71
- if (fence) {
72
- if (fenceMatch && fenceMatch[1]?.[0] === fence.char && (fenceMatch[1]?.length ?? 0) >= fence.length && fenceMatch[2]?.trim() === "") {
73
- fence = null;
74
- group = [];
75
- continue;
76
- }
77
- if (!fence.structured) continue;
78
- const decl = line.match(BLOCK_DECLARATION_RE);
79
- const tokens = decl ? (decl[1] ?? "").split(",").map((t) => t.trim().replace(/\(s\)$/, "")) : [];
80
- const rest = decl?.[2];
81
- if (decl !== null && (rest?.startsWith(":") || tokens.length >= 2)) {
82
- const glossed = rest?.startsWith(":") === true && rest.slice(1).trim() !== "";
83
- group = tokens.filter(isField).map((token) => ({
84
- token,
85
- line: lineNo,
86
- glossed
87
- }));
88
- declarations.push(...group);
89
- } else if (line.trim() !== "") for (const d of group) d.glossed = true;
90
- continue;
91
- }
92
- if (fenceMatch) {
93
- const info = fenceMatch[2]?.trim() ?? "";
94
- fence = {
95
- char: fenceMatch[1]?.[0] ?? "`",
96
- length: fenceMatch[1]?.length ?? 3,
97
- structured: info === "" || info === "text"
98
- };
99
- group = [];
100
- continue;
101
- }
102
- const listDecl = line.match(LIST_DECLARATION_RE);
103
- if (listDecl) {
104
- const rest = listDecl[2] ?? "";
105
- const glossed = rest.trim() !== "";
106
- for (const m of (listDecl[1] ?? "").matchAll(BOLD_TOKEN_RE)) {
107
- const token = m[1] ?? "";
108
- if (isField(token)) declarations.push({
109
- token,
110
- line: lineNo,
111
- glossed
112
- });
113
- }
114
- scanProse(rest, lineNo);
115
- continue;
116
- }
117
- scanProse(line, lineNo);
118
- }
119
- return {
120
- declarations,
121
- mandates,
122
- proseWords
123
- };
124
- }
125
- function walk(dir, out) {
126
- let entries;
127
- try {
128
- entries = readdirSync(dir, { withFileTypes: true });
129
- } catch {
130
- return;
131
- }
132
- for (const e of entries) {
133
- const full = join(dir, e.name);
134
- if (e.isDirectory()) walk(full, out);
135
- else if (e.isFile() && e.name.endsWith(".md")) out.push(full);
136
- }
137
- }
138
- /** Is this root-relative path a skill (`plugins/<p>/skills/**\/SKILL.md`) or agent (`plugins/<p>/agents/*.md`)? */
139
- function isDefinitionPath(rel) {
140
- const parts = rel.split("/");
141
- if (parts[0] !== "plugins" || parts.length < 4) return false;
142
- if (parts[2] === "agents") return parts.length === 4 && (parts[3] ?? "").endsWith(".md");
143
- if (parts[2] === "skills") return parts.length >= 5 && parts[parts.length - 1] === "SKILL.md";
144
- return false;
145
- }
146
- /** Every definition under `root`, root-relative, `/`-separated, path-sorted. */
147
- function discoverDefinitions(root) {
148
- const pluginsDir = join(root, "plugins");
149
- if (!existsSync(pluginsDir)) return [];
150
- const files = [];
151
- walk(pluginsDir, files);
152
- return files.map((f) => relative(root, f).split(sep).join("/")).filter(isDefinitionPath).sort();
153
- }
154
- function check(parsed) {
155
- const known = /* @__PURE__ */ new Set();
156
- for (const p of parsed.values()) for (const d of p.declarations) known.add(fold(d.token));
157
- const findings = [];
158
- for (const [file, p] of parsed) {
159
- if (p.declarations.length === 0) continue;
160
- const declared = new Set(p.declarations.map((d) => fold(d.token)));
161
- const proseWords = new Set([...p.proseWords].map(fold));
162
- const seen = /* @__PURE__ */ new Set();
163
- for (const d of p.declarations) {
164
- if (seen.has(d.token)) continue;
165
- seen.add(d.token);
166
- if (!p.declarations.some((o) => o.token === d.token && o.glossed) && !hasSameField(proseWords, d.token)) findings.push({
167
- kind: "unexplained",
168
- file,
169
- line: d.line,
170
- token: d.token
171
- });
172
- }
173
- const reported = /* @__PURE__ */ new Set();
174
- for (const m of p.mandates) {
175
- if (!hasSameField(known, m.token)) continue;
176
- const key = `${m.line}:${m.token}`;
177
- if (reported.has(key)) continue;
178
- if (!hasSameField(declared, m.token)) {
179
- reported.add(key);
180
- findings.push({
181
- kind: "undeclared",
182
- file,
183
- line: m.line,
184
- token: m.token
185
- });
186
- continue;
187
- }
188
- const forms = sameFieldForms(m.token);
189
- const same = p.declarations.filter((d) => forms.includes(fold(d.token)));
190
- if (same.some((d) => isLower(d.token) === isLower(m.token))) continue;
191
- reported.add(key);
192
- findings.push({
193
- kind: "miscased",
194
- file,
195
- line: m.line,
196
- token: m.token,
197
- declared: same[0]?.token
198
- });
199
- }
200
- }
201
- return findings.sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
202
- }
203
- const DETAIL = {
204
- unexplained: "declared in a block, but no gloss and no prose explains it",
205
- undeclared: "mandated in the prose, but no block in this file declares it",
206
- miscased: "mandated in the prose, but this file declares it in the other case"
207
- };
208
- function formatReport(definitions, findings) {
209
- if (definitions.length === 0) return "check-field-mandates: no skill or agent definition found\n";
210
- if (findings.length === 0) return `check-field-mandates: ${definitions.length} definition(s) OK\n`;
211
- return `${findings.map((f) => ` ${f.kind.padEnd(11)} ${f.file}:${f.line} ${f.token} — ${DETAIL[f.kind]}${f.declared ? ` (${f.declared})` : ""}`).join("\n")}\ncheck-field-mandates: ${findings.length} finding(s) across ${definitions.length} definition(s)\n`;
212
- }
213
- function isReadableDirectory(path) {
214
- try {
215
- if (!statSync(path).isDirectory()) return false;
216
- readdirSync(path);
217
- return true;
218
- } catch {
219
- return false;
220
- }
221
- }
222
- function main(argv) {
223
- let root = ".";
224
- for (let i = 0; i < argv.length; i++) {
225
- const a = argv[i];
226
- if (a === "--root") {
227
- const next = argv[++i];
228
- if (next === void 0) {
229
- process.stderr.write("check-field-mandates: --root needs a directory\n");
230
- return 1;
231
- }
232
- root = next;
233
- continue;
234
- }
235
- process.stderr.write(`check-field-mandates: unrecognized flag ${a}\n`);
236
- return 1;
237
- }
238
- if (!isReadableDirectory(root)) {
239
- process.stderr.write(`check-field-mandates: root ${root} is not a readable directory\n`);
240
- return 1;
241
- }
242
- const definitions = discoverDefinitions(root);
243
- const parsed = /* @__PURE__ */ new Map();
244
- for (const rel of definitions) parsed.set(rel, parseDefinition(readFileSync(join(root, rel), "utf8")));
245
- const findings = check(parsed);
246
- process.stdout.write(formatReport(definitions, findings));
247
- return findings.length === 0 ? 0 : 1;
248
- }
249
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
250
- //#endregion
251
- export { check, discoverDefinitions, formatReport, isDefinitionPath, main, parseDefinition };
@@ -1,337 +0,0 @@
1
- #!/usr/bin/env node
2
- // check-field-mandates — a definition's declared fields and its prose must agree.
3
- //
4
- // Skill and agent definitions ship structured blocks (an Input, an Output, a dispatch payload)
5
- // whose fields the surrounding prose also mandates. Nothing else compares the two, so a field can be
6
- // mandated and never carried, or carried and never explained, and both read as complete. This
7
- // engine diffs the token sets (spec: .agents/specs/sdd/plugin/check-field-mandates/):
8
- // unexplained — a block declares a field that carries no gloss and the prose never names
9
- // undeclared — the prose mandates a known field (a code span) that none of the file's blocks declare
10
- // miscased — the prose mandates a field this file declares, but spelled in the other case
11
- //
12
- // A field is explained by a gloss OR by the prose — requiring the prose to repeat a glossed
13
- // declaration would demand filler in every definition. A mandate is a code span naming a KNOWN field
14
- // (declared somewhere in the tree); that vocabulary is what separates a field from `TODO`.
15
- //
16
- // A field token is UPPER_CASE or snake_case. A lowercase word needs an underscore to be a token, so
17
- // ordinary prose words (`owner`, `summary`) never become fields. Case decides whether two tokens are the
18
- // same field only for the miscased finding: `governances_loaded` and `GOVERNANCES_LOADED` are one
19
- // field, spelled two ways, and a file that does both is reported.
20
- //
21
- // Pure functions are exported for node:test; running the file directly drives the CLI. No
22
- // dependencies (the repo's node-≥23.6 / no-deps convention).
23
-
24
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
25
- import { join, relative, sep } from 'node:path'
26
- import { pathToFileURL } from 'node:url'
27
-
28
- export type FindingKind = 'unexplained' | 'undeclared' | 'miscased'
29
-
30
- export interface Finding {
31
- kind: FindingKind
32
- /** Root-relative path of the definition, `/`-separated. */
33
- file: string
34
- /** 1-based line: the declaration for `unexplained`, the prose line for `undeclared`. */
35
- line: number
36
- token: string
37
- /** For `miscased`: the spelling this file's declaration uses. */
38
- declared?: string
39
- }
40
-
41
- interface Declaration {
42
- token: string
43
- line: number
44
- glossed: boolean
45
- }
46
-
47
- interface Mandate {
48
- token: string
49
- line: number
50
- }
51
-
52
- export interface ParsedDefinition {
53
- declarations: Declaration[]
54
- mandates: Mandate[]
55
- /** Every field-shaped word the prose names, code span or not. */
56
- proseWords: Set<string>
57
- }
58
-
59
- /** UPPER_CASE, or snake_case with at least one underscore — a bare lowercase word is prose. */
60
- const TOKEN = '(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)'
61
- const TOKEN_ITEM = `${TOKEN}(?:\\(s\\))?`
62
- /** A block line opening with a comma list of tokens, then a colon, a trailing comment, or nothing. */
63
- const BLOCK_DECLARATION_RE = new RegExp(`^\\s*(${TOKEN_ITEM}(?:\\s*,\\s*${TOKEN_ITEM})*)\\s*(:.*|#.*)?$`)
64
- /** A list item opening with one or more bold tokens joined by `+`. */
65
- const LIST_DECLARATION_RE = new RegExp(`^\\s*[-*+]\\s+(\\*\\*${TOKEN}\\*\\*(?:\\s*\\+\\s*\\*\\*${TOKEN}\\*\\*)*)(.*)$`)
66
- const BOLD_TOKEN_RE = new RegExp(`\\*\\*(${TOKEN})\\*\\*`, 'g')
67
- const WORD_RE = new RegExp(`(?<![A-Za-z0-9_])${TOKEN}(?![A-Za-z0-9_])`, 'g')
68
- const CODE_SPAN_RE = /`([^`\n]+)`/g
69
- const MANDATE_LEAD_RE = new RegExp(`^<?(${TOKEN})(?=$|[\\s.:\\[\\]>(,=])`)
70
- const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/
71
- /** The marker must carry a reason — a bare marker excuses nothing. */
72
- const IGNORE_MARKER_RE = /<!--\s*field-mandate-ignore\s*:\s*[^\s>-][^>]*-->/
73
-
74
- /** A field token is two or more characters: a lone capital is a word, not a field. */
75
- function isField(token: string): boolean {
76
- return token.length >= 2
77
- }
78
-
79
- /** A token's case-folded form: the spelling-independent name of the field. */
80
- function fold(token: string): string {
81
- return token.toUpperCase()
82
- }
83
-
84
- /** The folded forms a token may take and still be the same field — equal, or one trailing `S` apart. */
85
- function sameFieldForms(token: string): string[] {
86
- const t = fold(token)
87
- const forms = [t, `${t}S`]
88
- if (t.endsWith('S') && t.length > 2) forms.push(t.slice(0, -1))
89
- return forms
90
- }
91
-
92
- /** Does the set of folded tokens hold the same field as `token`? */
93
- function hasSameField(folded: Set<string>, token: string): boolean {
94
- return sameFieldForms(token).some((f) => folded.has(f))
95
- }
96
-
97
- /** Is the token spelled lowercase (snake_case) rather than uppercase? */
98
- function isLower(token: string): boolean {
99
- return token !== fold(token)
100
- }
101
-
102
- // ── Parse ──
103
-
104
- export function parseDefinition(text: string): ParsedDefinition {
105
- const lines = text.split(/\r?\n/)
106
- const declarations: Declaration[] = []
107
- const mandates: Mandate[] = []
108
- const proseWords = new Set<string>()
109
-
110
- let i = 0
111
- // YAML frontmatter is metadata, not prose.
112
- if (lines[0]?.trim() === '---') {
113
- const end = lines.findIndex((l, n) => n > 0 && l.trim() === '---')
114
- if (end > 0) i = end + 1
115
- }
116
-
117
- let fence: { char: string; length: number; structured: boolean } | null = null
118
- // The declarations a continuation line glosses: the most recent group in the open block.
119
- let group: Declaration[] = []
120
-
121
- /** Record a span of text as prose: its field-shaped words, and any code-span mandate. */
122
- function scanProse(text: string, lineNo: number): void {
123
- for (const m of text.matchAll(WORD_RE)) if (isField(m[0])) proseWords.add(m[0])
124
- if (IGNORE_MARKER_RE.test(text)) return
125
- for (const m of text.matchAll(CODE_SPAN_RE)) {
126
- const lead = (m[1] ?? '').trim().match(MANDATE_LEAD_RE)
127
- const token = lead?.[1]
128
- if (token && isField(token)) mandates.push({ token, line: lineNo })
129
- }
130
- }
131
-
132
- for (; i < lines.length; i++) {
133
- const line = lines[i] ?? ''
134
- const lineNo = i + 1
135
- const fenceMatch = line.match(FENCE_RE)
136
-
137
- if (fence) {
138
- if (
139
- fenceMatch &&
140
- fenceMatch[1]?.[0] === fence.char &&
141
- (fenceMatch[1]?.length ?? 0) >= fence.length &&
142
- fenceMatch[2]?.trim() === ''
143
- ) {
144
- fence = null
145
- group = []
146
- continue
147
- }
148
- if (!fence.structured) continue
149
- const decl = line.match(BLOCK_DECLARATION_RE)
150
- const tokens = decl ? (decl[1] ?? '').split(',').map((t) => t.trim().replace(/\(s\)$/, '')) : []
151
- const rest = decl?.[2]
152
- const isDeclaration = decl !== null && (rest?.startsWith(':') || tokens.length >= 2)
153
- if (isDeclaration) {
154
- const glossed = rest?.startsWith(':') === true && rest.slice(1).trim() !== ''
155
- group = tokens.filter(isField).map((token) => ({ token, line: lineNo, glossed }))
156
- declarations.push(...group)
157
- } else if (line.trim() !== '') {
158
- for (const d of group) d.glossed = true
159
- }
160
- continue
161
- }
162
-
163
- if (fenceMatch) {
164
- const info = fenceMatch[2]?.trim() ?? ''
165
- fence = {
166
- char: fenceMatch[1]?.[0] ?? '`',
167
- length: fenceMatch[1]?.length ?? 3,
168
- structured: info === '' || info === 'text',
169
- }
170
- group = []
171
- continue
172
- }
173
-
174
- const listDecl = line.match(LIST_DECLARATION_RE)
175
- if (listDecl) {
176
- const rest = listDecl[2] ?? ''
177
- const glossed = rest.trim() !== ''
178
- for (const m of (listDecl[1] ?? '').matchAll(BOLD_TOKEN_RE)) {
179
- const token = m[1] ?? ''
180
- if (isField(token)) declarations.push({ token, line: lineNo, glossed })
181
- }
182
- // The text after a list item's bold lead is that declaration's gloss AND prose: a code
183
- // span in it mandates like any other (it is scanned the same as a full prose line).
184
- scanProse(rest, lineNo)
185
- continue
186
- }
187
-
188
- // Prose.
189
- scanProse(line, lineNo)
190
- }
191
-
192
- return { declarations, mandates, proseWords }
193
- }
194
-
195
- // ── Discovery ──
196
-
197
- function walk(dir: string, out: string[]): void {
198
- let entries: import('node:fs').Dirent[]
199
- try {
200
- entries = readdirSync(dir, { withFileTypes: true })
201
- } catch {
202
- return
203
- }
204
- for (const e of entries) {
205
- const full = join(dir, e.name)
206
- if (e.isDirectory()) walk(full, out)
207
- else if (e.isFile() && e.name.endsWith('.md')) out.push(full)
208
- }
209
- }
210
-
211
- /** Is this root-relative path a skill (`plugins/<p>/skills/**\/SKILL.md`) or agent (`plugins/<p>/agents/*.md`)? */
212
- export function isDefinitionPath(rel: string): boolean {
213
- const parts = rel.split('/')
214
- if (parts[0] !== 'plugins' || parts.length < 4) return false
215
- if (parts[2] === 'agents') return parts.length === 4 && (parts[3] ?? '').endsWith('.md')
216
- if (parts[2] === 'skills') return parts.length >= 5 && parts[parts.length - 1] === 'SKILL.md'
217
- return false
218
- }
219
-
220
- /** Every definition under `root`, root-relative, `/`-separated, path-sorted. */
221
- export function discoverDefinitions(root: string): string[] {
222
- const pluginsDir = join(root, 'plugins')
223
- if (!existsSync(pluginsDir)) return []
224
- const files: string[] = []
225
- walk(pluginsDir, files)
226
- return files
227
- .map((f) => relative(root, f).split(sep).join('/'))
228
- .filter(isDefinitionPath)
229
- .sort()
230
- }
231
-
232
- // ── The check ──
233
-
234
- export function check(parsed: Map<string, ParsedDefinition>): Finding[] {
235
- const known = new Set<string>()
236
- for (const p of parsed.values()) for (const d of p.declarations) known.add(fold(d.token))
237
-
238
- const findings: Finding[] = []
239
- for (const [file, p] of parsed) {
240
- if (p.declarations.length === 0) continue
241
- const declared = new Set(p.declarations.map((d) => fold(d.token)))
242
- const proseWords = new Set([...p.proseWords].map(fold))
243
-
244
- const seen = new Set<string>()
245
- for (const d of p.declarations) {
246
- if (seen.has(d.token)) continue
247
- seen.add(d.token)
248
- const glossed = p.declarations.some((o) => o.token === d.token && o.glossed)
249
- if (!glossed && !hasSameField(proseWords, d.token)) {
250
- findings.push({ kind: 'unexplained', file, line: d.line, token: d.token })
251
- }
252
- }
253
-
254
- const reported = new Set<string>()
255
- for (const m of p.mandates) {
256
- if (!hasSameField(known, m.token)) continue
257
- const key = `${m.line}:${m.token}`
258
- if (reported.has(key)) continue
259
- if (!hasSameField(declared, m.token)) {
260
- reported.add(key)
261
- findings.push({ kind: 'undeclared', file, line: m.line, token: m.token })
262
- continue
263
- }
264
- // Declared here — but is any same-field declaration spelled in the mandate's case?
265
- const forms = sameFieldForms(m.token)
266
- const same = p.declarations.filter((d) => forms.includes(fold(d.token)))
267
- if (same.some((d) => isLower(d.token) === isLower(m.token))) continue
268
- reported.add(key)
269
- findings.push({ kind: 'miscased', file, line: m.line, token: m.token, declared: same[0]?.token })
270
- }
271
- }
272
- return findings.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1))
273
- }
274
-
275
- // ── Report ──
276
-
277
- const DETAIL: Record<FindingKind, string> = {
278
- unexplained: 'declared in a block, but no gloss and no prose explains it',
279
- undeclared: 'mandated in the prose, but no block in this file declares it',
280
- miscased: 'mandated in the prose, but this file declares it in the other case',
281
- }
282
-
283
- export function formatReport(definitions: string[], findings: Finding[]): string {
284
- if (definitions.length === 0) return 'check-field-mandates: no skill or agent definition found\n'
285
- if (findings.length === 0) return `check-field-mandates: ${definitions.length} definition(s) OK\n`
286
- const rows = findings
287
- .map(
288
- (f) =>
289
- ` ${f.kind.padEnd(11)} ${f.file}:${f.line} ${f.token} — ${DETAIL[f.kind]}${f.declared ? ` (${f.declared})` : ''}`,
290
- )
291
- .join('\n')
292
- return `${rows}\ncheck-field-mandates: ${findings.length} finding(s) across ${definitions.length} definition(s)\n`
293
- }
294
-
295
- // ── CLI ──
296
-
297
- function isReadableDirectory(path: string): boolean {
298
- try {
299
- if (!statSync(path).isDirectory()) return false
300
- readdirSync(path)
301
- return true
302
- } catch {
303
- return false
304
- }
305
- }
306
-
307
- export function main(argv: string[]): number {
308
- let root = '.'
309
- for (let i = 0; i < argv.length; i++) {
310
- const a = argv[i]
311
- if (a === '--root') {
312
- const next = argv[++i]
313
- if (next === undefined) {
314
- process.stderr.write('check-field-mandates: --root needs a directory\n')
315
- return 1
316
- }
317
- root = next
318
- continue
319
- }
320
- process.stderr.write(`check-field-mandates: unrecognized flag ${a}\n`)
321
- return 1
322
- }
323
- if (!isReadableDirectory(root)) {
324
- process.stderr.write(`check-field-mandates: root ${root} is not a readable directory\n`)
325
- return 1
326
- }
327
- const definitions = discoverDefinitions(root)
328
- const parsed = new Map<string, ParsedDefinition>()
329
- for (const rel of definitions) parsed.set(rel, parseDefinition(readFileSync(join(root, rel), 'utf8')))
330
- const findings = check(parsed)
331
- process.stdout.write(formatReport(definitions, findings))
332
- return findings.length === 0 ? 0 : 1
333
- }
334
-
335
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
336
- process.exit(main(process.argv.slice(2)))
337
- }