@blamejs/exceptd-skills 0.13.1 → 0.13.3

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 (41) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/bin/exceptd.js +140 -7
  3. package/data/_indexes/_meta.json +28 -28
  4. package/data/_indexes/activity-feed.json +3 -3
  5. package/data/_indexes/catalog-summaries.json +3 -3
  6. package/data/_indexes/chains.json +1897 -88
  7. package/data/_indexes/frequency.json +20 -0
  8. package/data/_indexes/section-offsets.json +574 -574
  9. package/data/_indexes/token-budget.json +97 -97
  10. package/data/atlas-ttps.json +2 -0
  11. package/data/attack-techniques.json +24 -3
  12. package/data/cve-catalog.json +96 -29
  13. package/data/cwe-catalog.json +20 -3
  14. package/data/framework-control-gaps.json +700 -1
  15. package/data/zeroday-lessons.json +889 -0
  16. package/lib/lint-skills.js +54 -1
  17. package/lib/source-advisories.js +26 -0
  18. package/manifest.json +62 -62
  19. package/orchestrator/index.js +155 -3
  20. package/package.json +1 -1
  21. package/sbom.cdx.json +50 -39
  22. package/scripts/check-test-count.js +146 -0
  23. package/scripts/predeploy.js +16 -0
  24. package/skills/age-gates-child-safety/skill.md +1 -0
  25. package/skills/ai-risk-management/skill.md +1 -0
  26. package/skills/api-security/skill.md +14 -4
  27. package/skills/cloud-iam-incident/skill.md +1 -1
  28. package/skills/defensive-countermeasure-mapping/skill.md +1 -0
  29. package/skills/email-security-anti-phishing/skill.md +15 -4
  30. package/skills/fuzz-testing-strategy/skill.md +1 -0
  31. package/skills/mlops-security/skill.md +1 -0
  32. package/skills/ot-ics-security/skill.md +1 -0
  33. package/skills/researcher/skill.md +1 -0
  34. package/skills/sector-energy/skill.md +1 -0
  35. package/skills/sector-federal-government/skill.md +1 -0
  36. package/skills/sector-telecom/skill.md +1 -0
  37. package/skills/skill-update-loop/skill.md +1 -0
  38. package/skills/threat-model-currency/skill.md +1 -0
  39. package/skills/threat-modeling-methodology/skill.md +1 -0
  40. package/skills/webapp-security/skill.md +1 -0
  41. package/skills/zeroday-gap-learn/skill.md +1 -0
@@ -379,11 +379,18 @@ async function runReport(format) {
379
379
  // (GENERIC_FAILURE) not 2 (DETECTED_ESCALATE). Pre-v0.13 the
380
380
  // body went to stderr and exit was 2; both broke CI consumers
381
381
  // that expected the dispatch-error vs verb-finding distinction.
382
+ // v0.13.2: did-you-mean on the unknown format value (reuses
383
+ // lib/flag-suggest.js for Levenshtein-≤2 typo correction).
384
+ const { suggestFlag } = require('../lib/flag-suggest');
385
+ const dym = suggestFlag(String(format), VALID_REPORT_FORMATS);
386
+ const hint = dym ? ` Did you mean "${dym}"?` : '';
382
387
  process.stdout.write(JSON.stringify({
383
388
  ok: false,
384
389
  verb: 'report',
385
- error: `report: format "${format}" not in accepted set ${JSON.stringify(VALID_REPORT_FORMATS)}.`,
390
+ error: `report: format "${format}" not in accepted set ${JSON.stringify(VALID_REPORT_FORMATS)}.${hint}`,
391
+ provided: format,
386
392
  accepted_formats: VALID_REPORT_FORMATS,
393
+ did_you_mean: dym ? [dym] : [],
387
394
  }) + '\n');
388
395
  safeExit(EXIT_CODES.GENERIC_FAILURE);
389
396
  return;
@@ -1086,6 +1093,7 @@ function runWatchlist(rawArgs = []) {
1086
1093
 
1087
1094
  const byskill = rawArgs.includes('--by-skill');
1088
1095
  const alertsMode = rawArgs.includes('--alerts');
1096
+ const orgScanMode = rawArgs.includes('--org-scan');
1089
1097
  const manifestPath = path.join(__dirname, '..', 'manifest.json');
1090
1098
  const repoRoot = path.join(__dirname, '..');
1091
1099
 
@@ -1093,12 +1101,22 @@ function runWatchlist(rawArgs = []) {
1093
1101
  // "CVE-class alert patterns" — surfaces catalog entries matching
1094
1102
  // high-priority shape rules (kernel-LPE-with-PoC, supply-chain-family,
1095
1103
  // AI-discovered-KEV, recently-disclosed-with-active-exploitation).
1096
- // The two modes are mutually exclusive; --alerts short-circuits the
1097
- // forward-watch aggregation.
1104
+ // The modes are mutually exclusive; the first matching flag wins.
1098
1105
  if (alertsMode) {
1099
1106
  return runWatchlistAlerts(rawArgs);
1100
1107
  }
1101
1108
 
1109
+ // v0.13.3: --org-scan probes GitHub for repository naming patterns
1110
+ // associated with known threat actors (per NEW-CTRL-052 from the
1111
+ // MAL-2026-SHAI-HULUD-OSS zeroday-lessons entry). The Shai-Hulud
1112
+ // worm uses GitHub itself as the exfil channel — repos named
1113
+ // "A Gift From TeamPCP", "Shai-Hulud-*", or with future variants
1114
+ // hold the stolen credentials. Operators can run this against
1115
+ // their own GitHub org to surface exfil staging in their tenant.
1116
+ if (orgScanMode) {
1117
+ return runWatchlistOrgScan(rawArgs);
1118
+ }
1119
+
1102
1120
  let manifest;
1103
1121
  try {
1104
1122
  manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
@@ -1582,6 +1600,140 @@ Examples:
1582
1600
  `);
1583
1601
  }
1584
1602
 
1603
+ /**
1604
+ * v0.13.3 — runWatchlistOrgScan: GitHub repo-pattern monitoring per
1605
+ * NEW-CTRL-052 (MAL-2026-SHAI-HULUD-OSS zeroday-lessons). The Shai-Hulud
1606
+ * worm uses GitHub itself as the exfil channel; the canonical naming
1607
+ * pattern is "A Gift From TeamPCP", commit-timestamps falsified to
1608
+ * 2099-01-01, and contributor accounts agwagwagwa / headdirt / tmechen.
1609
+ *
1610
+ * Operators run this against their GitHub org (--org <login> or
1611
+ * GITHUB_ORG env var) to surface exfil staging within their tenant.
1612
+ * Uses the GitHub Search API (unauthenticated for public-repo search;
1613
+ * GITHUB_TOKEN env var lifts the rate limit + enables private-repo
1614
+ * coverage). Report-only — never modifies repos.
1615
+ *
1616
+ * Flags / env:
1617
+ * --org <login> GitHub org / user to scope the search to (required)
1618
+ * --pattern <s> (repeatable) additional naming-pattern strings (defaults below)
1619
+ * --json structured JSON output
1620
+ * GITHUB_TOKEN env var lifts rate limit + private-repo search coverage
1621
+ */
1622
+ async function runWatchlistOrgScan(rawArgs = []) {
1623
+ const jsonOut = rawArgs.includes('--json');
1624
+ // Extract --org <login>. Accept --org=foo too.
1625
+ let org = null;
1626
+ for (let i = 0; i < rawArgs.length; i++) {
1627
+ if (rawArgs[i] === '--org' && rawArgs[i + 1]) { org = rawArgs[i + 1]; break; }
1628
+ if (rawArgs[i].startsWith('--org=')) { org = rawArgs[i].slice('--org='.length); break; }
1629
+ }
1630
+ if (!org) org = process.env.GITHUB_ORG || null;
1631
+ if (!org) {
1632
+ process.stdout.write(JSON.stringify({
1633
+ ok: false,
1634
+ verb: 'watchlist',
1635
+ mode: 'org-scan',
1636
+ error: 'watchlist --org-scan requires --org <login> (or GITHUB_ORG env var). Example: exceptd watchlist --org-scan --org blamejs',
1637
+ }) + '\n');
1638
+ safeExit(EXIT_CODES.GENERIC_FAILURE);
1639
+ return;
1640
+ }
1641
+ // Custom patterns via --pattern <s> (repeatable). Default set from
1642
+ // the MAL-2026-SHAI-HULUD-OSS catalog entry + NEW-CTRL-052 evidence.
1643
+ const customPatterns = [];
1644
+ for (let i = 0; i < rawArgs.length; i++) {
1645
+ if (rawArgs[i] === '--pattern' && rawArgs[i + 1]) customPatterns.push(rawArgs[i + 1]);
1646
+ if (rawArgs[i].startsWith('--pattern=')) customPatterns.push(rawArgs[i].slice('--pattern='.length));
1647
+ }
1648
+ const DEFAULT_PATTERNS = [
1649
+ { id: 'shai-hulud-classic', q: 'Shai-Hulud', severity: 'critical', source: 'MAL-2026-SHAI-HULUD-OSS (pre-source-release naming)' },
1650
+ { id: 'teampcp-gift', q: 'A Gift From TeamPCP', severity: 'critical', source: 'MAL-2026-SHAI-HULUD-OSS (post-2026-05-12 release naming)' },
1651
+ { id: 'teampcp-bare', q: 'TeamPCP', severity: 'high', source: 'MAL-2026-SHAI-HULUD-OSS threat actor reference' },
1652
+ ];
1653
+ const patterns = [
1654
+ ...DEFAULT_PATTERNS,
1655
+ ...customPatterns.map((q, i) => ({ id: `custom-${i + 1}`, q, severity: 'medium', source: 'operator --pattern' })),
1656
+ ];
1657
+
1658
+ // GitHub Search API: GET /search/code?q=<query>+org:<login>
1659
+ // and GET /search/repositories?q=<query>+org:<login>. Both return
1660
+ // the same shape (items[] with name, html_url, owner, created_at).
1661
+ const token = process.env.GITHUB_TOKEN || '';
1662
+ const matches = [];
1663
+ let rateLimited = false;
1664
+ let unauth = !token;
1665
+ if (typeof fetch !== 'function') {
1666
+ process.stdout.write(JSON.stringify({
1667
+ ok: false,
1668
+ verb: 'watchlist',
1669
+ mode: 'org-scan',
1670
+ error: 'fetch() not available — Node 18+ required.',
1671
+ }) + '\n');
1672
+ safeExit(EXIT_CODES.GENERIC_FAILURE);
1673
+ return;
1674
+ }
1675
+ for (const p of patterns) {
1676
+ const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(p.q + ' org:' + org)}&per_page=100`;
1677
+ const headers = { 'User-Agent': 'exceptd-watchlist-org-scan/0.13.3 (+https://exceptd.com)', 'Accept': 'application/vnd.github+json' };
1678
+ if (token) headers.Authorization = `Bearer ${token}`;
1679
+ try {
1680
+ const ac = new AbortController();
1681
+ const timer = setTimeout(() => ac.abort(), 10000);
1682
+ const r = await fetch(url, { headers, signal: ac.signal });
1683
+ clearTimeout(timer);
1684
+ if (r.status === 403 || r.status === 429) {
1685
+ rateLimited = true;
1686
+ continue;
1687
+ }
1688
+ if (!r.ok) continue;
1689
+ const body = await r.json();
1690
+ for (const item of body.items || []) {
1691
+ matches.push({
1692
+ pattern_id: p.id,
1693
+ severity: p.severity,
1694
+ source: p.source,
1695
+ repo: item.full_name,
1696
+ url: item.html_url,
1697
+ private: item.private,
1698
+ created_at: item.created_at,
1699
+ updated_at: item.updated_at,
1700
+ stars: item.stargazers_count || 0,
1701
+ });
1702
+ }
1703
+ } catch { /* network failure — best effort */ }
1704
+ }
1705
+ const generated_at = new Date().toISOString();
1706
+ if (jsonOut) {
1707
+ process.stdout.write(JSON.stringify({
1708
+ ok: !rateLimited,
1709
+ verb: 'watchlist',
1710
+ mode: 'org-scan',
1711
+ generated_at,
1712
+ org,
1713
+ patterns_evaluated: patterns.length,
1714
+ match_count: matches.length,
1715
+ matches,
1716
+ rate_limited: rateLimited,
1717
+ unauthenticated: unauth,
1718
+ control_reference: 'NEW-CTRL-052 (MAL-2026-SHAI-HULUD-OSS lesson)',
1719
+ }) + '\n');
1720
+ return;
1721
+ }
1722
+ console.log(`\nGitHub Org-Scan — ${generated_at}`);
1723
+ console.log(`Org: ${org} patterns: ${patterns.length} matches: ${matches.length}`);
1724
+ if (unauth) console.log('(unauthenticated — set GITHUB_TOKEN for private-repo coverage + higher rate limit)');
1725
+ if (rateLimited) console.log('WARNING: GitHub rate limit hit on at least one query. Re-run with GITHUB_TOKEN set.');
1726
+ if (matches.length === 0) {
1727
+ console.log('No threat-actor-pattern repos found.');
1728
+ return;
1729
+ }
1730
+ for (const m of matches) {
1731
+ console.log(`[${m.severity}] ${m.repo} ${m.private ? '(private)' : ''} created ${m.created_at}`);
1732
+ console.log(` pattern: ${m.pattern_id} (${m.source})`);
1733
+ console.log(` ${m.url}`);
1734
+ }
1735
+ }
1736
+
1585
1737
  // Only run the CLI when this file is executed directly. Earlier versions
1586
1738
  // invoked main() at import time too, which meant `require('./orchestrator')`
1587
1739
  // would trigger a full CLI dispatch (and printHelp) inside the importing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/exceptd-skills",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 42 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
5
5
  "keywords": [
6
6
  "ai-security",
package/sbom.cdx.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.6",
4
- "serialNumber": "urn:uuid:c9932465-eaaf-45d0-850b-1ac483acd405",
4
+ "serialNumber": "urn:uuid:215c1846-8948-4275-8e79-ad2f8593225c",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2133-03-02T22:32:05.000Z",
7
+ "timestamp": "2043-09-26T19:40:54.000Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "blamejs",
11
11
  "name": "scripts/refresh-sbom.js",
12
- "version": "0.13.1"
12
+ "version": "0.13.3"
13
13
  }
14
14
  ],
15
15
  "component": {
16
- "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.13.1",
16
+ "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.13.3",
17
17
  "type": "application",
18
18
  "name": "@blamejs/exceptd-skills",
19
- "version": "0.13.1",
19
+ "version": "0.13.3",
20
20
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 42 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
21
21
  "licenses": [
22
22
  {
@@ -25,17 +25,17 @@
25
25
  }
26
26
  }
27
27
  ],
28
- "purl": "pkg:npm/%40blamejs/exceptd-skills@0.13.1",
28
+ "purl": "pkg:npm/%40blamejs/exceptd-skills@0.13.3",
29
29
  "hashes": [
30
30
  {
31
31
  "alg": "SHA-256",
32
- "content": "4b9469db507f7f20a000a34c40f90adbbc98c328f2fc7ddee22843732a024c09"
32
+ "content": "5f31c2d0207da9b36fe526a2913e975e27ae2442561e03b079695af7f5a8926b"
33
33
  }
34
34
  ],
35
35
  "externalReferences": [
36
36
  {
37
37
  "type": "distribution",
38
- "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.13.1"
38
+ "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.13.3"
39
39
  },
40
40
  {
41
41
  "type": "vcs",
@@ -108,7 +108,7 @@
108
108
  "hashes": [
109
109
  {
110
110
  "alg": "SHA-256",
111
- "content": "27fbbfa0da04040c626789c887d0ea570d865bcb9e99ea6ef3fd670c73a39b5b"
111
+ "content": "1932f52ef4f3d1ba7963310436df82f5ef6269a3c77851e993e31885ebade1b2"
112
112
  }
113
113
  ]
114
114
  },
@@ -229,7 +229,7 @@
229
229
  "hashes": [
230
230
  {
231
231
  "alg": "SHA-256",
232
- "content": "e4eda996f3de0a8ffc727717065165b66c690452a576b0605036db2d5caef429"
232
+ "content": "b3540a3296e5e901004d428351d40d3ac40b154da082071da2c00222c40b7b6e"
233
233
  }
234
234
  ]
235
235
  },
@@ -240,7 +240,7 @@
240
240
  "hashes": [
241
241
  {
242
242
  "alg": "SHA-256",
243
- "content": "0ec427652a9e613f04675beb26dc4c08934ba291e47427972b2a008c151cca78"
243
+ "content": "2b021f47355365d1ba59078dfa582397c7a64c2b4ebea4657ea260a66b76daf6"
244
244
  }
245
245
  ]
246
246
  },
@@ -251,7 +251,7 @@
251
251
  "hashes": [
252
252
  {
253
253
  "alg": "SHA-256",
254
- "content": "0ca33f8b0cf55a43de1290e310096020c4e0d16305bd01bcbe6cb46e0278caa8"
254
+ "content": "76461dbec048c5e072435d57e3a04b780e3992dab9f316b1b52608e0a997e355"
255
255
  }
256
256
  ]
257
257
  },
@@ -262,7 +262,7 @@
262
262
  "hashes": [
263
263
  {
264
264
  "alg": "SHA-256",
265
- "content": "7fae34cf0abbd09abbbbd6a61ea06e487ddbd57060d3af6a58528c684156cf60"
265
+ "content": "b499cb431c1b71aab505db577a1e3b2fdcb5190afbcc6aaee0f6a237cfc16ca8"
266
266
  }
267
267
  ]
268
268
  },
@@ -273,7 +273,7 @@
273
273
  "hashes": [
274
274
  {
275
275
  "alg": "SHA-256",
276
- "content": "832d096bd52081fe43c082fd6958f9054d6b6e136df5b3d4cef7efd0ea49a843"
276
+ "content": "4a0036f9ec17af29e0df111ac77b94f8be6a52742bfd89ff3583096d23b75e35"
277
277
  }
278
278
  ]
279
279
  },
@@ -317,7 +317,7 @@
317
317
  "hashes": [
318
318
  {
319
319
  "alg": "SHA-256",
320
- "content": "5e2baf1e435c5b61b183e3f603636eae4fab34ee800488919c679665882c4f62"
320
+ "content": "ce1535f13d29ab90fac99b983f38a23dd685702b3f12ac9f2371294cb9859ecf"
321
321
  }
322
322
  ]
323
323
  },
@@ -570,7 +570,7 @@
570
570
  "hashes": [
571
571
  {
572
572
  "alg": "SHA-256",
573
- "content": "40d666d0932da24b425b01ced0f9c9e5f2e6cfd2082f53861d982919dde56a4a"
573
+ "content": "1438620d2c8b0606eac4f63e620906b9ba079c57bfa7f737ceb6a50370cdc9a5"
574
574
  }
575
575
  ]
576
576
  },
@@ -691,7 +691,7 @@
691
691
  "hashes": [
692
692
  {
693
693
  "alg": "SHA-256",
694
- "content": "17c6fda9bffce0c0bcf782fca3961e6a77b1caba9054ce12121adb541028bc53"
694
+ "content": "48aa70089fe9fc3bee80e19042d28d91ceb996ed018b6131db970dba7cadb90e"
695
695
  }
696
696
  ]
697
697
  },
@@ -812,7 +812,7 @@
812
812
  "hashes": [
813
813
  {
814
814
  "alg": "SHA-256",
815
- "content": "678cce9841ee92128e777cc0f355e020bd69c37cbd637be91479858e6a4958fd"
815
+ "content": "63702da0ef17b9dd32cff349473d5e1c32aae763cd769936a07570e34cb6b824"
816
816
  }
817
817
  ]
818
818
  },
@@ -988,7 +988,7 @@
988
988
  "hashes": [
989
989
  {
990
990
  "alg": "SHA-256",
991
- "content": "4535de4e48530ccb3c5d97402ae9dcf45f0336b838bddb8ab081c6df9632014a"
991
+ "content": "fbc30b15d294d3bdfccfb3880781dae9e9a9624e3b6d6a64723cdc75b6b47d3b"
992
992
  }
993
993
  ]
994
994
  },
@@ -1032,7 +1032,7 @@
1032
1032
  "hashes": [
1033
1033
  {
1034
1034
  "alg": "SHA-256",
1035
- "content": "7c7378f69024b1abb11d2f17978f9023b262ac6a67a82c2eb31d4996c2caee28"
1035
+ "content": "b827fb5d2a43409ba2c390b000e175c9357b86137d25d6647ff238b94922275b"
1036
1036
  }
1037
1037
  ]
1038
1038
  },
@@ -1289,6 +1289,17 @@
1289
1289
  }
1290
1290
  ]
1291
1291
  },
1292
+ {
1293
+ "bom-ref": "file:scripts/check-test-count.js",
1294
+ "type": "file",
1295
+ "name": "scripts/check-test-count.js",
1296
+ "hashes": [
1297
+ {
1298
+ "alg": "SHA-256",
1299
+ "content": "e8c7473d7a1f87d27aeab39cefa54c10c773831c3c3b0a786c81f9ac9a50d6e3"
1300
+ }
1301
+ ]
1302
+ },
1292
1303
  {
1293
1304
  "bom-ref": "file:scripts/check-test-coverage.README.md",
1294
1305
  "type": "file",
@@ -1329,7 +1340,7 @@
1329
1340
  "hashes": [
1330
1341
  {
1331
1342
  "alg": "SHA-256",
1332
- "content": "83c795a05c0a1abcbb358eb47de22f04dfc3ecec602b35fbb686093dbe27c182"
1343
+ "content": "6a7766b986988fd14105d92f3488052333afff8b72eda17460e193ca58b2d60a"
1333
1344
  }
1334
1345
  ]
1335
1346
  },
@@ -1406,7 +1417,7 @@
1406
1417
  "hashes": [
1407
1418
  {
1408
1419
  "alg": "SHA-256",
1409
- "content": "66e7773d29c179ab62f409007c05e05993e04a19273225a1e520f2481fd9a90d"
1420
+ "content": "51295c849bcced965b6448eb6b4bbd5caef5ba0b0cea7ce48abbacf47d331621"
1410
1421
  }
1411
1422
  ]
1412
1423
  },
@@ -1439,7 +1450,7 @@
1439
1450
  "hashes": [
1440
1451
  {
1441
1452
  "alg": "SHA-256",
1442
- "content": "67e62791f60231f2ff53408922fa7137a9060de72097769c630f838a1c227c45"
1453
+ "content": "2b611eb8fa4841fdfc3f1dd1ffd504a46c6ecdc654213a955efbabefb6b1db87"
1443
1454
  }
1444
1455
  ]
1445
1456
  },
@@ -1450,7 +1461,7 @@
1450
1461
  "hashes": [
1451
1462
  {
1452
1463
  "alg": "SHA-256",
1453
- "content": "2bdfa3dbe534efa3df245e0da37998ad7ab2da4a3171d5000d3346513c10bceb"
1464
+ "content": "9fc2252cbcf6162591e70d0bf5499a430b0584495ad584ce49fb7daf070d335f"
1454
1465
  }
1455
1466
  ]
1456
1467
  },
@@ -1472,7 +1483,7 @@
1472
1483
  "hashes": [
1473
1484
  {
1474
1485
  "alg": "SHA-256",
1475
- "content": "6494ee3856edeb212e65fe5cdb208357c1a832eb8ac374b26055586bfc71f629"
1486
+ "content": "5ec3800a0049b2123aff67bfab4ff28491a86d2daeb712283e5e88b10c3d5d7b"
1476
1487
  }
1477
1488
  ]
1478
1489
  },
@@ -1527,7 +1538,7 @@
1527
1538
  "hashes": [
1528
1539
  {
1529
1540
  "alg": "SHA-256",
1530
- "content": "e62c71ba3be2b4d0f7dfa529fec007cba6bee3013f76b93756e3e6310f2d22ab"
1541
+ "content": "3d0c7ca85f32ee1fe74598889361ef2be16d099fe6e9e8d8c8184b7004306b30"
1531
1542
  }
1532
1543
  ]
1533
1544
  },
@@ -1549,7 +1560,7 @@
1549
1560
  "hashes": [
1550
1561
  {
1551
1562
  "alg": "SHA-256",
1552
- "content": "e4e9e5a820c0ed3fde9483282e7a0ecaf79284cd2e9923ce66f2b0fb1fc44626"
1563
+ "content": "250f266908f51f99a4cb3aec0d5dacfcf91fac9f3d95e5a117429a40ed2ff45a"
1553
1564
  }
1554
1565
  ]
1555
1566
  },
@@ -1582,7 +1593,7 @@
1582
1593
  "hashes": [
1583
1594
  {
1584
1595
  "alg": "SHA-256",
1585
- "content": "51acb746cd63366ca62567588c700a9eb3f37c43250bd9ae4e1477ccb71c5b6d"
1596
+ "content": "fb8c261def9e3344b44fd219c209027029e1eddf0e6bee1ecffb2d2176e1585e"
1586
1597
  }
1587
1598
  ]
1588
1599
  },
@@ -1659,7 +1670,7 @@
1659
1670
  "hashes": [
1660
1671
  {
1661
1672
  "alg": "SHA-256",
1662
- "content": "ca3fd922b43fc57aeb5e65c2d5a2823e6bc438167d6afa3a767cee83e4af1f96"
1673
+ "content": "72429f05010accbcb191cb1544f1b88493c2f5249362846e5713ec3226b83dc2"
1663
1674
  }
1664
1675
  ]
1665
1676
  },
@@ -1670,7 +1681,7 @@
1670
1681
  "hashes": [
1671
1682
  {
1672
1683
  "alg": "SHA-256",
1673
- "content": "9ece7b1fb7f24e37dbdd8618b94b2a4434e182e3426e15f17e26464c0a1fdfd1"
1684
+ "content": "7423cca19aab1026c07de63279137441018345731d3ee895c474316d432adaa2"
1674
1685
  }
1675
1686
  ]
1676
1687
  },
@@ -1725,7 +1736,7 @@
1725
1736
  "hashes": [
1726
1737
  {
1727
1738
  "alg": "SHA-256",
1728
- "content": "b47daaa26fdac07aa23e7becaa18487c5302e65c654f99fecab3689f23ec1bd2"
1739
+ "content": "fd441131484dc5af4cd785ded0bac039123e6205483543752cb16fa508460c00"
1729
1740
  }
1730
1741
  ]
1731
1742
  },
@@ -1736,7 +1747,7 @@
1736
1747
  "hashes": [
1737
1748
  {
1738
1749
  "alg": "SHA-256",
1739
- "content": "643fd951359c2602d9b029a244fe66c1e23f726e711141a06c09cc760a479534"
1750
+ "content": "91f00e7a9be2608393ec8cb6d5f0c9828f81b954a12a7c9fd04bd642b9091e09"
1740
1751
  }
1741
1752
  ]
1742
1753
  },
@@ -1747,7 +1758,7 @@
1747
1758
  "hashes": [
1748
1759
  {
1749
1760
  "alg": "SHA-256",
1750
- "content": "c63cf1c7c98e920f968cfe60f14e718ea71b120c1b01616af22f64a796963bbe"
1761
+ "content": "a73c3f36f23c12750d369931b7e3f884edae4a8aef35fc8690d15ef4500c4dd0"
1751
1762
  }
1752
1763
  ]
1753
1764
  },
@@ -1780,7 +1791,7 @@
1780
1791
  "hashes": [
1781
1792
  {
1782
1793
  "alg": "SHA-256",
1783
- "content": "862f9482af88e5409e011a6981a5d719863deeb646e41cd4df63e5d6597c50b1"
1794
+ "content": "59193e39c2fd73fdd7fede38a956bc730bbe4b712d7d6020788bb4d85f001ad8"
1784
1795
  }
1785
1796
  ]
1786
1797
  },
@@ -1802,7 +1813,7 @@
1802
1813
  "hashes": [
1803
1814
  {
1804
1815
  "alg": "SHA-256",
1805
- "content": "cf2b996cb18a5146614c06e3a50f4734a07d02b5be36bbdf492583f9cdcfed4d"
1816
+ "content": "b6f3bee321833dc18f5624a9be4d28673d22e22018254b0bd1f3690b945073af"
1806
1817
  }
1807
1818
  ]
1808
1819
  },
@@ -1824,7 +1835,7 @@
1824
1835
  "hashes": [
1825
1836
  {
1826
1837
  "alg": "SHA-256",
1827
- "content": "ecc6441cb47ef2bc24547e47be018098228c956a41d61ddb50de7e7b37114a37"
1838
+ "content": "cf1cc27ae5ae68d336c56d9f3afd950641e1d8d5b9f90b64c2daf00abe92bab0"
1828
1839
  }
1829
1840
  ]
1830
1841
  },
@@ -1835,7 +1846,7 @@
1835
1846
  "hashes": [
1836
1847
  {
1837
1848
  "alg": "SHA-256",
1838
- "content": "ac623f61585de66c9ef5ed63e9c6059faef77e525abc672ac6d435c616a7268f"
1849
+ "content": "cebeba3940320ebc5b44ad2bb7b4cdcda412257c1a6319a1b7379c875ebe8d6a"
1839
1850
  }
1840
1851
  ]
1841
1852
  },
@@ -1846,7 +1857,7 @@
1846
1857
  "hashes": [
1847
1858
  {
1848
1859
  "alg": "SHA-256",
1849
- "content": "fdb07324b69a3a724e3eaba17bf687d72d4bd9d5c4f440be816bc9b08b8aef04"
1860
+ "content": "f2063eaea3f5ddf0f3d37b41985bf522b682a41f104796b3f0dff611cefd043c"
1850
1861
  }
1851
1862
  ]
1852
1863
  },
@@ -1857,7 +1868,7 @@
1857
1868
  "hashes": [
1858
1869
  {
1859
1870
  "alg": "SHA-256",
1860
- "content": "59a0d7cd85b923b3f5633bdc15c1a88eef7dea6332480d93b0bb0ae93a4cd0fe"
1871
+ "content": "e26f194880cd6acf46abe31e9348d445e9222c7691e9b9b953662c4a472462f5"
1861
1872
  }
1862
1873
  ]
1863
1874
  },
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * scripts/check-test-count.js — v0.13.2 canonical-test-count predeploy gate.
6
+ *
7
+ * Why this exists. The v0.12 audit flagged that nothing in the suite asserts
8
+ * "we expect N tests today." A test file accidentally deleted, a skip-all
9
+ * mistakenly committed, or a misnamed file glob-excluded would all silently
10
+ * drop tests without anyone noticing. The lint + diff-coverage gates catch
11
+ * source changes; this gate catches test-set shrinkage.
12
+ *
13
+ * Mechanism: count `test(`, `test.only(`, and `test.skip(` declarations
14
+ * across `tests/*.test.js` via static analysis (faster than running). Compare
15
+ * to a baseline pinned in `tests/.test-count-baseline.json`. Fail if the
16
+ * observed count drops MORE than the configured tolerance (default 1) below
17
+ * the baseline. Growth above baseline is fine; if the count grows by more
18
+ * than `update_baseline_when_growth_exceeds`, surface a notice that the
19
+ * baseline file should be refreshed (operator commits the refresh as part
20
+ * of the release that added the tests).
21
+ *
22
+ * Output:
23
+ * stdout: structured JSON when --json, else a one-line summary
24
+ * exit 0: observed count is at or above baseline minus tolerance
25
+ * exit 1: observed count dropped beyond tolerance — fail predeploy
26
+ * exit 2: baseline file missing or malformed
27
+ */
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+
32
+ const ROOT = path.resolve(__dirname, '..');
33
+ const TESTS_DIR = path.join(ROOT, 'tests');
34
+ const BASELINE_PATH = path.join(TESTS_DIR, '.test-count-baseline.json');
35
+
36
+ function listTestFiles(dir) {
37
+ const out = [];
38
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
39
+ for (const e of entries) {
40
+ const p = path.join(dir, e.name);
41
+ if (e.isDirectory()) {
42
+ if (e.name === '_helpers' || e.name === 'fixtures' || e.name === 'e2e-scenarios') continue;
43
+ out.push(...listTestFiles(p));
44
+ } else if (e.isFile() && e.name.endsWith('.test.js')) {
45
+ out.push(p);
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function countTests(filePath) {
52
+ const text = fs.readFileSync(filePath, 'utf8');
53
+ const lines = text.split('\n');
54
+ let count = 0;
55
+ for (const line of lines) {
56
+ const stripped = line.replace(/^\s*\/\/.*$/, '').trim();
57
+ if (!stripped) continue;
58
+ if (/(?<![A-Za-z0-9_$.])test(?:\.only|\.skip)?\s*\(/.test(stripped)) count++;
59
+ }
60
+ return count;
61
+ }
62
+
63
+ function main() {
64
+ const wantJson = process.argv.includes('--json');
65
+ const wantUpdate = process.argv.includes('--update-baseline');
66
+
67
+ if (!fs.existsSync(BASELINE_PATH)) {
68
+ if (wantUpdate) {
69
+ const files = listTestFiles(TESTS_DIR);
70
+ const observed = files.reduce((n, f) => n + countTests(f), 0);
71
+ fs.writeFileSync(BASELINE_PATH, JSON.stringify({
72
+ baseline: observed,
73
+ tolerance: 1,
74
+ update_baseline_when_growth_exceeds: 20,
75
+ notes: 'Operator-pinned canonical test count. Bump when new test files land in a release. See scripts/check-test-count.js for the contract.',
76
+ recorded_at: new Date().toISOString().slice(0, 10),
77
+ }, null, 2) + '\n', 'utf8');
78
+ console.error(`[check-test-count] wrote initial baseline: ${observed}`);
79
+ process.exit(0);
80
+ }
81
+ console.error(`[check-test-count] baseline missing at ${path.relative(ROOT, BASELINE_PATH)}. Run with --update-baseline to create it.`);
82
+ process.exit(2);
83
+ }
84
+
85
+ let baselineFile;
86
+ try { baselineFile = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); }
87
+ catch (e) {
88
+ console.error(`[check-test-count] cannot parse baseline: ${e.message}`);
89
+ process.exit(2);
90
+ }
91
+ const baseline = baselineFile.baseline;
92
+ const tolerance = baselineFile.tolerance || 1;
93
+ const updateThreshold = baselineFile.update_baseline_when_growth_exceeds || 20;
94
+ if (typeof baseline !== 'number' || baseline <= 0) {
95
+ console.error(`[check-test-count] baseline value invalid: ${baseline}`);
96
+ process.exit(2);
97
+ }
98
+
99
+ const files = listTestFiles(TESTS_DIR);
100
+ const observed = files.reduce((n, f) => n + countTests(f), 0);
101
+
102
+ if (wantUpdate) {
103
+ fs.writeFileSync(BASELINE_PATH, JSON.stringify({
104
+ ...baselineFile,
105
+ baseline: observed,
106
+ recorded_at: new Date().toISOString().slice(0, 10),
107
+ }, null, 2) + '\n', 'utf8');
108
+ console.error(`[check-test-count] baseline updated: ${baseline} -> ${observed}`);
109
+ process.exit(0);
110
+ }
111
+
112
+ const delta = observed - baseline;
113
+ const status = delta < -tolerance
114
+ ? 'shrunk_beyond_tolerance'
115
+ : delta > updateThreshold
116
+ ? 'grew_beyond_threshold_consider_bump'
117
+ : 'ok';
118
+
119
+ if (wantJson) {
120
+ process.stdout.write(JSON.stringify({
121
+ ok: status === 'ok' || status === 'grew_beyond_threshold_consider_bump',
122
+ verb: 'check-test-count',
123
+ observed,
124
+ baseline,
125
+ tolerance,
126
+ delta,
127
+ status,
128
+ files_scanned: files.length,
129
+ }) + '\n');
130
+ } else {
131
+ console.log(`[check-test-count] observed=${observed} baseline=${baseline} delta=${delta >= 0 ? '+' : ''}${delta} tolerance=${tolerance} files=${files.length} status=${status}`);
132
+ }
133
+
134
+ if (status === 'shrunk_beyond_tolerance') {
135
+ console.error(`[check-test-count] FAIL - test count dropped from ${baseline} to ${observed} (delta ${delta}, tolerance -${tolerance}).`);
136
+ console.error('[check-test-count] Either a test file was accidentally removed, a test() invocation was deleted, OR the baseline is stale.');
137
+ console.error('[check-test-count] If the drop is intentional, run: node scripts/check-test-count.js --update-baseline');
138
+ process.exit(1);
139
+ }
140
+ if (status === 'grew_beyond_threshold_consider_bump') {
141
+ console.error(`[check-test-count] NOTICE - test count grew by ${delta} (above the ${updateThreshold} notice threshold). Consider refreshing the baseline: node scripts/check-test-count.js --update-baseline`);
142
+ }
143
+ process.exit(0);
144
+ }
145
+
146
+ main();