@blamejs/exceptd-skills 0.9.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 (136) hide show
  1. package/AGENTS.md +232 -0
  2. package/ARCHITECTURE.md +267 -0
  3. package/CHANGELOG.md +616 -0
  4. package/CONTEXT.md +203 -0
  5. package/LICENSE +200 -0
  6. package/NOTICE +82 -0
  7. package/README.md +307 -0
  8. package/SECURITY.md +73 -0
  9. package/agents/README.md +81 -0
  10. package/agents/report-generator.md +156 -0
  11. package/agents/skill-updater.md +102 -0
  12. package/agents/source-validator.md +119 -0
  13. package/agents/threat-researcher.md +149 -0
  14. package/bin/exceptd.js +183 -0
  15. package/data/_indexes/_meta.json +88 -0
  16. package/data/_indexes/activity-feed.json +362 -0
  17. package/data/_indexes/catalog-summaries.json +229 -0
  18. package/data/_indexes/chains.json +7135 -0
  19. package/data/_indexes/currency.json +359 -0
  20. package/data/_indexes/did-ladders.json +451 -0
  21. package/data/_indexes/frequency.json +2072 -0
  22. package/data/_indexes/handoff-dag.json +476 -0
  23. package/data/_indexes/jurisdiction-clocks.json +967 -0
  24. package/data/_indexes/jurisdiction-map.json +536 -0
  25. package/data/_indexes/recipes.json +319 -0
  26. package/data/_indexes/section-offsets.json +3656 -0
  27. package/data/_indexes/stale-content.json +14 -0
  28. package/data/_indexes/summary-cards.json +1736 -0
  29. package/data/_indexes/theater-fingerprints.json +381 -0
  30. package/data/_indexes/token-budget.json +2137 -0
  31. package/data/_indexes/trigger-table.json +1374 -0
  32. package/data/_indexes/xref.json +818 -0
  33. package/data/atlas-ttps.json +282 -0
  34. package/data/cve-catalog.json +496 -0
  35. package/data/cwe-catalog.json +1017 -0
  36. package/data/d3fend-catalog.json +738 -0
  37. package/data/dlp-controls.json +1039 -0
  38. package/data/exploit-availability.json +67 -0
  39. package/data/framework-control-gaps.json +1255 -0
  40. package/data/global-frameworks.json +2913 -0
  41. package/data/rfc-references.json +324 -0
  42. package/data/zeroday-lessons.json +377 -0
  43. package/keys/public.pem +3 -0
  44. package/lib/framework-gap.js +328 -0
  45. package/lib/job-queue.js +195 -0
  46. package/lib/lint-skills.js +536 -0
  47. package/lib/prefetch.js +372 -0
  48. package/lib/refresh-external.js +713 -0
  49. package/lib/schemas/cve-catalog.schema.json +151 -0
  50. package/lib/schemas/manifest.schema.json +106 -0
  51. package/lib/schemas/skill-frontmatter.schema.json +113 -0
  52. package/lib/scoring.js +149 -0
  53. package/lib/sign.js +197 -0
  54. package/lib/ttp-mapper.js +80 -0
  55. package/lib/validate-catalog-meta.js +198 -0
  56. package/lib/validate-cve-catalog.js +213 -0
  57. package/lib/validate-indexes.js +83 -0
  58. package/lib/validate-package.js +162 -0
  59. package/lib/validate-vendor.js +85 -0
  60. package/lib/verify.js +216 -0
  61. package/lib/worker-pool.js +84 -0
  62. package/manifest-snapshot.json +1833 -0
  63. package/manifest.json +2108 -0
  64. package/orchestrator/README.md +124 -0
  65. package/orchestrator/dispatcher.js +140 -0
  66. package/orchestrator/event-bus.js +146 -0
  67. package/orchestrator/index.js +874 -0
  68. package/orchestrator/pipeline.js +201 -0
  69. package/orchestrator/scanner.js +327 -0
  70. package/orchestrator/scheduler.js +137 -0
  71. package/package.json +113 -0
  72. package/sbom.cdx.json +158 -0
  73. package/scripts/audit-cross-skill.js +261 -0
  74. package/scripts/audit-perf.js +160 -0
  75. package/scripts/bootstrap.js +205 -0
  76. package/scripts/build-indexes.js +721 -0
  77. package/scripts/builders/activity-feed.js +79 -0
  78. package/scripts/builders/catalog-summaries.js +67 -0
  79. package/scripts/builders/currency.js +109 -0
  80. package/scripts/builders/cwe-chains.js +105 -0
  81. package/scripts/builders/did-ladders.js +149 -0
  82. package/scripts/builders/frequency.js +89 -0
  83. package/scripts/builders/jurisdiction-clocks.js +126 -0
  84. package/scripts/builders/recipes.js +159 -0
  85. package/scripts/builders/section-offsets.js +162 -0
  86. package/scripts/builders/stale-content.js +171 -0
  87. package/scripts/builders/summary-cards.js +166 -0
  88. package/scripts/builders/theater-fingerprints.js +198 -0
  89. package/scripts/builders/token-budget.js +96 -0
  90. package/scripts/check-manifest-snapshot.js +217 -0
  91. package/scripts/predeploy.js +267 -0
  92. package/scripts/refresh-manifest-snapshot.js +57 -0
  93. package/scripts/refresh-sbom.js +222 -0
  94. package/skills/age-gates-child-safety/skill.md +456 -0
  95. package/skills/ai-attack-surface/skill.md +282 -0
  96. package/skills/ai-c2-detection/skill.md +440 -0
  97. package/skills/ai-risk-management/skill.md +311 -0
  98. package/skills/api-security/skill.md +287 -0
  99. package/skills/attack-surface-pentest/skill.md +381 -0
  100. package/skills/cloud-security/skill.md +384 -0
  101. package/skills/compliance-theater/skill.md +365 -0
  102. package/skills/container-runtime-security/skill.md +379 -0
  103. package/skills/coordinated-vuln-disclosure/skill.md +473 -0
  104. package/skills/defensive-countermeasure-mapping/skill.md +300 -0
  105. package/skills/dlp-gap-analysis/skill.md +337 -0
  106. package/skills/email-security-anti-phishing/skill.md +206 -0
  107. package/skills/exploit-scoring/skill.md +331 -0
  108. package/skills/framework-gap-analysis/skill.md +374 -0
  109. package/skills/fuzz-testing-strategy/skill.md +313 -0
  110. package/skills/global-grc/skill.md +564 -0
  111. package/skills/identity-assurance/skill.md +272 -0
  112. package/skills/incident-response-playbook/skill.md +546 -0
  113. package/skills/kernel-lpe-triage/skill.md +303 -0
  114. package/skills/mcp-agent-trust/skill.md +326 -0
  115. package/skills/mlops-security/skill.md +325 -0
  116. package/skills/ot-ics-security/skill.md +340 -0
  117. package/skills/policy-exception-gen/skill.md +437 -0
  118. package/skills/pqc-first/skill.md +546 -0
  119. package/skills/rag-pipeline-security/skill.md +294 -0
  120. package/skills/researcher/skill.md +310 -0
  121. package/skills/sector-energy/skill.md +409 -0
  122. package/skills/sector-federal-government/skill.md +302 -0
  123. package/skills/sector-financial/skill.md +398 -0
  124. package/skills/sector-healthcare/skill.md +373 -0
  125. package/skills/security-maturity-tiers/skill.md +464 -0
  126. package/skills/skill-update-loop/skill.md +463 -0
  127. package/skills/supply-chain-integrity/skill.md +318 -0
  128. package/skills/threat-model-currency/skill.md +404 -0
  129. package/skills/threat-modeling-methodology/skill.md +312 -0
  130. package/skills/webapp-security/skill.md +281 -0
  131. package/skills/zeroday-gap-learn/skill.md +350 -0
  132. package/vendor/blamejs/LICENSE +201 -0
  133. package/vendor/blamejs/README.md +54 -0
  134. package/vendor/blamejs/_PROVENANCE.json +54 -0
  135. package/vendor/blamejs/retry.js +335 -0
  136. package/vendor/blamejs/worker-pool.js +418 -0
@@ -0,0 +1,536 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * lib/lint-skills.js — exceptd skill pre-ship linter.
4
+ *
5
+ * Enforces AGENTS.md rules that are otherwise informal:
6
+ * Rule #10 — No placeholder data. Skill bodies and frontmatter must not
7
+ * contain TODO / TBD / coming soon / placeholder / fixme / XXX
8
+ * / to be determined.
9
+ * Rule #11 — No-MVP ban. Every skill ships with complete frontmatter,
10
+ * all 7 required body sections, all data deps existing, and
11
+ * all referenced TTPs / framework controls resolving.
12
+ *
13
+ * For every skill registered in manifest.json this linter checks:
14
+ * - skill.md exists at the manifest path
15
+ * - frontmatter contains every required field per AGENTS.md spec
16
+ * - frontmatter values conform to lib/schemas/skill-frontmatter.schema.json
17
+ * (the subset relevant for this codebase — no external validator dep)
18
+ * - body contains all 7 required H2/H3 sections (case-insensitive):
19
+ * Threat Context, Framework Lag Declaration, TTP Mapping,
20
+ * Exploit Availability Matrix, Analysis Procedure, Output Format,
21
+ * Compliance Theater Check
22
+ * - body and frontmatter free of placeholder language
23
+ * - every data_deps filename resolves to data/<filename>
24
+ * - every atlas_refs ID exists as a top-level key in data/atlas-ttps.json
25
+ * - every framework_gaps ID exists as a top-level key in
26
+ * data/framework-control-gaps.json
27
+ *
28
+ * Usage:
29
+ * node lib/lint-skills.js lint every skill
30
+ * node lib/lint-skills.js --skill foo lint only the named skill
31
+ * node lib/lint-skills.js --quiet only print failures and final summary
32
+ *
33
+ * Exit code: 0 if every linted skill passes, 1 otherwise.
34
+ *
35
+ * No external dependencies. Node 24 stdlib only.
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ const fs = require('node:fs');
41
+ const path = require('node:path');
42
+ const process = require('node:process');
43
+
44
+ const REPO_ROOT = path.resolve(__dirname, '..');
45
+ const MANIFEST_PATH = path.join(REPO_ROOT, 'manifest.json');
46
+ const DATA_DIR = path.join(REPO_ROOT, 'data');
47
+ const ATLAS_PATH = path.join(DATA_DIR, 'atlas-ttps.json');
48
+ const FRAMEWORK_GAPS_PATH = path.join(DATA_DIR, 'framework-control-gaps.json');
49
+ const RFC_REFS_PATH = path.join(DATA_DIR, 'rfc-references.json');
50
+ const CWE_REFS_PATH = path.join(DATA_DIR, 'cwe-catalog.json');
51
+ const D3FEND_REFS_PATH = path.join(DATA_DIR, 'd3fend-catalog.json');
52
+ const DLP_REFS_PATH = path.join(DATA_DIR, 'dlp-controls.json');
53
+
54
+ const REQUIRED_FRONTMATTER_FIELDS = [
55
+ 'name',
56
+ 'version',
57
+ 'description',
58
+ 'triggers',
59
+ 'data_deps',
60
+ 'atlas_refs',
61
+ 'attack_refs',
62
+ 'framework_gaps',
63
+ 'last_threat_review',
64
+ ];
65
+
66
+ const OPTIONAL_FRONTMATTER_FIELDS = ['forward_watch', 'rfc_refs', 'cwe_refs', 'd3fend_refs', 'dlp_refs'];
67
+
68
+ const ALL_KNOWN_FIELDS = new Set([
69
+ ...REQUIRED_FRONTMATTER_FIELDS,
70
+ ...OPTIONAL_FRONTMATTER_FIELDS,
71
+ ]);
72
+
73
+ const REQUIRED_SECTIONS = [
74
+ 'Threat Context',
75
+ 'Framework Lag Declaration',
76
+ 'TTP Mapping',
77
+ 'Exploit Availability Matrix',
78
+ 'Analysis Procedure',
79
+ 'Output Format',
80
+ 'Compliance Theater Check',
81
+ ];
82
+
83
+ const PLACEHOLDER_PATTERNS = [
84
+ /\bTODO\b/i,
85
+ /\bTBD\b/i,
86
+ /\bcoming soon\b/i,
87
+ /\bplaceholder\b/i,
88
+ /\bto be determined\b/i,
89
+ /\bFIXME\b/i,
90
+ /\bXXX\b/,
91
+ ];
92
+
93
+ const ATLAS_ID_RE = /^AML\.T\d{4}(\.\d{3})?$/;
94
+ const ATTACK_ID_RE = /^T\d{4}(\.\d{3})?$/;
95
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
96
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
97
+ const KEBAB_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
98
+ const JSON_FILENAME_RE = /^[A-Za-z0-9._-]+\.json$/;
99
+
100
+ function parseArgs(argv) {
101
+ const opts = { skill: null, quiet: false };
102
+ for (let i = 2; i < argv.length; i++) {
103
+ const a = argv[i];
104
+ if (a === '--skill') {
105
+ opts.skill = argv[++i] || null;
106
+ } else if (a.startsWith('--skill=')) {
107
+ opts.skill = a.slice('--skill='.length);
108
+ } else if (a === '--quiet' || a === '-q') {
109
+ opts.quiet = true;
110
+ } else if (a === '--help' || a === '-h') {
111
+ printHelp();
112
+ process.exit(0);
113
+ } else {
114
+ console.error(`Unknown argument: ${a}`);
115
+ printHelp();
116
+ process.exit(2);
117
+ }
118
+ }
119
+ return opts;
120
+ }
121
+
122
+ function printHelp() {
123
+ console.log(
124
+ 'Usage: node lib/lint-skills.js [--skill <name>] [--quiet]\n' +
125
+ '\n' +
126
+ ' --skill <name> Lint only the named skill from manifest.json.\n' +
127
+ ' --quiet Suppress per-skill PASS output; show failures only.\n' +
128
+ ' --help Show this message.\n',
129
+ );
130
+ }
131
+
132
+ function readJson(p) {
133
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
134
+ }
135
+
136
+ /*
137
+ * Minimal YAML frontmatter parser. Supports the subset actually used in this
138
+ * repo:
139
+ * key: "quoted string"
140
+ * key: bare-string
141
+ * key: [] (empty list)
142
+ * key:
143
+ * - item one
144
+ * - "item two"
145
+ * Anything outside this shape produces a parse error so we don't silently
146
+ * accept malformed frontmatter.
147
+ */
148
+ function parseFrontmatter(text) {
149
+ const lines = text.split(/\r?\n/);
150
+ const result = {};
151
+ let i = 0;
152
+ while (i < lines.length) {
153
+ const raw = lines[i];
154
+ if (raw.trim() === '' || raw.trimStart().startsWith('#')) {
155
+ i++;
156
+ continue;
157
+ }
158
+ if (!/^\S/.test(raw)) {
159
+ throw new Error(
160
+ `Unexpected indented line at frontmatter top level (line ${i + 1}): ${raw}`,
161
+ );
162
+ }
163
+ const m = raw.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/);
164
+ if (!m) {
165
+ throw new Error(`Could not parse frontmatter line ${i + 1}: ${raw}`);
166
+ }
167
+ const key = m[1];
168
+ const rest = m[2];
169
+ if (rest === '' || rest === undefined) {
170
+ const items = [];
171
+ i++;
172
+ while (i < lines.length && /^\s+-\s+/.test(lines[i])) {
173
+ const itemMatch = lines[i].match(/^\s+-\s+(.*)$/);
174
+ items.push(unquote(itemMatch[1].trim()));
175
+ i++;
176
+ }
177
+ result[key] = items;
178
+ continue;
179
+ }
180
+ if (rest.trim() === '[]') {
181
+ result[key] = [];
182
+ i++;
183
+ continue;
184
+ }
185
+ result[key] = unquote(rest.trim());
186
+ i++;
187
+ }
188
+ return result;
189
+ }
190
+
191
+ function unquote(s) {
192
+ if (s.length >= 2) {
193
+ const first = s[0];
194
+ const last = s[s.length - 1];
195
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
196
+ return s.slice(1, -1);
197
+ }
198
+ }
199
+ return s;
200
+ }
201
+
202
+ function extractFrontmatterBlock(content) {
203
+ if (!content.startsWith('---')) {
204
+ return { frontmatter: null, body: content, frontmatterRaw: '' };
205
+ }
206
+ const rest = content.slice(3);
207
+ const endIdx = rest.indexOf('\n---');
208
+ if (endIdx === -1) {
209
+ return { frontmatter: null, body: content, frontmatterRaw: '' };
210
+ }
211
+ const raw = rest.slice(0, endIdx);
212
+ const afterClose = rest.slice(endIdx + '\n---'.length);
213
+ const bodyStart = afterClose.replace(/^\r?\n/, '');
214
+ return { frontmatter: raw.replace(/^\r?\n/, ''), body: bodyStart, frontmatterRaw: raw };
215
+ }
216
+
217
+ /* Validate frontmatter object against the codified schema rules. */
218
+ function validateFrontmatter(fm, skillName) {
219
+ const errors = [];
220
+
221
+ for (const key of Object.keys(fm)) {
222
+ if (!ALL_KNOWN_FIELDS.has(key)) {
223
+ errors.push(`frontmatter: unknown field "${key}"`);
224
+ }
225
+ }
226
+ for (const field of REQUIRED_FRONTMATTER_FIELDS) {
227
+ if (!(field in fm)) {
228
+ errors.push(`frontmatter: missing required field "${field}"`);
229
+ }
230
+ }
231
+
232
+ if (typeof fm.name === 'string') {
233
+ if (!KEBAB_RE.test(fm.name)) {
234
+ errors.push(`frontmatter.name "${fm.name}" is not lowercase kebab-case`);
235
+ }
236
+ if (skillName && fm.name !== skillName) {
237
+ errors.push(
238
+ `frontmatter.name "${fm.name}" does not match manifest skill name "${skillName}"`,
239
+ );
240
+ }
241
+ }
242
+
243
+ if (typeof fm.version === 'string') {
244
+ if (!SEMVER_RE.test(fm.version)) {
245
+ errors.push(`frontmatter.version "${fm.version}" is not semver (x.y.z)`);
246
+ }
247
+ }
248
+
249
+ if (typeof fm.description === 'string') {
250
+ if (fm.description.length < 10) {
251
+ errors.push('frontmatter.description is shorter than 10 characters');
252
+ }
253
+ } else if ('description' in fm) {
254
+ errors.push('frontmatter.description must be a string');
255
+ }
256
+
257
+ if ('triggers' in fm) {
258
+ if (!Array.isArray(fm.triggers) || fm.triggers.length === 0) {
259
+ errors.push('frontmatter.triggers must be a non-empty list');
260
+ } else {
261
+ for (const t of fm.triggers) {
262
+ if (typeof t !== 'string' || t.length === 0) {
263
+ errors.push(`frontmatter.triggers contains a non-string or empty entry: ${JSON.stringify(t)}`);
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ if ('data_deps' in fm) {
270
+ if (!Array.isArray(fm.data_deps)) {
271
+ errors.push('frontmatter.data_deps must be a list');
272
+ } else {
273
+ for (const d of fm.data_deps) {
274
+ if (typeof d !== 'string' || !JSON_FILENAME_RE.test(d)) {
275
+ errors.push(`frontmatter.data_deps entry is not a *.json filename: ${JSON.stringify(d)}`);
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ if ('atlas_refs' in fm) {
282
+ if (!Array.isArray(fm.atlas_refs)) {
283
+ errors.push('frontmatter.atlas_refs must be a list');
284
+ } else {
285
+ for (const a of fm.atlas_refs) {
286
+ if (typeof a !== 'string' || !ATLAS_ID_RE.test(a)) {
287
+ errors.push(`frontmatter.atlas_refs entry is not a valid ATLAS ID: ${JSON.stringify(a)}`);
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ if ('attack_refs' in fm) {
294
+ if (!Array.isArray(fm.attack_refs)) {
295
+ errors.push('frontmatter.attack_refs must be a list');
296
+ } else {
297
+ for (const a of fm.attack_refs) {
298
+ if (typeof a !== 'string' || !ATTACK_ID_RE.test(a)) {
299
+ errors.push(`frontmatter.attack_refs entry is not a valid ATT&CK ID: ${JSON.stringify(a)}`);
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ if ('framework_gaps' in fm) {
306
+ if (!Array.isArray(fm.framework_gaps)) {
307
+ errors.push('frontmatter.framework_gaps must be a list');
308
+ } else {
309
+ for (const f of fm.framework_gaps) {
310
+ if (typeof f !== 'string' || f.length === 0) {
311
+ errors.push(`frontmatter.framework_gaps entry is empty or non-string: ${JSON.stringify(f)}`);
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ if ('forward_watch' in fm) {
318
+ if (!Array.isArray(fm.forward_watch)) {
319
+ errors.push('frontmatter.forward_watch must be a list');
320
+ }
321
+ }
322
+
323
+ if ('last_threat_review' in fm) {
324
+ if (typeof fm.last_threat_review !== 'string' || !ISO_DATE_RE.test(fm.last_threat_review)) {
325
+ errors.push(
326
+ `frontmatter.last_threat_review "${fm.last_threat_review}" is not an ISO date (YYYY-MM-DD)`,
327
+ );
328
+ }
329
+ }
330
+
331
+ return errors;
332
+ }
333
+
334
+ function findMissingSections(body) {
335
+ const lower = body.toLowerCase();
336
+ const missing = [];
337
+ for (const section of REQUIRED_SECTIONS) {
338
+ if (!lower.includes(section.toLowerCase())) {
339
+ missing.push(section);
340
+ }
341
+ }
342
+ return missing;
343
+ }
344
+
345
+ function findPlaceholders(text) {
346
+ const hits = [];
347
+ const lines = text.split(/\r?\n/);
348
+ for (let i = 0; i < lines.length; i++) {
349
+ for (const re of PLACEHOLDER_PATTERNS) {
350
+ if (re.test(lines[i])) {
351
+ hits.push({ line: i + 1, text: lines[i].trim(), pattern: re.source });
352
+ break;
353
+ }
354
+ }
355
+ }
356
+ return hits;
357
+ }
358
+
359
+ function lintSkill(entry, ctx) {
360
+ const skillErrors = [];
361
+ const skillPath = path.join(REPO_ROOT, entry.path);
362
+
363
+ if (!fs.existsSync(skillPath)) {
364
+ return { name: entry.name, errors: [`skill file not found at ${entry.path}`] };
365
+ }
366
+
367
+ const content = fs.readFileSync(skillPath, 'utf8');
368
+ const { frontmatter: fmRaw, body } = extractFrontmatterBlock(content);
369
+ if (fmRaw === null) {
370
+ skillErrors.push('skill.md does not start with a `---` YAML frontmatter block');
371
+ return { name: entry.name, errors: skillErrors };
372
+ }
373
+
374
+ let fm;
375
+ try {
376
+ fm = parseFrontmatter(fmRaw);
377
+ } catch (err) {
378
+ skillErrors.push(`frontmatter parse error: ${err.message}`);
379
+ return { name: entry.name, errors: skillErrors };
380
+ }
381
+
382
+ skillErrors.push(...validateFrontmatter(fm, entry.name));
383
+
384
+ if (Array.isArray(fm.data_deps)) {
385
+ for (const dep of fm.data_deps) {
386
+ const depPath = path.join(DATA_DIR, dep);
387
+ if (!fs.existsSync(depPath)) {
388
+ skillErrors.push(`data_deps: file not found at data/${dep}`);
389
+ }
390
+ }
391
+ }
392
+
393
+ if (Array.isArray(fm.atlas_refs)) {
394
+ for (const ref of fm.atlas_refs) {
395
+ if (!ctx.atlasKeys.has(ref)) {
396
+ skillErrors.push(`atlas_refs: "${ref}" not present in data/atlas-ttps.json`);
397
+ }
398
+ }
399
+ }
400
+
401
+ if (Array.isArray(fm.framework_gaps)) {
402
+ for (const ref of fm.framework_gaps) {
403
+ if (!ctx.frameworkKeys.has(ref)) {
404
+ skillErrors.push(
405
+ `framework_gaps: "${ref}" not present in data/framework-control-gaps.json`,
406
+ );
407
+ }
408
+ }
409
+ }
410
+
411
+ if (Array.isArray(fm.rfc_refs)) {
412
+ for (const ref of fm.rfc_refs) {
413
+ if (!ctx.rfcKeys.has(ref)) {
414
+ skillErrors.push(
415
+ `rfc_refs: "${ref}" not present in data/rfc-references.json`,
416
+ );
417
+ }
418
+ }
419
+ }
420
+
421
+ if (Array.isArray(fm.cwe_refs)) {
422
+ for (const ref of fm.cwe_refs) {
423
+ if (!ctx.cweKeys.has(ref)) {
424
+ skillErrors.push(
425
+ `cwe_refs: "${ref}" not present in data/cwe-catalog.json`,
426
+ );
427
+ }
428
+ }
429
+ }
430
+
431
+ if (Array.isArray(fm.d3fend_refs)) {
432
+ for (const ref of fm.d3fend_refs) {
433
+ if (!ctx.d3fendKeys.has(ref)) {
434
+ skillErrors.push(
435
+ `d3fend_refs: "${ref}" not present in data/d3fend-catalog.json`,
436
+ );
437
+ }
438
+ }
439
+ }
440
+
441
+ if (Array.isArray(fm.dlp_refs)) {
442
+ for (const ref of fm.dlp_refs) {
443
+ if (!ctx.dlpKeys.has(ref)) {
444
+ skillErrors.push(
445
+ `dlp_refs: "${ref}" not present in data/dlp-controls.json`,
446
+ );
447
+ }
448
+ }
449
+ }
450
+
451
+ const missingSections = findMissingSections(body);
452
+ for (const s of missingSections) {
453
+ skillErrors.push(`body: missing required section "${s}"`);
454
+ }
455
+
456
+ const placeholders = findPlaceholders(content);
457
+ for (const p of placeholders) {
458
+ skillErrors.push(`placeholder language at line ${p.line} (pattern /${p.pattern}/): ${p.text}`);
459
+ }
460
+
461
+ return { name: entry.name, errors: skillErrors };
462
+ }
463
+
464
+ function loadContext() {
465
+ const atlas = readJson(ATLAS_PATH);
466
+ const frameworks = readJson(FRAMEWORK_GAPS_PATH);
467
+ const atlasKeys = new Set(Object.keys(atlas).filter((k) => !k.startsWith('_')));
468
+ const frameworkKeys = new Set(Object.keys(frameworks).filter((k) => !k.startsWith('_')));
469
+ // Optional catalogs — load if present, otherwise treat as empty.
470
+ function loadKeys(p) {
471
+ const s = new Set();
472
+ if (fs.existsSync(p)) {
473
+ const j = readJson(p);
474
+ for (const k of Object.keys(j)) if (!k.startsWith('_')) s.add(k);
475
+ }
476
+ return s;
477
+ }
478
+ return {
479
+ atlasKeys,
480
+ frameworkKeys,
481
+ rfcKeys: loadKeys(RFC_REFS_PATH),
482
+ cweKeys: loadKeys(CWE_REFS_PATH),
483
+ d3fendKeys: loadKeys(D3FEND_REFS_PATH),
484
+ dlpKeys: loadKeys(DLP_REFS_PATH),
485
+ };
486
+ }
487
+
488
+ function main() {
489
+ const opts = parseArgs(process.argv);
490
+ const manifest = readJson(MANIFEST_PATH);
491
+
492
+ let skills = manifest.skills;
493
+ if (opts.skill) {
494
+ skills = skills.filter((s) => s.name === opts.skill);
495
+ if (skills.length === 0) {
496
+ console.error(`No skill named "${opts.skill}" in manifest.json`);
497
+ process.exit(2);
498
+ }
499
+ }
500
+
501
+ const ctx = loadContext();
502
+
503
+ const results = skills.map((entry) => lintSkill(entry, ctx));
504
+
505
+ let failed = 0;
506
+ for (const r of results) {
507
+ if (r.errors.length === 0) {
508
+ if (!opts.quiet) {
509
+ console.log(`PASS ${r.name}`);
510
+ }
511
+ } else {
512
+ failed++;
513
+ console.log(`FAIL ${r.name}`);
514
+ for (const e of r.errors) {
515
+ console.log(` - ${e}`);
516
+ }
517
+ }
518
+ }
519
+
520
+ const total = results.length;
521
+ const passed = total - failed;
522
+ console.log(`\n${passed}/${total} skills passed${failed ? `, ${failed} failed` : ''}.`);
523
+ process.exit(failed === 0 ? 0 : 1);
524
+ }
525
+
526
+ // Export the minimal frontmatter parser for downstream consumers
527
+ // (e.g., orchestrator `watchlist` command) so they don't reinvent it.
528
+ module.exports = {
529
+ parseFrontmatter,
530
+ extractFrontmatterBlock,
531
+ unquote,
532
+ };
533
+
534
+ if (require.main === module) {
535
+ main();
536
+ }