@blamejs/exceptd-skills 0.19.33 → 0.19.34

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 (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -152
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +35 -114
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -412
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +50 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +13 -73
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +66 -197
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +3 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -140
package/lib/ttp-mapper.js CHANGED
@@ -8,10 +8,8 @@
8
8
  const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
9
9
 
10
10
  function map(controlId, gapCatalog) {
11
- // hasOwnProperty guard: a bare gapCatalog[controlId] dereferences inherited
12
- // Object.prototype members, so controlId='__proto__' / 'toString' /
13
- // 'constructor' returned found:true for a key that is not a real catalog
14
- // entry. Only own enumerable keys are catalog controls.
11
+ // hasOwn, not a bare gapCatalog[controlId]: '__proto__' / 'toString' /
12
+ // 'constructor' otherwise resolve an inherited member and report found:true.
15
13
  if (!gapCatalog || !hasOwn(gapCatalog, controlId)) {
16
14
  return { control_id: controlId, found: false, message: 'Control not in gap catalog' };
17
15
  }
@@ -45,43 +43,35 @@ function gapsFor(attackPattern, gapCatalog, atlasCatalog) {
45
43
  }
46
44
 
47
45
  function coverage(frameworkId, ttpId, gapCatalog, atlasCatalog) {
48
- // Input guard before any deref — an empty / non-string frameworkId
49
- // yielded frameworkPrefix='' which matched EVERY control via
50
- // includes(''), and null/undefined threw on .split(). Match the
51
- // { found:false } contract already used for an unknown TTP. Surface
52
- // partially_covered_by / not_covered_by as an explicit null (not absent)
53
- // so the no-match outcome is observable rather than a silent universal
54
- // match.
46
+ // Guard before any deref: an empty frameworkId yields a prefix of '' that
47
+ // matches every control, and a non-string throws on .split().
48
+ // partially_covered_by / not_covered_by come back explicitly null, so the
49
+ // no-match outcome is observable rather than a silent universal match.
55
50
  if (typeof frameworkId !== 'string' || frameworkId.trim() === '') {
56
51
  return { ttp_id: ttpId, found: false, error: 'frameworkId required', partially_covered_by: null, not_covered_by: null };
57
52
  }
58
53
 
59
- // hasOwnProperty guard: ttpId='__proto__' / 'toString' / 'constructor'
60
- // would otherwise resolve to an inherited Object.prototype member and be
61
- // treated as a real ATLAS technique. Only own keys are catalog techniques.
54
+ // Same guard for ttpId: an inherited Object.prototype member would otherwise
55
+ // be treated as a real ATLAS technique.
62
56
  if (!atlasCatalog || !hasOwn(atlasCatalog, ttpId)) {
63
57
  return { ttp_id: ttpId, found: false };
64
58
  }
65
59
  const ttp = atlasCatalog[ttpId];
66
60
  if (!ttp) return { ttp_id: ttpId, found: false };
67
61
 
68
- // atlas-ttps.json uses controls_that_partially_help / controls_that_dont_help / framework_gap_detail
69
62
  const partialControls = ttp.controls_that_partially_help || [];
70
63
  const noHelpControls = ttp.controls_that_dont_help || [];
71
64
  const gapDetail = ttp.framework_gap_detail || '';
72
65
  const hasFrameworkGap = ttp.framework_gap === true;
73
66
 
74
- // Check if the requested framework has any coverage in the partially-helpful
75
- // controls. Match on the first hyphen-delimited segment of the control id
76
- // (token-boundary), NOT bare substring containment: a bare includes() let
77
- // 'IS' match 'NIST' and '' match everything. A control id matches when it
78
- // begins with the prefix and the next char is a segment boundary (-, .) or
79
- // end-of-string, so 'soc2' still matches 'soc2-z' but 'is' never matches
80
- // 'nist-...'.
67
+ // Match the first hyphen-delimited segment of a control id, not bare
68
+ // containment: includes() lets 'IS' match 'NIST' and '' match everything. A
69
+ // control matches when it starts with the prefix and the next character ends
70
+ // the segment (-, . or end), so 'soc2' still matches 'soc2-z'.
81
71
  const frameworkPrefix = frameworkId.split('-')[0].toLowerCase();
82
72
  if (frameworkPrefix.length === 0) {
83
- // frameworkId is a hyphen-led string (e.g. "-" or "-X") whose first
84
- // segment is empty — same universal-match hazard, same fail-closed result.
73
+ // A hyphen-led frameworkId ("-X") has an empty first segment — the same
74
+ // universal-match hazard, so the same fail-closed result.
85
75
  return { ttp_id: ttpId, found: false, error: 'frameworkId required', partially_covered_by: null, not_covered_by: null };
86
76
  }
87
77
  const segMatch = (c) => {
@@ -2,23 +2,11 @@
2
2
  "use strict";
3
3
 
4
4
  /**
5
- * lib/upstream-check-cli.js
6
- *
7
- * Small wrapper that calls lib/upstream-check.fetchLatestPublished() and
8
- * emits the freshness report as JSON to stdout. Used internally by:
9
- * - `exceptd doctor --registry-check`
10
- * - `exceptd run --upstream-check`
11
- * - `exceptd refresh --network`
12
- *
13
- * Runs in a child process so the parent verb stays synchronous and the
14
- * network timeout is bounded by the spawnSync timeout.
15
- *
16
- * Output: one JSON line to stdout. Exits 0 even when the registry is
17
- * unreachable (offline ≠ error — the freshness signal degrades gracefully).
18
- *
19
- * Flags:
20
- * --timeout <ms> override the default 5000 ms network timeout
21
- * --raw emit raw registry response instead of freshness report
5
+ * Emits lib/upstream-check's freshness report as one JSON line on stdout.
6
+ * Spawned as a child process so the calling verb stays synchronous and the
7
+ * network timeout is bounded by the spawnSync timeout. Exits 0 even when the
8
+ * registry is unreachable: callers parse the envelope off stdout, and absent
9
+ * freshness data is a degraded signal rather than an error.
22
10
  */
23
11
 
24
12
  const path = require("path");
@@ -54,11 +42,8 @@ function readManifest() {
54
42
 
55
43
  (async () => {
56
44
  const opts = parseArgs(process.argv);
57
- // Air-gap short-circuit — the registry probe is a network operation. When
58
- // the operator has declared air-gapped mode (env var OR --air-gap), emit a
59
- // structured `skipped` envelope and exit 0. Exit 0 because, like the
60
- // existing "offline" path, missing freshness data is a graceful degradation
61
- // — it is NOT an error condition for downstream callers.
45
+ // The registry probe is a network call, so air-gap mode answers with a
46
+ // `skipped` envelope instead. `ok: null` is neither pass nor fail.
62
47
  if (process.env.EXCEPTD_AIR_GAP === "1" || opts.airGap) {
63
48
  process.stdout.write(JSON.stringify({
64
49
  ok: null,
@@ -81,12 +66,9 @@ function readManifest() {
81
66
  });
82
67
  process.stdout.write(JSON.stringify(report) + "\n");
83
68
  })().catch((err) => {
84
- // Any unexpected throw still yields one parseable JSON line on stdout and a
85
- // clean exit, consistent with this probe's offline-degradation contract
86
- // (missing freshness data is not an error for downstream callers, which parse
87
- // res.stdout for the envelope). String(...) coerces the error to a primitive,
88
- // so this JSON.stringify itself cannot throw. Exit 0 is the default — no prior
89
- // non-zero exitCode is set on this path.
69
+ // Any throw still yields one parseable JSON line and exit 0, per the
70
+ // degradation contract above. String(...) coerces the error to a primitive so
71
+ // this JSON.stringify cannot itself throw.
90
72
  process.stdout.write(JSON.stringify({
91
73
  ok: false,
92
74
  error: String((err && err.message) || err),
@@ -1,19 +1,11 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/upstream-check.js
5
- *
6
- * Shared helper used by `doctor --registry-check`, `run --upstream-check`,
7
- * and `refresh --network`. Queries the npm registry for the package's
8
- * latest published version + publish timestamp. Operator opts in — never
9
- * fired automatically on every CLI invocation.
10
- *
11
- * Trust model: the registry call is a freshness signal, not a trust
12
- * anchor. The Ed25519-signed skill catalog shipped in the operator's
13
- * installed package remains the source of truth. This helper only
14
- * reports "you're N days behind" — does not auto-update anything.
15
- *
16
- * Zero npm deps. Node 24 stdlib only.
4
+ * Queries the npm registry for the package's latest published version and
5
+ * publish time, for `doctor --registry-check`, `run --upstream-check` and
6
+ * `refresh --network`. The registry call is a freshness signal, not a trust
7
+ * anchor: the Ed25519-signed local catalog stays the source of truth, and this
8
+ * reports how far behind it is without updating anything.
17
9
  */
18
10
 
19
11
  const https = require("https");
@@ -23,21 +15,14 @@ const PKG_NAME = "@blamejs/exceptd-skills";
23
15
  const REQUEST_TIMEOUT_MS = 5000;
24
16
 
25
17
  /**
26
- * Fetch the latest version + publish time from the npm registry.
27
- *
28
- * Returns:
29
- * { ok: true, version: "0.11.14", published_at: ISO_STRING, source: "npm-registry" }
30
- * { ok: false, error: "timeout" | "offline" | "parse" | string, source: "offline" }
31
- *
32
- * Honors EXCEPTD_REGISTRY_FIXTURE env var for offline testing — value is
33
- * a path to a JSON file with { version, time: { <ver>: ISO } } shape.
18
+ * Returns { ok: true, version, published_at, source: "npm-registry" }, or
19
+ * { ok: false, error, source: "offline" } — never throws, so a caller treats an
20
+ * absent freshness signal as absent rather than as an error.
21
+ * EXCEPTD_REGISTRY_FIXTURE points at a JSON file shaped
22
+ * { version, time: { <ver>: ISO } } for offline runs.
34
23
  */
35
24
  async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName = PKG_NAME } = {}) {
36
- // Air-gap refusal — registry probes are a network operation and must never
37
- // be issued when the operator has declared an air-gapped environment.
38
- // Returning a structured refusal (instead of throwing) lets callers degrade
39
- // gracefully the same way they handle `offline` — the freshness signal is
40
- // intentionally absent, not in error.
25
+ // A network operation: air-gap gets a structured refusal, as `offline` does.
41
26
  if (process.env.EXCEPTD_AIR_GAP === "1") {
42
27
  return { ok: false, error: "air-gap-blocked", source: "fetchLatestPublished" };
43
28
  }
@@ -47,11 +32,8 @@ async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName =
47
32
  const fs = require("fs");
48
33
  const fixture = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
49
34
  const version = fixture["dist-tags"]?.latest || fixture.version;
50
- // Same guard the network branch applies: a fixture with neither
51
- // dist-tags.latest nor a top-level version yields no freshness signal.
52
- // Returning ok:true with version:undefined would let an undefined
53
- // version leak into buildFreshnessReport's semver compare and the
54
- // operator-facing hint. Degrade to an explicit offline refusal.
35
+ // Same guard as the network branch: ok:true with version undefined leaks
36
+ // into buildFreshnessReport's semver compare and its hint.
55
37
  if (!version) {
56
38
  return { ok: false, error: "fixture missing dist-tags.latest / version", source: "offline" };
57
39
  }
@@ -99,9 +81,8 @@ async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName =
99
81
  }
100
82
 
101
83
  /**
102
- * Semver compare (returns -1, 0, 1). Accepts canonical N.N.N strings only;
103
- * pre-release tags are ignored (rare on this package, and operators behind
104
- * a pre-release would explicitly opt in).
84
+ * Semver compare, returning -1, 0 or 1. Canonical N.N.N only; a pre-release tag
85
+ * is ignored rather than ordered.
105
86
  */
106
87
  function semverCmp(a, b) {
107
88
  const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
@@ -113,10 +94,7 @@ function semverCmp(a, b) {
113
94
  return 0;
114
95
  }
115
96
 
116
- /**
117
- * Build the operator-facing freshness report. Pure function — takes the
118
- * registry response and the local version/manifest, returns the report.
119
- */
97
+ /** Operator-facing freshness report. Pure — no I/O of its own. */
120
98
  function buildFreshnessReport({ localVersion, registry, localManifest }) {
121
99
  if (!registry || !registry.ok) {
122
100
  return {
@@ -128,16 +106,13 @@ function buildFreshnessReport({ localVersion, registry, localManifest }) {
128
106
  };
129
107
  }
130
108
  const cmp = semverCmp(localVersion, registry.version);
131
- // Parse the publish timestamp once and validate it. A non-empty but
132
- // unparseable date string (e.g. a malformed registry `time` value) makes
133
- // `new Date(...).getTime()` return NaN, which would otherwise propagate
134
- // NaN into days_since_latest_publish. Degrade an unparseable date to the
135
- // explicit null (signal-absent) branch instead of emitting NaN.
109
+ // An unparseable registry `time` yields NaN from getTime(); it degrades to the
110
+ // explicit null signal-absent branch rather than reaching the report.
136
111
  const publishedTs = registry.published_at ? new Date(registry.published_at).getTime() : NaN;
137
112
  const daysBehind = Number.isFinite(publishedTs)
138
113
  ? Math.max(0, Math.floor((Date.now() - publishedTs) / (24 * 3600 * 1000)))
139
114
  : null;
140
- // Manifest's last_threat_review (per-skill) — surface the most stale.
115
+ // last_threat_review is per-skill; the report surfaces the most stale.
141
116
  let oldestReview = null;
142
117
  if (localManifest && Array.isArray(localManifest.skills)) {
143
118
  for (const s of localManifest.skills) {
@@ -1,29 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * lib/validate-catalog-meta.js — assert every data/*.json carries the
4
- * source-trust + freshness fields required by the audit follow-up:
5
- *
6
- * _meta.tlp — Traffic Light Protocol marking
7
- * _meta.source_confidence — Admiralty scheme (A-F + 1-6), with
8
- * default rating and per-entry override note
9
- * _meta.freshness_policy — review cadence + decay thresholds
10
- *
11
- * Per AGENTS.md rule #10 (no placeholder language), this validator
12
- * rejects empty strings and the usual placeholder tokens. Rule #12
13
- * (external data version pinning) is enforced informally — every catalog
14
- * still needs its existing schema_version / last_updated fields, but
15
- * those are validated by the existing per-catalog validators.
16
- *
17
- * Usage:
18
- * node lib/validate-catalog-meta.js
19
- * node lib/validate-catalog-meta.js --quiet
20
- *
21
- * Exit code:
22
- * 0 all catalogs have the required _meta fields
23
- * 1 one or more catalogs missing a required field
24
- * 2 argv error
25
- *
26
- * No external dependencies. Node 24 stdlib only.
3
+ * Asserts every data/*.json carries its source-trust and freshness fields:
4
+ * _meta.tlp (TLP marking), _meta.source_confidence (Admiralty scheme) and
5
+ * _meta.freshness_policy. Empty strings and placeholder tokens are rejected.
6
+ * Exits 0 when every catalog passes, 1 on a missing or malformed field, 2 on an
7
+ * argv error. --strict promotes freshness warnings to errors.
27
8
  */
28
9
 
29
10
  'use strict';
@@ -85,14 +66,10 @@ function containsPlaceholder(s) {
85
66
  return PLACEHOLDER_TOKENS.some((re) => re.test(s));
86
67
  }
87
68
 
88
- // Round-trip ISO calendar-date check. Returns a Date for a real YYYY-MM-DD
89
- // calendar date, or null for anything malformed. Unlike a shape-only regex,
90
- // this rejects impossible dates (2026-13-99, 2026-04-31, 2026-02-29 in a
91
- // non-leap year): `new Date('2026-02-30T00:00:00Z')` does NOT throw — it rolls
92
- // over to March 2 with a valid getTime() — so the parsed Y-M-D must round-trip
93
- // back to the input components. Deliberately carries NO year-floor business
94
- // rule (a valid-but-old 1900-01-01 stays a valid date so the staleness branch,
95
- // not the validity branch, reports it).
69
+ // A Date for a real YYYY-MM-DD calendar date, null for anything else. The
70
+ // round-trip is what rejects an impossible date: `new Date('2026-02-30')` does
71
+ // not throw, it rolls over to March 2. No year-floor rule here — a
72
+ // valid-but-ancient date stays valid so the staleness branch reports it.
96
73
  function parseIsoDateStrict(v) {
97
74
  if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return null;
98
75
  const d = new Date(v + 'T00:00:00Z');
@@ -115,19 +92,12 @@ function validateMeta(catalogPath, opts) {
115
92
  const meta = data._meta;
116
93
 
117
94
  if (!meta || typeof meta !== 'object') {
118
- // Honor both return contracts. The rest of the body dereferences
119
- // `meta.tlp` / `meta.source_confidence` / `meta.freshness_policy`, so it
120
- // must NOT run when `meta` is absent or non-object. Return early in the
121
- // SAME shape the caller asked for: includeWarnings callers (main()) get
122
- // `{errors, warnings}` so `result.errors` is a real array and the loop
123
- // reports a clean FAIL + continues; no-opts callers still get a non-empty
124
- // `string[]`. This still FAILS — it only removes the uncaught TypeError.
95
+ // Must return here — everything below dereferences `meta.*`.
125
96
  errors.push('missing _meta block');
126
97
  if (opts && opts.includeWarnings) return { errors, warnings };
127
98
  return errors;
128
99
  }
129
100
 
130
- /* tlp */
131
101
  if (typeof meta.tlp !== 'string') {
132
102
  errors.push('_meta.tlp is missing or not a string');
133
103
  } else if (!REQUIRED_TLP_VALUES.has(meta.tlp)) {
@@ -136,7 +106,6 @@ function validateMeta(catalogPath, opts) {
136
106
  );
137
107
  }
138
108
 
139
- /* source_confidence */
140
109
  const sc = meta.source_confidence;
141
110
  if (!sc || typeof sc !== 'object') {
142
111
  errors.push('_meta.source_confidence is missing or not an object');
@@ -157,7 +126,6 @@ function validateMeta(catalogPath, opts) {
157
126
  }
158
127
  }
159
128
 
160
- /* freshness_policy */
161
129
  const fp = meta.freshness_policy;
162
130
  if (!fp || typeof fp !== 'object') {
163
131
  errors.push('_meta.freshness_policy is missing or not an object');
@@ -179,8 +147,6 @@ function validateMeta(catalogPath, opts) {
179
147
  } else if (containsPlaceholder(fp.note)) {
180
148
  errors.push('_meta.freshness_policy.note contains placeholder language');
181
149
  }
182
- /* Soft check: cadence < stale < rebuild. Catches an obvious copy-paste
183
- * mistake without being a hard schema constraint. */
184
150
  if (
185
151
  typeof fp.default_review_cadence_days === 'number' &&
186
152
  typeof fp.stale_after_days === 'number' &&
@@ -198,15 +164,8 @@ function validateMeta(catalogPath, opts) {
198
164
  }
199
165
  }
200
166
 
201
- /* freshness enforcement. When both meta.last_updated and
202
- * freshness_policy.stale_after_days are present, surface a warning if
203
- * (now - last_updated) > stale_after_days. Emitted at WARN level by
204
- * default (does not fail validation).
205
- *
206
- * Optional `opts.strict` (or `opts.errorOnStale`) promotes the warning
207
- * to an error; the predeploy gate runs --strict, plain validation keeps
208
- * the warning posture.
209
- */
167
+ /* Staleness is a warning by default; `opts.strict` or `opts.errorOnStale`
168
+ * promotes it to an error, and the predeploy gate runs --strict. */
210
169
  if (
211
170
  meta.last_updated !== undefined &&
212
171
  typeof fp.stale_after_days === 'number' &&
@@ -214,10 +173,8 @@ function validateMeta(catalogPath, opts) {
214
173
  ) {
215
174
  const lu = parseIsoDateStrict(meta.last_updated);
216
175
  if (lu === null) {
217
- // Fail-closed on a malformed date instead of silently skipping the
218
- // freshness gate. A NaN/impossible/wrong-shape last_updated is
219
- // "invalid input" (error under --strict, warning by default), NOT
220
- // "no opinion" — otherwise the staleness check fails open.
176
+ // A malformed last_updated is invalid input, not "no opinion":
177
+ // skipping it here would let the staleness check fail open.
221
178
  const msg =
222
179
  `_meta.last_updated ${JSON.stringify(meta.last_updated)} is not a valid ISO date ` +
223
180
  `(YYYY-MM-DD calendar date) — cannot evaluate freshness. ` +
@@ -244,9 +201,8 @@ function validateMeta(catalogPath, opts) {
244
201
  }
245
202
  }
246
203
 
247
- // Warnings are appended after errors when callers ask for the combined
248
- // shape via opts.includeWarnings. Default return is errors only so the
249
- // public function signature is unchanged for existing callers.
204
+ // Two return shapes: `{errors, warnings}` under opts.includeWarnings,
205
+ // otherwise a bare string[] of errors.
250
206
  if (opts && opts.includeWarnings) {
251
207
  return { errors, warnings };
252
208
  }
@@ -291,7 +247,7 @@ function main() {
291
247
  console.log(
292
248
  `\n${passed}/${total} catalogs validated${warnSuffix}${failSuffix}.`,
293
249
  );
294
- // process.exitCode + return so buffered writes drain.
250
+ // process.exitCode, not process.exit() — the exit can truncate a piped write.
295
251
  process.exitCode = failed === 0 ? 0 : 1;
296
252
  }
297
253