dw-kit 1.3.4 → 1.3.6

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.
@@ -0,0 +1,321 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const LOCKFILE_NAMES = ['package-lock.json', 'npm-shrinkwrap.json'];
5
+
6
+ export function findLockfile(rootDir = process.cwd()) {
7
+ for (const name of LOCKFILE_NAMES) {
8
+ const p = join(rootDir, name);
9
+ if (existsSync(p)) return p;
10
+ }
11
+ return null;
12
+ }
13
+
14
+ export function parsePackageLockfile(filepath) {
15
+ const raw = readFileSync(filepath, 'utf-8');
16
+ const data = JSON.parse(raw);
17
+
18
+ const out = new Map();
19
+
20
+ if (data.packages && typeof data.packages === 'object') {
21
+ for (const [key, info] of Object.entries(data.packages)) {
22
+ if (!key || key === '') continue;
23
+ if (!info || !info.version) continue;
24
+
25
+ const name = info.name || key.replace(/^node_modules\//, '').replace(/.*\/node_modules\//, '');
26
+ if (!name) continue;
27
+ if (!out.has(name)) out.set(name, info.version);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ if (data.dependencies && typeof data.dependencies === 'object') {
33
+ walkDeps(data.dependencies, out);
34
+ }
35
+
36
+ return out;
37
+ }
38
+
39
+ function walkDeps(deps, out) {
40
+ for (const [name, info] of Object.entries(deps)) {
41
+ if (info && info.version && !out.has(name)) {
42
+ out.set(name, info.version);
43
+ }
44
+ if (info && info.dependencies) walkDeps(info.dependencies, out);
45
+ }
46
+ }
47
+
48
+ export function compareVersions(a, b) {
49
+ const norm = (v) => String(v).split('-')[0].split('+')[0].split('.').map((s) => parseInt(s, 10) || 0);
50
+ const pa = norm(a);
51
+ const pb = norm(b);
52
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
53
+ const x = pa[i] || 0;
54
+ const y = pb[i] || 0;
55
+ if (x !== y) return x - y;
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ export function versionInRange(version, range) {
61
+ if (!range || !range.events || !Array.isArray(range.events)) return false;
62
+
63
+ let affected = false;
64
+ let lastIntroduced = null;
65
+ let lastFixed = null;
66
+
67
+ const events = range.events.slice().sort((e1, e2) => {
68
+ const v1 = e1.introduced || e1.fixed || e1.last_affected || '0.0.0';
69
+ const v2 = e2.introduced || e2.fixed || e2.last_affected || '0.0.0';
70
+ return compareVersions(v1, v2);
71
+ });
72
+
73
+ for (const evt of events) {
74
+ if (evt.introduced !== undefined) lastIntroduced = evt.introduced;
75
+ if (evt.fixed !== undefined) lastFixed = evt.fixed;
76
+ if (evt.last_affected !== undefined) lastFixed = evt.last_affected;
77
+ }
78
+
79
+ if (lastIntroduced !== null && compareVersions(version, lastIntroduced) >= 0) {
80
+ affected = true;
81
+ }
82
+ if (affected && lastFixed !== null && compareVersions(version, lastFixed) >= 0) {
83
+ if (range.type === 'SEMVER' || range.type === 'ECOSYSTEM') affected = false;
84
+ }
85
+
86
+ return affected;
87
+ }
88
+
89
+ export function matchAdvisory(packageName, version, advisory) {
90
+ if (!advisory || !advisory.affected) return null;
91
+
92
+ for (const aff of advisory.affected) {
93
+ const affName = aff.package?.name || aff.package_name || aff.name;
94
+ if (affName !== packageName) continue;
95
+
96
+ if (Array.isArray(aff.versions) && aff.versions.includes(version)) {
97
+ return buildMatch(packageName, version, advisory, aff);
98
+ }
99
+
100
+ if (Array.isArray(aff.ranges)) {
101
+ for (const range of aff.ranges) {
102
+ if (versionInRange(version, range)) {
103
+ return buildMatch(packageName, version, advisory, aff);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function buildMatch(packageName, version, advisory, aff) {
112
+ const fixVersions = [];
113
+ if (Array.isArray(aff.ranges)) {
114
+ for (const range of aff.ranges) {
115
+ if (Array.isArray(range.events)) {
116
+ for (const evt of range.events) {
117
+ if (evt.fixed) fixVersions.push(evt.fixed);
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ const severity = pickSeverity(advisory);
124
+
125
+ return {
126
+ package: packageName,
127
+ version,
128
+ advisory_id: advisory.id,
129
+ summary: advisory.summary || '',
130
+ severity,
131
+ fix_versions: fixVersions,
132
+ references: (advisory.references || []).map((r) => r.url || r).filter(Boolean),
133
+ };
134
+ }
135
+
136
+ function pickSeverity(advisory) {
137
+ if (Array.isArray(advisory.severity) && advisory.severity.length > 0) {
138
+ const cvss = advisory.severity.find((s) => s.type && s.type.startsWith('CVSS'));
139
+ if (cvss && cvss.score) return cvssToLabel(cvss.score);
140
+ }
141
+ if (advisory.database_specific?.severity) return String(advisory.database_specific.severity).toLowerCase();
142
+ return 'unknown';
143
+ }
144
+
145
+ function cvssToLabel(score) {
146
+ if (typeof score === 'string') {
147
+ const m = score.match(/\d+(\.\d+)?/);
148
+ if (m) score = parseFloat(m[0]);
149
+ }
150
+ if (typeof score !== 'number') return 'unknown';
151
+ if (score >= 9.0) return 'critical';
152
+ if (score >= 7.0) return 'high';
153
+ if (score >= 4.0) return 'medium';
154
+ if (score > 0) return 'low';
155
+ return 'unknown';
156
+ }
157
+
158
+ export function scanProject(rootDir, snapshot) {
159
+ const result = {
160
+ lockfile: null,
161
+ packages_scanned: 0,
162
+ matches: [],
163
+ snapshot_meta: snapshot ? { fetched_at: snapshot.fetched_at, source: snapshot.source } : null,
164
+ };
165
+
166
+ const lockPath = findLockfile(rootDir);
167
+ if (!lockPath) {
168
+ result.error = 'no_lockfile';
169
+ return result;
170
+ }
171
+ result.lockfile = lockPath;
172
+
173
+ if (!snapshot || !Array.isArray(snapshot.advisories)) {
174
+ result.error = 'no_snapshot';
175
+ return result;
176
+ }
177
+
178
+ const packages = parsePackageLockfile(lockPath);
179
+ result.packages_scanned = packages.size;
180
+
181
+ for (const [name, version] of packages) {
182
+ for (const adv of snapshot.advisories) {
183
+ const m = matchAdvisory(name, version, adv);
184
+ if (m) result.matches.push(m);
185
+ }
186
+ }
187
+
188
+ return result;
189
+ }
190
+
191
+ export function severityRank(label) {
192
+ return { critical: 4, high: 3, medium: 2, low: 1, unknown: 0 }[label] || 0;
193
+ }
194
+
195
+ export function worstSeverity(matches) {
196
+ if (!matches || matches.length === 0) return null;
197
+ return matches.reduce((acc, m) => (severityRank(m.severity) > severityRank(acc) ? m.severity : acc), 'unknown');
198
+ }
199
+
200
+ // ── Pre-install scan helpers (package.json without lockfile) ────────────────
201
+
202
+ export function parsePackageJson(filepath) {
203
+ const raw = readFileSync(filepath, 'utf-8');
204
+ const data = JSON.parse(raw);
205
+ const out = new Map();
206
+ for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
207
+ if (data[section] && typeof data[section] === 'object') {
208
+ for (const [name, range] of Object.entries(data[section])) {
209
+ if (typeof range === 'string' && !out.has(name)) out.set(name, range);
210
+ }
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ export function findPackageJson(rootDir = process.cwd()) {
217
+ const p = join(rootDir, 'package.json');
218
+ return existsSync(p) ? p : null;
219
+ }
220
+
221
+ export function matchPackageByName(packageName, advisories) {
222
+ const hits = [];
223
+ for (const adv of advisories || []) {
224
+ if (!adv.affected) continue;
225
+ for (const aff of adv.affected) {
226
+ const affName = aff.package?.name || aff.package_name || aff.name;
227
+ if (affName !== packageName) continue;
228
+
229
+ const fixVersions = [];
230
+ if (Array.isArray(aff.ranges)) {
231
+ for (const range of aff.ranges) {
232
+ if (Array.isArray(range.events)) {
233
+ for (const evt of range.events) if (evt.fixed) fixVersions.push(evt.fixed);
234
+ }
235
+ }
236
+ }
237
+ hits.push({
238
+ advisory_id: adv.id,
239
+ summary: adv.summary || '',
240
+ severity: pickSeverity(adv),
241
+ fix_versions: fixVersions,
242
+ references: (adv.references || []).map((r) => r.url || r).filter(Boolean),
243
+ });
244
+ break;
245
+ }
246
+ }
247
+ return hits;
248
+ }
249
+
250
+ function stripVersionPrefix(v) {
251
+ if (!v || typeof v !== 'string') return null;
252
+ const m = v.match(/(\d+\.\d+\.\d+[\w.\-+]*)/);
253
+ return m ? m[1] : null;
254
+ }
255
+
256
+ function isRangeSpec(v) {
257
+ if (!v || typeof v !== 'string') return false;
258
+ return /^[\^~]|\s|^[<>=]/.test(v.trim());
259
+ }
260
+
261
+ export function matchNamespaceFixture(packages, fixture) {
262
+ const now = new Date();
263
+ const hits = [];
264
+ for (const entry of fixture?.namespaces || []) {
265
+ if (entry.active_until && new Date(entry.active_until) < now) continue;
266
+ if (!entry.pattern) continue;
267
+
268
+ for (const [name, version] of packages) {
269
+ const prefixMatch = name.startsWith(entry.pattern) || name === entry.pattern;
270
+ if (!prefixMatch) continue;
271
+
272
+ // Version-aware gate (per ADR-0006):
273
+ // - Concrete version (1.2.3) → strict in-range check; skip on miss (no FP)
274
+ // - Range spec (^1.2.3, ~1.2.3, >=1.2.3) → ambiguity flag with downgrade
275
+ // (resolve-on-install could land in affected range; warn but don't block)
276
+ // - No affected_range → prefix-only, severity downgraded one tier
277
+ let versionCheck = 'no-range';
278
+ let severity = entry.severity || 'high';
279
+ if (entry.affected_range && version) {
280
+ const concrete = stripVersionPrefix(version);
281
+ if (concrete && !isRangeSpec(version)) {
282
+ if (versionInRange(concrete, entry.affected_range)) {
283
+ versionCheck = 'in-range';
284
+ } else {
285
+ continue;
286
+ }
287
+ } else if (concrete && isRangeSpec(version)) {
288
+ // package.json range — we don't know which version will resolve.
289
+ // Be cautious: flag with downgrade unless the range's lower bound is
290
+ // already beyond the fixed version (then certainly safe).
291
+ const fixedEvent = (entry.affected_range.events || []).find((e) => e.fixed);
292
+ if (fixedEvent && compareVersions(concrete, fixedEvent.fixed) >= 0) {
293
+ continue;
294
+ }
295
+ versionCheck = 'range-ambiguous';
296
+ if (severity === 'critical') severity = 'high';
297
+ } else {
298
+ versionCheck = 'unresolvable-range';
299
+ if (severity === 'critical') severity = 'high';
300
+ else if (severity === 'high') severity = 'medium';
301
+ }
302
+ } else if (!entry.affected_range) {
303
+ versionCheck = 'prefix-only';
304
+ if (severity === 'critical') severity = 'high';
305
+ }
306
+
307
+ hits.push({
308
+ package: name,
309
+ version: version || null,
310
+ namespace_pattern: entry.pattern,
311
+ reason: entry.reason,
312
+ advisory_url: entry.advisory,
313
+ active_until: entry.active_until,
314
+ guidance: entry.guidance,
315
+ severity,
316
+ version_check: versionCheck,
317
+ });
318
+ }
319
+ }
320
+ return hits;
321
+ }
@@ -0,0 +1,288 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { parsePackageLockfile, findLockfile } from './sc-scanner.mjs';
5
+
6
+ const SECURITY_DIR = '.dw/security';
7
+ const SNAPSHOT_FILE = 'advisory-snapshot.json';
8
+ const SCHEMA_VERSION = '1.0';
9
+ const OSV_BATCH_ENDPOINT = 'https://api.osv.dev/v1/querybatch';
10
+ const STALE_DAYS_DEFAULT = 7;
11
+ const FETCH_TIMEOUT_MS = 15000;
12
+
13
+ // OSV.dev /v1/querybatch hard cap is 1000 entries per request.
14
+ // Concurrency=2 + jittered backoff keeps us polite to a public free-tier API.
15
+ const OSV_BATCH_LIMIT = 1000;
16
+ const OSV_BATCH_CONCURRENCY = 2;
17
+ const OSV_BATCH_RETRY_MAX = 3;
18
+ const OSV_BATCH_RETRY_BASE_MS = 500;
19
+
20
+ export function snapshotPath(rootDir = process.cwd()) {
21
+ return join(rootDir, SECURITY_DIR, SNAPSHOT_FILE);
22
+ }
23
+
24
+ export function loadSnapshot(rootDir = process.cwd()) {
25
+ const p = snapshotPath(rootDir);
26
+ if (!existsSync(p)) return null;
27
+ try {
28
+ const raw = readFileSync(p, 'utf-8');
29
+ return JSON.parse(raw);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function snapshotAgeDays(snapshot) {
36
+ if (!snapshot || !snapshot.fetched_at) return Infinity;
37
+ const fetched = new Date(snapshot.fetched_at).getTime();
38
+ if (!fetched) return Infinity;
39
+ return (Date.now() - fetched) / (1000 * 60 * 60 * 24);
40
+ }
41
+
42
+ export function isStale(snapshot, maxDays = STALE_DAYS_DEFAULT) {
43
+ return snapshotAgeDays(snapshot) > maxDays;
44
+ }
45
+
46
+ export function isSchemaCompatible(snapshot) {
47
+ if (!snapshot || !snapshot.schema_version) return false;
48
+ return snapshot.schema_version === SCHEMA_VERSION;
49
+ }
50
+
51
+ function ensureSecurityDir(rootDir) {
52
+ const dir = join(rootDir, SECURITY_DIR);
53
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
54
+ return dir;
55
+ }
56
+
57
+ export function saveSnapshot(snapshot, rootDir = process.cwd()) {
58
+ ensureSecurityDir(rootDir);
59
+ const target = snapshotPath(rootDir);
60
+
61
+ const body = JSON.stringify(snapshot, null, 2) + '\n';
62
+ const sha = createHash('sha256').update(body).digest('hex');
63
+ snapshot.snapshot_sha = `sha256:${sha.slice(0, 16)}`;
64
+
65
+ const finalBody = JSON.stringify(snapshot, null, 2) + '\n';
66
+ writeFileSync(target, finalBody, 'utf-8');
67
+ return target;
68
+ }
69
+
70
+ export async function fetchOsvBatch(queries, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
71
+ const controller = new AbortController();
72
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
73
+ try {
74
+ const res = await fetch(OSV_BATCH_ENDPOINT, {
75
+ method: 'POST',
76
+ headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({ queries }),
78
+ signal: controller.signal,
79
+ });
80
+ if (!res.ok) {
81
+ const err = new Error(`OSV batch HTTP ${res.status}`);
82
+ err.status = res.status;
83
+ err.retryable = res.status === 429 || res.status === 503 || res.status === 502 || res.status === 504;
84
+ throw err;
85
+ }
86
+ return await res.json();
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+
92
+ function chunkArray(arr, size) {
93
+ const out = [];
94
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
95
+ return out;
96
+ }
97
+
98
+ async function sleep(ms) {
99
+ return new Promise((r) => setTimeout(r, ms));
100
+ }
101
+
102
+ async function fetchOsvBatchWithRetry(queries, chunkIndex, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
103
+ let lastErr;
104
+ for (let attempt = 0; attempt < OSV_BATCH_RETRY_MAX; attempt++) {
105
+ try {
106
+ return await fetchOsvBatch(queries, { timeoutMs });
107
+ } catch (e) {
108
+ lastErr = e;
109
+ if (!e.retryable || attempt === OSV_BATCH_RETRY_MAX - 1) break;
110
+ const jitter = Math.floor(Math.random() * 200);
111
+ const delay = OSV_BATCH_RETRY_BASE_MS * Math.pow(2, attempt) + jitter;
112
+ await sleep(delay);
113
+ }
114
+ }
115
+ // Wrap with chunk diagnostics for the synthesis layer
116
+ const wrapped = new Error(`OSV batch chunk ${chunkIndex} failed: ${lastErr.message}`);
117
+ wrapped.cause = lastErr;
118
+ wrapped.chunkIndex = chunkIndex;
119
+ wrapped.retryable = lastErr.retryable;
120
+ throw wrapped;
121
+ }
122
+
123
+ export async function fetchOsvByName(packageName, ecosystem = 'npm', { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
124
+ const controller = new AbortController();
125
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
126
+ try {
127
+ const res = await fetch('https://api.osv.dev/v1/query', {
128
+ method: 'POST',
129
+ headers: { 'content-type': 'application/json' },
130
+ body: JSON.stringify({ package: { name: packageName, ecosystem } }),
131
+ signal: controller.signal,
132
+ });
133
+ if (!res.ok) throw new Error(`OSV name-query HTTP ${res.status}`);
134
+ return await res.json();
135
+ } finally {
136
+ clearTimeout(timer);
137
+ }
138
+ }
139
+
140
+ export async function fetchOsvDetail(vulnId, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
141
+ const controller = new AbortController();
142
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
143
+ try {
144
+ const res = await fetch(`https://api.osv.dev/v1/vulns/${vulnId}`, { signal: controller.signal });
145
+ if (!res.ok) throw new Error(`OSV detail HTTP ${res.status}`);
146
+ return await res.json();
147
+ } finally {
148
+ clearTimeout(timer);
149
+ }
150
+ }
151
+
152
+ export async function syncSnapshotForProject(rootDir = process.cwd(), { ecosystem = 'npm' } = {}) {
153
+ const lockPath = findLockfile(rootDir);
154
+ if (!lockPath) {
155
+ const err = new Error('No lockfile found (expected package-lock.json)');
156
+ err.code = 'NO_LOCKFILE';
157
+ throw err;
158
+ }
159
+
160
+ const packages = parsePackageLockfile(lockPath);
161
+ const queries = [];
162
+ const queryIndex = [];
163
+ for (const [name, version] of packages) {
164
+ queries.push({ package: { name, ecosystem }, version });
165
+ queryIndex.push({ name, version });
166
+ }
167
+
168
+ if (queries.length === 0) {
169
+ const empty = buildEmptySnapshot(ecosystem);
170
+ saveSnapshot(empty, rootDir);
171
+ return { snapshot: empty, advisoryCount: 0, packageCount: 0, partial: false, chunks: { total: 0, succeeded: 0, failed: 0 } };
172
+ }
173
+
174
+ const chunks = chunkArray(queries, OSV_BATCH_LIMIT);
175
+ const chunkOutcomes = [];
176
+ // Process with bounded concurrency. allSettled so a single chunk failure
177
+ // does not discard sibling successes — critical for fail-soft behavior.
178
+ for (let i = 0; i < chunks.length; i += OSV_BATCH_CONCURRENCY) {
179
+ const slice = chunks.slice(i, i + OSV_BATCH_CONCURRENCY);
180
+ const settled = await Promise.allSettled(
181
+ slice.map((chunk, j) => fetchOsvBatchWithRetry(chunk, i + j))
182
+ );
183
+ for (let j = 0; j < settled.length; j++) {
184
+ chunkOutcomes.push({ index: i + j, ...settled[j] });
185
+ }
186
+ }
187
+
188
+ const vulnIds = new Set();
189
+ const failedChunks = [];
190
+ for (const outcome of chunkOutcomes) {
191
+ if (outcome.status === 'fulfilled') {
192
+ const batchResults = outcome.value;
193
+ if (batchResults && Array.isArray(batchResults.results)) {
194
+ for (const r of batchResults.results) {
195
+ if (r && Array.isArray(r.vulns)) {
196
+ for (const v of r.vulns) if (v.id) vulnIds.add(v.id);
197
+ }
198
+ }
199
+ }
200
+ } else {
201
+ failedChunks.push({ index: outcome.index, message: outcome.reason?.message || String(outcome.reason) });
202
+ }
203
+ }
204
+
205
+ // Hard fail only when ALL chunks failed — otherwise emit partial snapshot.
206
+ if (failedChunks.length === chunkOutcomes.length) {
207
+ const err = new Error(`All ${chunkOutcomes.length} OSV batch chunk(s) failed; first: ${failedChunks[0].message}`);
208
+ err.code = 'SYNC_ALL_CHUNKS_FAILED';
209
+ err.failedChunks = failedChunks;
210
+ throw err;
211
+ }
212
+
213
+ const advisories = [];
214
+ for (const id of vulnIds) {
215
+ try {
216
+ const detail = await fetchOsvDetail(id);
217
+ if (detail) advisories.push(detail);
218
+ } catch {
219
+ // skip — do not fail entire sync on single detail failure
220
+ }
221
+ }
222
+
223
+ const partial = failedChunks.length > 0;
224
+ const snapshot = {
225
+ schema_version: SCHEMA_VERSION,
226
+ fetched_at: new Date().toISOString(),
227
+ source: 'osv.dev',
228
+ ecosystem,
229
+ package_count: queryIndex.length,
230
+ advisory_count: advisories.length,
231
+ advisories,
232
+ partial,
233
+ chunks: {
234
+ total: chunkOutcomes.length,
235
+ succeeded: chunkOutcomes.length - failedChunks.length,
236
+ failed: failedChunks.length,
237
+ failed_indices: failedChunks.map((c) => c.index),
238
+ },
239
+ };
240
+
241
+ saveSnapshot(snapshot, rootDir);
242
+ return {
243
+ snapshot,
244
+ advisoryCount: advisories.length,
245
+ packageCount: queryIndex.length,
246
+ partial,
247
+ chunks: snapshot.chunks,
248
+ };
249
+ }
250
+
251
+ function buildEmptySnapshot(ecosystem) {
252
+ return {
253
+ schema_version: SCHEMA_VERSION,
254
+ fetched_at: new Date().toISOString(),
255
+ source: 'osv.dev',
256
+ ecosystem,
257
+ package_count: 0,
258
+ advisory_count: 0,
259
+ advisories: [],
260
+ partial: false,
261
+ chunks: { total: 0, succeeded: 0, failed: 0, failed_indices: [] },
262
+ };
263
+ }
264
+
265
+ export function snapshotInfo(rootDir = process.cwd()) {
266
+ const p = snapshotPath(rootDir);
267
+ if (!existsSync(p)) {
268
+ return { exists: false };
269
+ }
270
+ const stat = statSync(p);
271
+ const snap = loadSnapshot(rootDir);
272
+ return {
273
+ exists: true,
274
+ path: p,
275
+ mtimeMs: stat.mtimeMs,
276
+ fetched_at: snap?.fetched_at || null,
277
+ source: snap?.source || null,
278
+ ecosystem: snap?.ecosystem || null,
279
+ schema_version: snap?.schema_version || null,
280
+ advisory_count: snap?.advisory_count ?? (Array.isArray(snap?.advisories) ? snap.advisories.length : 0),
281
+ package_count: snap?.package_count ?? 0,
282
+ age_days: snap ? snapshotAgeDays(snap) : Infinity,
283
+ stale: snap ? isStale(snap) : true,
284
+ schema_compatible: isSchemaCompatible(snap),
285
+ partial: snap?.partial === true,
286
+ chunks: snap?.chunks || null,
287
+ };
288
+ }
@@ -62,11 +62,35 @@ export function summarize(events) {
62
62
  const bySkill = {};
63
63
  const byHook = {};
64
64
  const byTask = {};
65
+ const bySupplyChain = { scan_run: 0, block: 0, allow: 0, sync: 0 };
66
+ // ADR-0005 sunset review: separate OSV catches from fixture catches.
67
+ // A catch from a curated namespace fixture is NOT evidence the OSV-based
68
+ // guard worked; conflating them would compromise the 2026-08-12 retire/keep decision.
69
+ const supplyChainBySource = { osv: 0, fixture: 0, mixed: 0, unknown: 0 };
70
+ const supplyChainPartial = { partial_syncs: 0, partial_scans: 0 };
65
71
 
66
72
  for (const e of events) {
67
73
  if (e.event === 'skill') bySkill[e.name] = (bySkill[e.name] || 0) + 1;
68
74
  else if (e.event === 'hook') byHook[e.name] = (byHook[e.name] || 0) + 1;
69
75
  else if (e.event === 'task') byTask[e.action || 'unknown'] = (byTask[e.action || 'unknown'] || 0) + 1;
76
+ else if (e.event === 'sc_guard') {
77
+ const action = e.action || e.name || 'scan_run';
78
+ if (bySupplyChain[action] === undefined) bySupplyChain[action] = 0;
79
+ bySupplyChain[action]++;
80
+
81
+ // Track block/allow source for sunset-review integrity.
82
+ // Pre-install mode reports block_source explicitly (fixture vs osv vs mixed).
83
+ // Scan/JSON/update-db modes report source=osv directly.
84
+ if (action === 'block' || action === 'allow') {
85
+ const src = e.block_source || e.source || 'unknown';
86
+ const key = src === 'pre-install-mixed' ? 'mixed' : src.startsWith('fixture+') ? 'mixed' : src;
87
+ if (supplyChainBySource[key] === undefined) supplyChainBySource[key] = 0;
88
+ supplyChainBySource[key]++;
89
+ }
90
+
91
+ if (action === 'sync' && e.partial === true) supplyChainPartial.partial_syncs++;
92
+ if (action === 'scan_run' && e.partial_snapshot === true) supplyChainPartial.partial_scans++;
93
+ }
70
94
  }
71
95
 
72
96
  return {
@@ -74,6 +98,9 @@ export function summarize(events) {
74
98
  bySkill,
75
99
  byHook,
76
100
  byTask,
101
+ bySupplyChain,
102
+ supplyChainBySource,
103
+ supplyChainPartial,
77
104
  dateRange:
78
105
  events.length > 0 ? { from: events[0].ts, to: events[events.length - 1].ts } : null,
79
106
  };