cyber-sdd 0.7.0 → 0.7.1

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