dw-kit 1.3.5 → 1.4.0

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,68 @@
1
+ /**
2
+ * Sanitize an arbitrary scope string (branch name, task name, free-form label)
3
+ * into a filesystem-safe directory slug usable on Windows + POSIX.
4
+ *
5
+ * Rules:
6
+ * - Strip filesystem-illegal chars on Windows (NTFS): / \ : * ? " < > |
7
+ * - Collapse whitespace + control chars to single dash
8
+ * - Collapse runs of dashes/underscores/dots
9
+ * - Trim leading/trailing dashes, underscores, dots
10
+ * - Forbid reserved Windows device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
11
+ * by prefixing with `_` if matched
12
+ * - Cap length at maxLength (default 80)
13
+ * - Lowercase optional (default keeps case; renderer config can opt-in)
14
+ *
15
+ * Examples:
16
+ * "fix/sc-guard-v1.3.6" → "fix-sc-guard-v1.3.6"
17
+ * "feat: thêm tính năng" → "feat-thêm-tính-năng" (Unicode preserved)
18
+ * " multiple spaces " → "multiple-spaces"
19
+ * "CON" → "_CON"
20
+ * "../../etc/passwd" → "etc-passwd"
21
+ *
22
+ * @param {string} scope - raw scope string
23
+ * @param {{maxLength?: number, lowercase?: boolean}} [opts]
24
+ * @returns {string} sanitized slug
25
+ * @throws {Error} if input is empty after sanitization
26
+ */
27
+ export function scopeSlug(scope, opts = {}) {
28
+ const { maxLength = 80, lowercase = false } = opts;
29
+
30
+ if (typeof scope !== 'string') {
31
+ throw new Error('scope must be a string');
32
+ }
33
+
34
+ let s = scope;
35
+
36
+ // Strip Windows-illegal chars + control chars (0x00-0x1F, 0x7F).
37
+ s = s.replace(/[\\/:*?"<>|\x00-\x1F\x7F]+/g, '-');
38
+
39
+ // Strip path traversal segments.
40
+ s = s.replace(/\.\.+/g, '-');
41
+
42
+ // Collapse whitespace runs to single dash.
43
+ s = s.replace(/\s+/g, '-');
44
+
45
+ // Collapse runs of separators (dash/underscore/dot) — keep one.
46
+ s = s.replace(/[-_.]{2,}/g, (m) => m[0]);
47
+
48
+ // Trim leading/trailing separators.
49
+ s = s.replace(/^[-_.]+|[-_.]+$/g, '');
50
+
51
+ if (lowercase) s = s.toLowerCase();
52
+
53
+ // Reserved Windows device names (case-insensitive).
54
+ if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(s)) {
55
+ s = `_${s}`;
56
+ }
57
+
58
+ // Cap length.
59
+ if (s.length > maxLength) {
60
+ s = s.slice(0, maxLength).replace(/[-_.]+$/g, '');
61
+ }
62
+
63
+ if (s.length === 0) {
64
+ throw new Error('scope sanitization produced empty slug — provide a non-empty alphanumeric scope');
65
+ }
66
+
67
+ return s;
68
+ }
@@ -0,0 +1,263 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parsePackageLockfile, findLockfile, compareVersions } from './sc-scanner.mjs';
5
+
6
+ const DEFAULT_THRESHOLD = 50;
7
+ const DEFAULT_WEEKLY_DOWNLOADS_MIN = 10000;
8
+ const DEFAULT_RECENT_PUBLISH_HOURS = 72;
9
+
10
+ // Top-500 popular npm packages (small bundled list for typo-squat detection).
11
+ // Source: npm download counts, December 2025 snapshot. Pruned to common surface.
12
+ // Not exhaustive — typo-squat is one signal of many.
13
+ const POPULAR_PACKAGES = new Set([
14
+ 'react', 'react-dom', 'vue', 'angular', 'svelte', 'next', 'nuxt',
15
+ 'lodash', 'axios', 'express', 'fastify', 'koa', 'hapi',
16
+ 'typescript', 'eslint', 'prettier', 'webpack', 'vite', 'rollup', 'esbuild',
17
+ 'jest', 'mocha', 'vitest', 'playwright', 'cypress', 'puppeteer',
18
+ 'chalk', 'commander', 'yargs', 'inquirer', 'enquirer',
19
+ 'tailwindcss', 'postcss', 'sass', 'less',
20
+ 'tanstack', '@tanstack/react-query', '@tanstack/react-router', '@tanstack/react-table',
21
+ 'redux', '@reduxjs/toolkit', 'zustand', 'jotai', 'recoil',
22
+ 'graphql', 'apollo-client', '@apollo/client', 'urql',
23
+ 'rxjs', 'immer', 'lodash-es', 'date-fns', 'moment', 'dayjs', 'luxon',
24
+ 'cors', 'helmet', 'jsonwebtoken', 'bcrypt', 'argon2',
25
+ 'mongoose', 'prisma', '@prisma/client', 'sequelize', 'typeorm', 'knex',
26
+ 'pg', 'mysql', 'mysql2', 'sqlite3', 'better-sqlite3',
27
+ 'redis', 'ioredis',
28
+ 'dotenv', 'uuid', 'nanoid', 'pino', 'winston', 'debug',
29
+ 'zod', 'yup', 'joi', 'ajv',
30
+ 'sharp', 'jimp', 'multer',
31
+ 'socket.io', 'ws', 'engine.io',
32
+ 'pm2', 'nodemon', 'concurrently', 'cross-env',
33
+ 'firebase', 'firebase-admin', '@supabase/supabase-js',
34
+ 'stripe', '@stripe/stripe-js',
35
+ ]);
36
+
37
+ export function loadHeuristicConfig(rootDir = process.cwd()) {
38
+ // Read .dw/config/dw.config.yml security.heuristic.* if present.
39
+ // Cheap inline YAML probe — no js-yaml dep here. Caller may pass overrides.
40
+ try {
41
+ const cfgPath = join(rootDir, '.dw/config/dw.config.yml');
42
+ if (!existsSync(cfgPath)) return defaultConfig();
43
+ const raw = readFileSync(cfgPath, 'utf-8');
44
+ const threshold = matchYamlNumber(raw, 'risk_threshold');
45
+ const weekly = matchYamlNumber(raw, 'weekly_downloads_min');
46
+ const recent = matchYamlNumber(raw, 'recent_publish_hours');
47
+ return {
48
+ risk_threshold: threshold ?? DEFAULT_THRESHOLD,
49
+ weekly_downloads_min: weekly ?? DEFAULT_WEEKLY_DOWNLOADS_MIN,
50
+ recent_publish_hours: recent ?? DEFAULT_RECENT_PUBLISH_HOURS,
51
+ };
52
+ } catch {
53
+ return defaultConfig();
54
+ }
55
+ }
56
+
57
+ function defaultConfig() {
58
+ return {
59
+ risk_threshold: DEFAULT_THRESHOLD,
60
+ weekly_downloads_min: DEFAULT_WEEKLY_DOWNLOADS_MIN,
61
+ recent_publish_hours: DEFAULT_RECENT_PUBLISH_HOURS,
62
+ };
63
+ }
64
+
65
+ function matchYamlNumber(yamlText, key) {
66
+ // Scoped inside security.heuristic.*
67
+ // Avoid full YAML parser dep; brittle but enough for top-level integer keys.
68
+ const re = new RegExp(`heuristic:[\\s\\S]*?\\b${key}\\s*:\\s*(\\d+)`, 'm');
69
+ const m = yamlText.match(re);
70
+ return m ? parseInt(m[1], 10) : null;
71
+ }
72
+
73
+ /**
74
+ * List packages introduced or version-bumped since the last committed lockfile.
75
+ *
76
+ * Strategy:
77
+ * 1. If git available and HEAD has a previous lockfile → diff against HEAD
78
+ * 2. Otherwise → return ALL packages in current lockfile (cold-start path,
79
+ * caller can cap how many to actually probe)
80
+ *
81
+ * Returns Array<{ name, version, change: 'added' | 'bumped', from?: string }>.
82
+ */
83
+ export function diffLockfilePackages(rootDir = process.cwd()) {
84
+ const lockPath = findLockfile(rootDir);
85
+ if (!lockPath) return [];
86
+ const currentPkgs = parsePackageLockfile(lockPath);
87
+
88
+ let previousPkgs = null;
89
+ try {
90
+ // Best-effort: read previous lockfile content from git HEAD
91
+ const lockRelPath = lockPath.startsWith(rootDir + '\\') || lockPath.startsWith(rootDir + '/')
92
+ ? lockPath.slice(rootDir.length + 1).replace(/\\/g, '/')
93
+ : 'package-lock.json';
94
+ const previousContent = execSync(`git show HEAD:${lockRelPath}`, {
95
+ cwd: rootDir,
96
+ encoding: 'utf-8',
97
+ stdio: ['ignore', 'pipe', 'ignore'],
98
+ });
99
+ previousPkgs = parsePackageLockfileContent(previousContent);
100
+ } catch {
101
+ // No git, or HEAD has no lockfile → cold start
102
+ previousPkgs = null;
103
+ }
104
+
105
+ const out = [];
106
+ if (!previousPkgs) {
107
+ // Cold start: caller decides how to handle a full lockfile
108
+ for (const [name, version] of currentPkgs) {
109
+ out.push({ name, version, change: 'cold-start' });
110
+ }
111
+ return out;
112
+ }
113
+
114
+ for (const [name, version] of currentPkgs) {
115
+ if (!previousPkgs.has(name)) {
116
+ out.push({ name, version, change: 'added' });
117
+ } else {
118
+ const prev = previousPkgs.get(name);
119
+ if (prev !== version) {
120
+ out.push({ name, version, change: 'bumped', from: prev });
121
+ }
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ function parsePackageLockfileContent(content) {
128
+ const lock = JSON.parse(content);
129
+ const packages = new Map();
130
+ if (lock.packages) {
131
+ for (const [k, v] of Object.entries(lock.packages)) {
132
+ if (!k || !v?.version) continue;
133
+ const name = k.startsWith('node_modules/') ? k.slice('node_modules/'.length) : k.split('node_modules/').pop();
134
+ if (!name || name === '') continue;
135
+ packages.set(name, v.version);
136
+ }
137
+ } else if (lock.dependencies) {
138
+ function walk(deps) {
139
+ for (const [n, v] of Object.entries(deps)) {
140
+ if (v?.version) packages.set(n, v.version);
141
+ if (v?.dependencies) walk(v.dependencies);
142
+ }
143
+ }
144
+ walk(lock.dependencies);
145
+ }
146
+ return packages;
147
+ }
148
+
149
+ /**
150
+ * Score one package's signals into a risk number + reason breakdown.
151
+ *
152
+ * Returns { score, reasons: [{signal, weight, detail}], blocking: bool }
153
+ *
154
+ * Signals (per ADR-0006):
155
+ * - very_recent_publish (<24h) +50
156
+ * - recent_publish (<72h) +30
157
+ * - popular_package (downloads≥10k) +20
158
+ * - maintainer_change_recent (<30d) +40
159
+ * - major_version_jump +15
160
+ * - typo_squat (Lev=1 of popular) +60
161
+ */
162
+ export function scoreSignals(signals, weeklyDownloads, config = defaultConfig(), context = {}) {
163
+ if (!signals) return { score: 0, reasons: [] };
164
+ const reasons = [];
165
+ let score = 0;
166
+
167
+ const age = signals.publish_age_hours;
168
+ if (age !== null && age !== undefined) {
169
+ if (age < 24) {
170
+ score += 50;
171
+ reasons.push({ signal: 'very_recent_publish', weight: 50, detail: `${age.toFixed(1)}h since publish` });
172
+ } else if (age < config.recent_publish_hours) {
173
+ score += 30;
174
+ reasons.push({ signal: 'recent_publish', weight: 30, detail: `${age.toFixed(1)}h since publish (threshold ${config.recent_publish_hours}h)` });
175
+ }
176
+ }
177
+
178
+ if (typeof weeklyDownloads === 'number' && weeklyDownloads >= config.weekly_downloads_min) {
179
+ score += 20;
180
+ reasons.push({ signal: 'popular_package', weight: 20, detail: `${weeklyDownloads.toLocaleString()} weekly downloads` });
181
+ }
182
+
183
+ // Maintainer-change proxy: package-level "modified" time within 30d
184
+ if (signals.package_modified_age_days !== null && signals.package_modified_age_days !== undefined) {
185
+ if (signals.package_modified_age_days < 30) {
186
+ score += 40;
187
+ reasons.push({
188
+ signal: 'maintainer_change_recent',
189
+ weight: 40,
190
+ detail: `package modified ${signals.package_modified_age_days.toFixed(1)}d ago — possible maintainer/token change`,
191
+ });
192
+ }
193
+ }
194
+
195
+ // Major-version-jump (caller passes context.from from diff)
196
+ if (context.change === 'bumped' && context.from && signals.version) {
197
+ try {
198
+ const fromMajor = parseInt(context.from.match(/\d+/)?.[0] || '0', 10);
199
+ const toMajor = parseInt(signals.version.match(/\d+/)?.[0] || '0', 10);
200
+ if (toMajor - fromMajor >= 1) {
201
+ score += 15;
202
+ reasons.push({
203
+ signal: 'major_version_jump',
204
+ weight: 15,
205
+ detail: `bumped from ${context.from} to ${signals.version}`,
206
+ });
207
+ }
208
+ } catch {
209
+ // skip
210
+ }
211
+ }
212
+
213
+ // Typo-squat detection (offline, bundled popular list)
214
+ const ts = detectTypoSquat(signals.package);
215
+ if (ts) {
216
+ score += 60;
217
+ reasons.push({ signal: 'typo_squat', weight: 60, detail: `name within Lev=1 of popular "${ts}"` });
218
+ }
219
+
220
+ return { score, reasons };
221
+ }
222
+
223
+ function detectTypoSquat(name) {
224
+ if (!name) return null;
225
+ if (POPULAR_PACKAGES.has(name)) return null;
226
+ // Strip scope for comparison; typo-squats often target unscoped popular names
227
+ const bare = name.includes('/') ? name.split('/').pop() : name;
228
+ for (const pop of POPULAR_PACKAGES) {
229
+ const popBare = pop.includes('/') ? pop.split('/').pop() : pop;
230
+ if (Math.abs(bare.length - popBare.length) > 2) continue;
231
+ if (levenshtein(bare, popBare) === 1) return pop;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ function levenshtein(a, b) {
237
+ if (a === b) return 0;
238
+ if (!a.length) return b.length;
239
+ if (!b.length) return a.length;
240
+ const prev = new Array(b.length + 1);
241
+ const curr = new Array(b.length + 1);
242
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
243
+ for (let i = 1; i <= a.length; i++) {
244
+ curr[0] = i;
245
+ for (let j = 1; j <= b.length; j++) {
246
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
247
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
248
+ }
249
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
250
+ }
251
+ return prev[b.length];
252
+ }
253
+
254
+ export function formatHeuristicHit(pkg, version, change, scoring) {
255
+ const lines = [];
256
+ const tag = scoring.score >= 80 ? '[HIGH-RISK]' : scoring.score >= 50 ? '[REVIEW]' : '[INFO]';
257
+ const changeLabel = change === 'bumped' ? `bumped to ${version}` : change === 'added' ? `added@${version}` : `${version}`;
258
+ lines.push(`${tag} ${pkg} ${changeLabel} — risk_score: ${scoring.score}`);
259
+ for (const r of scoring.reasons) {
260
+ lines.push(` · ${r.signal} (+${r.weight}): ${r.detail}`);
261
+ }
262
+ return lines.join('\n');
263
+ }
@@ -247,6 +247,17 @@ export function matchPackageByName(packageName, advisories) {
247
247
  return hits;
248
248
  }
249
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
+
250
261
  export function matchNamespaceFixture(packages, fixture) {
251
262
  const now = new Date();
252
263
  const hits = [];
@@ -254,18 +265,56 @@ export function matchNamespaceFixture(packages, fixture) {
254
265
  if (entry.active_until && new Date(entry.active_until) < now) continue;
255
266
  if (!entry.pattern) continue;
256
267
 
257
- for (const [name] of packages) {
258
- if (name.startsWith(entry.pattern) || name === entry.pattern) {
259
- hits.push({
260
- package: name,
261
- namespace_pattern: entry.pattern,
262
- reason: entry.reason,
263
- advisory_url: entry.advisory,
264
- active_until: entry.active_until,
265
- guidance: entry.guidance,
266
- severity: entry.severity || 'high',
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';
268
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
+ });
269
318
  }
270
319
  }
271
320
  return hits;
@@ -10,6 +10,13 @@ const OSV_BATCH_ENDPOINT = 'https://api.osv.dev/v1/querybatch';
10
10
  const STALE_DAYS_DEFAULT = 7;
11
11
  const FETCH_TIMEOUT_MS = 15000;
12
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
+
13
20
  export function snapshotPath(rootDir = process.cwd()) {
14
21
  return join(rootDir, SECURITY_DIR, SNAPSHOT_FILE);
15
22
  }
@@ -70,13 +77,49 @@ export async function fetchOsvBatch(queries, { timeoutMs = FETCH_TIMEOUT_MS } =
70
77
  body: JSON.stringify({ queries }),
71
78
  signal: controller.signal,
72
79
  });
73
- if (!res.ok) throw new Error(`OSV batch HTTP ${res.status}`);
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
+ }
74
86
  return await res.json();
75
87
  } finally {
76
88
  clearTimeout(timer);
77
89
  }
78
90
  }
79
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
+
80
123
  export async function fetchOsvByName(packageName, ecosystem = 'npm', { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
81
124
  const controller = new AbortController();
82
125
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -125,19 +168,48 @@ export async function syncSnapshotForProject(rootDir = process.cwd(), { ecosyste
125
168
  if (queries.length === 0) {
126
169
  const empty = buildEmptySnapshot(ecosystem);
127
170
  saveSnapshot(empty, rootDir);
128
- return { snapshot: empty, advisoryCount: 0, packageCount: 0 };
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
+ }
129
186
  }
130
187
 
131
- const batchResults = await fetchOsvBatch(queries);
132
188
  const vulnIds = new Set();
133
- if (batchResults && Array.isArray(batchResults.results)) {
134
- for (const r of batchResults.results) {
135
- if (r && Array.isArray(r.vulns)) {
136
- for (const v of r.vulns) if (v.id) vulnIds.add(v.id);
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
+ }
137
199
  }
200
+ } else {
201
+ failedChunks.push({ index: outcome.index, message: outcome.reason?.message || String(outcome.reason) });
138
202
  }
139
203
  }
140
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
+
141
213
  const advisories = [];
142
214
  for (const id of vulnIds) {
143
215
  try {
@@ -148,6 +220,7 @@ export async function syncSnapshotForProject(rootDir = process.cwd(), { ecosyste
148
220
  }
149
221
  }
150
222
 
223
+ const partial = failedChunks.length > 0;
151
224
  const snapshot = {
152
225
  schema_version: SCHEMA_VERSION,
153
226
  fetched_at: new Date().toISOString(),
@@ -156,10 +229,23 @@ export async function syncSnapshotForProject(rootDir = process.cwd(), { ecosyste
156
229
  package_count: queryIndex.length,
157
230
  advisory_count: advisories.length,
158
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
+ },
159
239
  };
160
240
 
161
241
  saveSnapshot(snapshot, rootDir);
162
- return { snapshot, advisoryCount: advisories.length, packageCount: queryIndex.length };
242
+ return {
243
+ snapshot,
244
+ advisoryCount: advisories.length,
245
+ packageCount: queryIndex.length,
246
+ partial,
247
+ chunks: snapshot.chunks,
248
+ };
163
249
  }
164
250
 
165
251
  function buildEmptySnapshot(ecosystem) {
@@ -171,6 +257,8 @@ function buildEmptySnapshot(ecosystem) {
171
257
  package_count: 0,
172
258
  advisory_count: 0,
173
259
  advisories: [],
260
+ partial: false,
261
+ chunks: { total: 0, succeeded: 0, failed: 0, failed_indices: [] },
174
262
  };
175
263
  }
176
264
 
@@ -194,5 +282,7 @@ export function snapshotInfo(rootDir = process.cwd()) {
194
282
  age_days: snap ? snapshotAgeDays(snap) : Infinity,
195
283
  stale: snap ? isStale(snap) : true,
196
284
  schema_compatible: isSchemaCompatible(snap),
285
+ partial: snap?.partial === true,
286
+ chunks: snap?.chunks || null,
197
287
  };
198
288
  }
@@ -63,6 +63,11 @@ export function summarize(events) {
63
63
  const byHook = {};
64
64
  const byTask = {};
65
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 };
66
71
 
67
72
  for (const e of events) {
68
73
  if (e.event === 'skill') bySkill[e.name] = (bySkill[e.name] || 0) + 1;
@@ -72,6 +77,19 @@ export function summarize(events) {
72
77
  const action = e.action || e.name || 'scan_run';
73
78
  if (bySupplyChain[action] === undefined) bySupplyChain[action] = 0;
74
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++;
75
93
  }
76
94
  }
77
95
 
@@ -81,6 +99,8 @@ export function summarize(events) {
81
99
  byHook,
82
100
  byTask,
83
101
  bySupplyChain,
102
+ supplyChainBySource,
103
+ supplyChainPartial,
84
104
  dateRange:
85
105
  events.length > 0 ? { from: events[0].ts, to: events[events.length - 1].ts } : null,
86
106
  };