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.
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { header, ok, warn, err, info, log, dry } from '../lib/ui.mjs';
5
5
  import { loadConfig, writeConfig, getToolkitVersions } from '../lib/config.mjs';
6
6
  import { diffDirs, copyDir, copyFile } from '../lib/copy.mjs';
7
+ import { ensureDwGitignore, ensureClaudeGitignore } from '../lib/gitignore.mjs';
7
8
 
8
9
  const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
9
10
 
@@ -57,6 +58,7 @@ export async function upgradeCommand(opts) {
57
58
 
58
59
  upgradeScripts(projectDir, opts);
59
60
  upgradeConfigSchema(projectDir, opts);
61
+ upgradeScopedGitignores(projectDir, opts);
60
62
 
61
63
  if (!opts.dryRun && totalChanges > 0) {
62
64
  updateVersionTracking(configPath, projectConfig, toolkitVersions);
@@ -169,6 +171,7 @@ function mergeSettingsJson(projectDir, opts) {
169
171
 
170
172
  if (opts.dryRun) {
171
173
  dry('merge .claude/settings.json');
174
+ dry('post-merge: wire supply-chain-scan.sh hook if missing (idempotent)');
172
175
  return;
173
176
  }
174
177
 
@@ -181,6 +184,57 @@ function mergeSettingsJson(projectDir, opts) {
181
184
  } catch (e) {
182
185
  warn(`settings.json merge failed: ${e.message}`);
183
186
  }
187
+
188
+ // Post-merge: explicitly install supply-chain-scan hook (ADR-0005, idempotent).
189
+ // deepMerge replaces arrays, so user's PostToolUse array may not contain the new
190
+ // hook entry. We must add it explicitly. Respects existing wiring.
191
+ installSupplyChainHookOnUpgrade(projectDir, opts);
192
+ }
193
+
194
+ function upgradeScopedGitignores(projectDir, opts) {
195
+ info('Scoped .gitignore (.dw/, .claude/)');
196
+ if (opts.dryRun) {
197
+ dry('refresh .dw/.gitignore + .claude/.gitignore managed blocks');
198
+ return;
199
+ }
200
+ try {
201
+ const dwR = ensureDwGitignore(projectDir);
202
+ if (dwR.action === 'noop') log(' .dw/.gitignore: up to date');
203
+ else ok(`.dw/.gitignore: ${dwR.action}`);
204
+
205
+ if (existsSync(join(projectDir, '.claude'))) {
206
+ const cR = ensureClaudeGitignore(projectDir);
207
+ if (cR.action === 'noop') log(' .claude/.gitignore: up to date');
208
+ else ok(`.claude/.gitignore: ${cR.action}`);
209
+ }
210
+ } catch (e) {
211
+ warn(`Scoped gitignore: ${e.message}`);
212
+ }
213
+ }
214
+
215
+ function installSupplyChainHookOnUpgrade(projectDir, opts) {
216
+ if (opts.dryRun) return;
217
+ try {
218
+ const configPath = join(projectDir, '.dw', 'config', 'dw.config.yml');
219
+ if (existsSync(configPath)) {
220
+ const cfg = readFileSync(configPath, 'utf-8');
221
+ if (/depth:\s*quick/i.test(cfg) && !/roles:\s*\[?\s*dev\s*,/i.test(cfg)) {
222
+ // Heuristic: solo preset → skip per ADR-0005 TW5
223
+ log(' Supply-chain hook: skipped (solo-style preset detected)');
224
+ log(' Enable later: `dw security-scan --install-hook`');
225
+ return;
226
+ }
227
+ }
228
+ } catch { /* fall through */ }
229
+
230
+ // Defer import (avoid loading at module top) for clean test isolation
231
+ import('../lib/sc-install.mjs').then((m) => {
232
+ const result = m.installHookInProject(projectDir);
233
+ if (result.ok && result.action === 'added') {
234
+ ok('Supply-chain guard hook wired (ADR-0005 — opt-in available since v1.3.5)');
235
+ log(' First scan: `dw security-scan --update-db`');
236
+ }
237
+ }).catch(() => { /* silent — installation is best-effort */ });
184
238
  }
185
239
 
186
240
  function deepMerge(base, override) {
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+
4
+ const MARKER_START = '# >>> dw-kit managed >>>';
5
+ const MARKER_END = '# <<< dw-kit managed <<<';
6
+
7
+ const DW_GITIGNORE_BLOCK = [
8
+ MARKER_START,
9
+ '# dw-kit framework files — regenerated by `dw init` / `dw upgrade`.',
10
+ '# Do NOT commit. Update dw-kit regularly via `dw upgrade`.',
11
+ 'adapters/',
12
+ 'core/',
13
+ 'security/',
14
+ '',
15
+ '# Config directory: ignore framework files, keep user dw.config.yml tracked',
16
+ 'config/*',
17
+ '!config/dw.config.yml',
18
+ '!config/.gitignore',
19
+ 'config/dw.config.local.yml',
20
+ '',
21
+ '# Local-only telemetry (machine-specific, has session hashes)',
22
+ 'metrics/',
23
+ MARKER_END,
24
+ ];
25
+
26
+ const CLAUDE_GITIGNORE_BLOCK = [
27
+ MARKER_START,
28
+ '# dw-kit framework files — regenerated by `dw init` / `dw upgrade`.',
29
+ '# Do NOT commit. Update dw-kit regularly via `dw upgrade`.',
30
+ 'agents/',
31
+ 'hooks/',
32
+ 'rules/',
33
+ 'skills/',
34
+ 'templates/',
35
+ '',
36
+ '# Local-only override (also in root .gitignore for safety)',
37
+ 'settings.local.json',
38
+ MARKER_END,
39
+ ];
40
+
41
+ function ensureDir(filepath) {
42
+ const dir = dirname(filepath);
43
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
44
+ }
45
+
46
+ function applyManagedBlock(content, blockLines) {
47
+ const block = blockLines.join('\n') + '\n';
48
+ if (!content) return block;
49
+
50
+ // If markers already present, replace block in-place (idempotent + upgrade-friendly)
51
+ const startIdx = content.indexOf(MARKER_START);
52
+ const endIdx = content.indexOf(MARKER_END);
53
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
54
+ const before = content.slice(0, startIdx);
55
+ const after = content.slice(endIdx + MARKER_END.length);
56
+ // Collapse multiple consecutive blanks AND normalize trailing whitespace
57
+ // so re-running the helper is byte-identical (required by smoke idempotency test).
58
+ return (before + block + after)
59
+ .replace(/\n{3,}/g, '\n\n')
60
+ .replace(/\n+$/, '\n');
61
+ }
62
+
63
+ // Markers not present — append block
64
+ const sep = content.endsWith('\n') ? '\n' : '\n\n';
65
+ return content + sep + block;
66
+ }
67
+
68
+ function writeGitignore(targetPath, blockLines) {
69
+ ensureDir(targetPath);
70
+ const current = existsSync(targetPath) ? readFileSync(targetPath, 'utf-8') : '';
71
+ const updated = applyManagedBlock(current, blockLines);
72
+ if (updated === current) return { action: 'noop', path: targetPath };
73
+ writeFileSync(targetPath, updated, 'utf-8');
74
+ return { action: current ? 'updated' : 'created', path: targetPath };
75
+ }
76
+
77
+ export function ensureDwGitignore(projectDir = process.cwd()) {
78
+ return writeGitignore(join(projectDir, '.dw', '.gitignore'), DW_GITIGNORE_BLOCK);
79
+ }
80
+
81
+ export function ensureClaudeGitignore(projectDir = process.cwd()) {
82
+ return writeGitignore(join(projectDir, '.claude', '.gitignore'), CLAUDE_GITIGNORE_BLOCK);
83
+ }
84
+
85
+ export function ensureBothGitignores(projectDir = process.cwd()) {
86
+ return {
87
+ dw: ensureDwGitignore(projectDir),
88
+ claude: ensureClaudeGitignore(projectDir),
89
+ };
90
+ }
@@ -0,0 +1,159 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+
4
+ const REGISTRY_BASE = 'https://registry.npmjs.org';
5
+ const FETCH_TIMEOUT_MS = 5000;
6
+ const CACHE_REL = '.dw/security/npm-registry-cache.jsonl';
7
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour per ADR-0006
8
+
9
+ export function cachePath(rootDir = process.cwd()) {
10
+ return join(rootDir, CACHE_REL);
11
+ }
12
+
13
+ function ensureCacheDir(rootDir) {
14
+ const dir = dirname(cachePath(rootDir));
15
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
16
+ }
17
+
18
+ export function loadCache(rootDir = process.cwd()) {
19
+ const p = cachePath(rootDir);
20
+ if (!existsSync(p)) return new Map();
21
+ const map = new Map();
22
+ try {
23
+ const lines = readFileSync(p, 'utf-8').split('\n').filter(Boolean);
24
+ for (const line of lines) {
25
+ try {
26
+ const e = JSON.parse(line);
27
+ if (e && e.key) map.set(e.key, e);
28
+ } catch {
29
+ // skip malformed line
30
+ }
31
+ }
32
+ } catch {
33
+ // unreadable cache → ignore
34
+ }
35
+ return map;
36
+ }
37
+
38
+ export function cacheGet(rootDir, key, ttlMs = CACHE_TTL_MS) {
39
+ const map = loadCache(rootDir);
40
+ const entry = map.get(key);
41
+ if (!entry) return null;
42
+ if (ttlMs <= 0) return null;
43
+ if (Date.now() - entry.cached_at > ttlMs) return null;
44
+ return entry.value;
45
+ }
46
+
47
+ export function cachePut(rootDir, key, value) {
48
+ ensureCacheDir(rootDir);
49
+ const line = JSON.stringify({ key, cached_at: Date.now(), value }) + '\n';
50
+ appendFileSync(cachePath(rootDir), line, 'utf-8');
51
+ }
52
+
53
+ export function pruneCache(rootDir, ttlMs = CACHE_TTL_MS) {
54
+ const p = cachePath(rootDir);
55
+ if (!existsSync(p)) return;
56
+ const map = loadCache(rootDir);
57
+ const now = Date.now();
58
+ const fresh = [];
59
+ for (const e of map.values()) {
60
+ if (now - e.cached_at <= ttlMs) fresh.push(e);
61
+ }
62
+ ensureCacheDir(rootDir);
63
+ writeFileSync(p, fresh.map((e) => JSON.stringify(e)).join('\n') + (fresh.length ? '\n' : ''), 'utf-8');
64
+ }
65
+
66
+ async function fetchJson(url, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
67
+ const controller = new AbortController();
68
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
69
+ try {
70
+ const res = await fetch(url, {
71
+ headers: { accept: 'application/json' },
72
+ signal: controller.signal,
73
+ });
74
+ if (!res.ok) {
75
+ const err = new Error(`npm registry HTTP ${res.status}`);
76
+ err.status = res.status;
77
+ throw err;
78
+ }
79
+ return await res.json();
80
+ } finally {
81
+ clearTimeout(timer);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Fetch the full registry document for a package. Cached.
87
+ * Returns the raw npm registry response or null on hard failure.
88
+ */
89
+ export async function fetchPackageMetadata(packageName, rootDir = process.cwd(), { useCache = true } = {}) {
90
+ const key = `pkg:${packageName}`;
91
+ if (useCache) {
92
+ const cached = cacheGet(rootDir, key);
93
+ if (cached) return cached;
94
+ }
95
+ try {
96
+ const data = await fetchJson(`${REGISTRY_BASE}/${encodeURIComponent(packageName).replace('%40', '@')}`);
97
+ cachePut(rootDir, key, data);
98
+ return data;
99
+ } catch (e) {
100
+ if (e.status === 404) return null;
101
+ throw e;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Extract the signals needed by the heuristic scorer (ADR-0006 pillar 3).
107
+ * Pure function — call after fetchPackageMetadata returned a document.
108
+ */
109
+ export function extractSignals(metadata, version) {
110
+ if (!metadata) return null;
111
+ const timeMap = metadata.time || {};
112
+ const publishIso = timeMap[version] || null;
113
+ const publishedAt = publishIso ? new Date(publishIso).getTime() : null;
114
+ const ageHours = publishedAt ? (Date.now() - publishedAt) / (1000 * 60 * 60) : null;
115
+
116
+ // Last modified time on the package as a whole — proxy for maintainer activity
117
+ const modifiedIso = timeMap.modified || null;
118
+ const modifiedAt = modifiedIso ? new Date(modifiedIso).getTime() : null;
119
+
120
+ // Maintainer set
121
+ const maintainers = Array.isArray(metadata.maintainers)
122
+ ? metadata.maintainers.map((m) => (typeof m === 'string' ? m : m.name)).filter(Boolean)
123
+ : [];
124
+
125
+ // Latest version on dist-tags
126
+ const latestVersion = metadata['dist-tags']?.latest || null;
127
+
128
+ return {
129
+ package: metadata.name,
130
+ version,
131
+ publish_iso: publishIso,
132
+ publish_age_hours: ageHours,
133
+ latest_version: latestVersion,
134
+ modified_iso: modifiedIso,
135
+ package_modified_age_days: modifiedAt ? (Date.now() - modifiedAt) / (1000 * 60 * 60 * 24) : null,
136
+ maintainer_count: maintainers.length,
137
+ maintainers,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Lightweight popularity probe via downloads API. Cached separately.
143
+ * Returns weekly downloads count or null on failure.
144
+ */
145
+ export async function fetchWeeklyDownloads(packageName, rootDir = process.cwd(), { useCache = true } = {}) {
146
+ const key = `dl:${packageName}`;
147
+ if (useCache) {
148
+ const cached = cacheGet(rootDir, key);
149
+ if (cached) return cached;
150
+ }
151
+ try {
152
+ const data = await fetchJson(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName).replace('%40', '@')}`);
153
+ const count = typeof data?.downloads === 'number' ? data.downloads : null;
154
+ cachePut(rootDir, key, count);
155
+ return count;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
@@ -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
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const HOOK_COMMAND = 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/supply-chain-scan.sh"';
5
+ const MATCHER = 'Write|Edit';
6
+
7
+ export function settingsPath(rootDir = process.cwd()) {
8
+ return join(rootDir, '.claude', 'settings.json');
9
+ }
10
+
11
+ export function isHookWired(settings) {
12
+ if (!settings?.hooks?.PostToolUse) return false;
13
+ for (const group of settings.hooks.PostToolUse) {
14
+ if (group.matcher !== MATCHER && !(group.matcher || '').split('|').includes('Write')) continue;
15
+ if (!Array.isArray(group.hooks)) continue;
16
+ for (const h of group.hooks) {
17
+ if (h.command && h.command.includes('supply-chain-scan.sh')) return true;
18
+ }
19
+ }
20
+ return false;
21
+ }
22
+
23
+ export function wireHook(settings) {
24
+ settings.hooks = settings.hooks || {};
25
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse || [];
26
+
27
+ let group = settings.hooks.PostToolUse.find((g) => g.matcher === MATCHER);
28
+ if (!group) {
29
+ group = { matcher: MATCHER, hooks: [] };
30
+ settings.hooks.PostToolUse.push(group);
31
+ }
32
+ group.hooks = group.hooks || [];
33
+
34
+ for (const h of group.hooks) {
35
+ if (h.command && h.command.includes('supply-chain-scan.sh')) {
36
+ return { action: 'noop', reason: 'already wired' };
37
+ }
38
+ }
39
+
40
+ group.hooks.push({ type: 'command', command: HOOK_COMMAND });
41
+ return { action: 'added' };
42
+ }
43
+
44
+ export function unwireHook(settings) {
45
+ if (!settings?.hooks?.PostToolUse) return { action: 'noop', reason: 'no PostToolUse' };
46
+
47
+ let removed = 0;
48
+ for (const group of settings.hooks.PostToolUse) {
49
+ if (!Array.isArray(group.hooks)) continue;
50
+ const before = group.hooks.length;
51
+ group.hooks = group.hooks.filter((h) => !(h.command && h.command.includes('supply-chain-scan.sh')));
52
+ removed += before - group.hooks.length;
53
+ }
54
+ return removed > 0 ? { action: 'removed', count: removed } : { action: 'noop', reason: 'not wired' };
55
+ }
56
+
57
+ export function installHookInProject(rootDir = process.cwd()) {
58
+ const p = settingsPath(rootDir);
59
+ if (!existsSync(p)) {
60
+ return { ok: false, error: 'settings.json not found — run `dw init` first', path: p };
61
+ }
62
+ let settings;
63
+ try {
64
+ settings = JSON.parse(readFileSync(p, 'utf-8'));
65
+ } catch (e) {
66
+ return { ok: false, error: `failed to parse settings.json: ${e.message}`, path: p };
67
+ }
68
+
69
+ const result = wireHook(settings);
70
+ if (result.action === 'added') {
71
+ writeFileSync(p, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
72
+ }
73
+ return { ok: true, ...result, path: p };
74
+ }
75
+
76
+ export function uninstallHookFromProject(rootDir = process.cwd()) {
77
+ const p = settingsPath(rootDir);
78
+ if (!existsSync(p)) {
79
+ return { ok: false, error: 'settings.json not found', path: p };
80
+ }
81
+ let settings;
82
+ try {
83
+ settings = JSON.parse(readFileSync(p, 'utf-8'));
84
+ } catch (e) {
85
+ return { ok: false, error: `failed to parse settings.json: ${e.message}`, path: p };
86
+ }
87
+
88
+ const result = unwireHook(settings);
89
+ if (result.action === 'removed') {
90
+ writeFileSync(p, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
91
+ }
92
+ return { ok: true, ...result, path: p };
93
+ }