sigmap 7.10.0 → 7.12.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.
package/gen-context.js CHANGED
@@ -25,222 +25,245 @@ function __require(key) {
25
25
  // ── ./src/conventions/conflicts ──
26
26
  // ── ./src/conventions/inject ──
27
27
  // ── ./src/scaffold/propose ──
28
- __factories["./src/scaffold/propose"] = function(module, exports) {
28
+ // ── ./src/plan/verify-plan ──
29
+ // ── ./src/cache/freshen ──
30
+ // ── ./src/review/review-pr ──
31
+ __factories["./src/review/review-pr"] = function(module, exports) {
29
32
 
30
33
  /**
31
- * Scaffold proposal with a confidence floor (IMPL.md §5Cause 3).
34
+ * review-pr (IMPL.md §6 step 4 last guard stage of the `create` pipeline).
32
35
  *
33
- * Proposes a convention-matched structure for a new module filename in the
34
- * repo's dominant naming style, the export style to use, and a matching test
35
- * file but only when the conventions are consistent enough. Below a hard
36
- * floor it refuses and surfaces the conflict, because a wrong proposal
37
- * systematizes bad code. Pure, zero-dependency, bundle-safe; reuses the
38
- * conventions primitives.
36
+ * Audits a diff for drift and side effects after a PR is opened: scope drift,
37
+ * edits to high-fan-in "god-node" files, source changes without matching tests,
38
+ * and changes to security-sensitive files. Pure (takes a changed-file list),
39
+ * zero-dependency, bundle-safe; reuses the impact graph for blast radius.
39
40
  */
40
41
 
41
- const { toNamingStyle, analyzeConflicts } = __require('./src/conventions/conflicts');
42
-
43
- // Soft threshold is configurable; the hard floor is not (IMPL.md §5.1).
44
- const DEFAULT_THRESHOLD = 0.7;
45
- const HARD_FLOOR = 0.5;
42
+ const path = require('path');
43
+ const { analyzeImpact } = __require('./src/graph/impact');
44
+
45
+ const SECURITY_PATTERNS = [
46
+ /(^|\/)\.env(\.|$)/i,
47
+ /(^|\/)(secrets?|credentials?)(\/|\.|$)/i,
48
+ /(^|\/)auth(\/|\.|-)/i,
49
+ /(^|\/)package(-lock)?\.json$/,
50
+ /(^|\/)(yarn\.lock|pnpm-lock\.yaml)$/,
51
+ /(^|\/)\.github\/workflows\//,
52
+ /(^|\/)Dockerfile/i,
53
+ /\.(pem|key|crt|p12)$/i,
54
+ ];
46
55
 
47
- /** Tier for a consistency score (matches the conventions tiers). */
48
- function _tier(pct) {
49
- if (pct >= 0.9) return 'consistent';
50
- if (pct >= 0.7) return 'mostly';
51
- return 'inconsistent';
52
- }
56
+ const SRC_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php']);
57
+ const GOD_NODE_THRESHOLD = 15; // transitive dependents → high-fan-in "god node"
58
+ const SCOPE_DIR_THRESHOLD = 5; // distinct top-level dirs → scope drift
53
59
 
54
- /** Strip any extension/compound suffix from a requested name → bare stem. */
55
- function _stem(name) {
56
- const s = String(name || '').trim();
57
- const slash = s.lastIndexOf('/');
58
- const base = slash >= 0 ? s.slice(slash + 1) : s;
59
- const dot = base.indexOf('.');
60
- return dot > 0 ? base.slice(0, dot) : base;
60
+ function isTestFile(p) {
61
+ return /\.(test|spec)\.[jt]sx?$|(^|\/)test_|_test\.(py|go)$|(^|\/)(tests?|__tests__|spec)\//.test(p);
61
62
  }
62
-
63
- /** Test file path for a styled stem given the detected framework + ext. */
64
- function _testFile(styledStem, framework, ext) {
65
- if (framework === 'pytest' || framework === 'unittest') {
66
- return `test_${toNamingStyle(styledStem, 'snake_case')}.py`;
67
- }
68
- return `${styledStem}.test.${ext}`;
63
+ function isSource(p) {
64
+ return SRC_EXTS.has(path.extname(p).toLowerCase()) && !isTestFile(p);
69
65
  }
70
66
 
71
67
  /**
72
- * Propose a convention-matched scaffold, gated by a confidence floor.
73
- * @param {string} name desired module name (any casing; extension ignored)
74
- * @param {object} conventions an `extractConventions` result
68
+ * Audit a changed-file list.
69
+ * @param {Array<{path:string,status:string}>|string[]} changedFiles
70
+ * @param {string} cwd
75
71
  * @param {object} [opts]
76
- * @param {number} [opts.threshold=0.7] soft threshold (clamped to ≥ hard floor)
77
- * @param {boolean} [opts.force=false] allow proposing below the soft threshold
78
- * (never below the hard floor)
79
- * @param {string} [opts.ext='js'] file extension for the proposed files
80
- * @returns {{ ok:boolean, refused:boolean, name:string, tier:string,
81
- * confidence:number, threshold:number, hardFloor:number, forced:boolean,
82
- * warning:string|null, reason:string, proposal:object|null, conflicts:object }}
72
+ * @param {number} [opts.godNodeThreshold=15]
73
+ * @param {number} [opts.scopeThreshold=5]
74
+ * @returns {{ findings: object[], blast: object[], summary: object }}
83
75
  */
84
- function proposeScaffold(name, conventions, opts = {}) {
85
- const threshold = Math.max(HARD_FLOOR, opts.threshold != null ? opts.threshold : DEFAULT_THRESHOLD);
86
- const force = !!opts.force;
87
- const ext = opts.ext || 'js';
88
- const fileNaming = (conventions && conventions.fileNaming) || { dominant: null, dominantPct: 0, total: 0 };
89
- const exportStyle = (conventions && conventions.exportStyle) || { dominant: null };
90
- const confidence = fileNaming.dominantPct || 0;
91
- const tier = fileNaming.total > 0 ? _tier(confidence) : 'unknown';
92
- const conflicts = analyzeConflicts(conventions || {});
93
-
94
- const base = {
95
- ok: false, refused: true, name: String(name || ''), tier, confidence,
96
- threshold, hardFloor: HARD_FLOOR, forced: false, warning: null,
97
- reason: '', proposal: null, conflicts,
98
- };
99
-
100
- if (!fileNaming.dominant || fileNaming.total === 0) {
101
- return { ...base, reason: 'no file-naming convention detected — cannot propose a name' };
102
- }
103
- if (confidence < HARD_FLOOR) {
104
- return {
105
- ...base,
106
- reason: `file-naming consistency ${(confidence * 100).toFixed(0)}% is below the hard floor ${(HARD_FLOOR * 100).toFixed(0)}% — refusing (not overridable)`,
107
- };
108
- }
109
- if (confidence < threshold && !force) {
110
- return {
111
- ...base,
112
- reason: `file-naming consistency ${(confidence * 100).toFixed(0)}% is below the threshold ${(threshold * 100).toFixed(0)}% — refusing (use --force to override above the ${(HARD_FLOOR * 100).toFixed(0)}% floor)`,
113
- };
76
+ function reviewPr(changedFiles, cwd, opts = {}) {
77
+ const godThreshold = opts.godNodeThreshold != null ? opts.godNodeThreshold : GOD_NODE_THRESHOLD;
78
+ const scopeThreshold = opts.scopeThreshold != null ? opts.scopeThreshold : SCOPE_DIR_THRESHOLD;
79
+ const files = (changedFiles || []).map((f) => (typeof f === 'string' ? { path: f, status: 'M' } : f));
80
+
81
+ const findings = [];
82
+ const paths = files.map((f) => f.path);
83
+ const live = files.filter((f) => f.status !== 'D');
84
+ const srcChanged = live.filter((f) => isSource(f.path)).map((f) => f.path);
85
+ const testChanged = paths.filter(isTestFile);
86
+
87
+ // 1. Missing tests: a changed source file with no matching changed test file.
88
+ for (const s of srcChanged) {
89
+ const stem = path.basename(s).replace(/\.[^.]+$/, '');
90
+ const covered = testChanged.some((t) => path.basename(t).includes(stem));
91
+ if (!covered) findings.push({ type: 'missing-tests', file: s, severity: 'warn' });
92
+ }
93
+
94
+ // 2. Security-sensitive files.
95
+ for (const f of live) {
96
+ if (SECURITY_PATTERNS.some((re) => re.test(f.path))) {
97
+ findings.push({ type: 'security-file', file: f.path, severity: 'warn' });
98
+ }
99
+ }
100
+
101
+ // 3. God-node edits (high blast radius).
102
+ const blast = [];
103
+ if (srcChanged.length) {
104
+ let impacts = [];
105
+ try { impacts = analyzeImpact(srcChanged, cwd, {}); } catch (_) { impacts = []; }
106
+ for (const { file, impact } of impacts) {
107
+ blast.push({ file, totalImpact: impact.totalImpact });
108
+ if (impact.totalImpact > godThreshold) {
109
+ findings.push({ type: 'god-node', file, count: impact.totalImpact, severity: 'warn' });
110
+ }
111
+ }
112
+ blast.sort((a, b) => b.totalImpact - a.totalImpact);
114
113
  }
115
114
 
116
- const styledStem = toNamingStyle(_stem(name), fileNaming.dominant);
117
- const forced = confidence < threshold && force;
118
- const proposal = {
119
- filename: `${styledStem}.${ext}`,
120
- namingStyle: fileNaming.dominant,
121
- exportStyle: exportStyle.dominant || 'named',
122
- testFile: _testFile(styledStem, conventions.testFramework, ext),
123
- testFramework: conventions.testFramework || null,
124
- };
115
+ // 4. Scope drift: distinct top-level directories touched.
116
+ const dirs = [...new Set(paths.map((p) => (p.includes('/') ? p.split('/')[0] : '.')))];
117
+ if (dirs.length > scopeThreshold) {
118
+ findings.push({ type: 'scope-drift', dirs, count: dirs.length, threshold: scopeThreshold, severity: 'warn' });
119
+ }
125
120
 
121
+ const byType = findings.reduce((a, f) => { a[f.type] = (a[f.type] || 0) + 1; return a; }, {});
126
122
  return {
127
- ...base,
128
- ok: true,
129
- refused: false,
130
- forced,
131
- warning: forced
132
- ? `proposed below the ${(threshold * 100).toFixed(0)}% threshold (--force); conventions are only ${tier}`
133
- : null,
134
- reason: forced ? 'forced proposal above the hard floor' : `conventions are ${tier} — proposing`,
135
- proposal,
123
+ findings,
124
+ blast,
125
+ summary: {
126
+ filesChanged: files.length,
127
+ sourceChanged: srcChanged.length,
128
+ testsChanged: testChanged.length,
129
+ findings: findings.length,
130
+ byType,
131
+ ok: findings.length === 0,
132
+ },
136
133
  };
137
134
  }
138
135
 
139
- module.exports = { proposeScaffold, DEFAULT_THRESHOLD, HARD_FLOOR };
136
+ module.exports = { reviewPr, SECURITY_PATTERNS, GOD_NODE_THRESHOLD, SCOPE_DIR_THRESHOLD };
140
137
 
141
138
  };
142
139
 
143
- __factories["./src/conventions/inject"] = function(module, exports) {
140
+ __factories["./src/cache/freshen"] = function(module, exports) {
144
141
 
145
142
  /**
146
- * CLAUDE.md convention injection (IMPL.md §7 Phase 2 / §8 step 5).
143
+ * Read-time self-heal (IMPL.md Layer 1, "safety net" tier).
147
144
  *
148
- * Renders the conventions detected by `extractConventions` into a
149
- * marker-delimited markdown block and injects it into CLAUDE.md so an agent
150
- * reading the file plans grounded in the repo's house style. Idempotent and
151
- * marker-scoped it never touches human content or the `## Auto-generated
152
- * signatures` block. Pure string transforms; zero-dependency, bundle-safe.
145
+ * Keeps the sig-cache in line with the current source tree so the index reflects
146
+ * on-disk reality even when no write hook was called. Re-extracts files modified
147
+ * since the context file was generated (bounded to actual session edits, not the
148
+ * whole tree), drops cache entries for deleted files, and persists. buildSigIndex
149
+ * already merges the cache, so the next read is fresh.
150
+ *
151
+ * Throttled per cwd. Skips entirely when there is no generated index to heal
152
+ * (a cold repo should run `generate` or use the notify hooks).
153
+ *
154
+ * Zero-dependency, bundle-safe (fs + dispatch + sig-cache).
153
155
  */
154
156
 
155
- const START = '<!-- sigmap-conventions:start -->';
156
- const END = '<!-- sigmap-conventions:end -->';
157
+ const fs = require('fs');
158
+ const path = require('path');
159
+ const { loadCache, saveCache, getChangedFiles } = __require('./src/cache/sig-cache');
160
+ const { extractFile, langFor } = __require('./src/extractors/dispatch');
157
161
 
158
- const TIER_NOTE = {
159
- consistent: 'consistent match it',
160
- mostly: 'dominant, with some drift',
161
- inconsistent: 'no clear convention — check neighboring files',
162
- };
162
+ const DEFAULT_SRC_DIRS = ['src', 'app', 'lib', 'packages', 'services', 'api'];
163
+ const DEFAULT_EXCLUDE = [
164
+ 'node_modules', '.git', 'dist', 'build', 'out', '__pycache__',
165
+ '.next', 'coverage', 'target', 'vendor', '.context',
166
+ ];
167
+ const CONTEXT_PATHS = [
168
+ ['.github', 'copilot-instructions.md'],
169
+ ['CLAUDE.md'], ['AGENTS.md'], ['.github', 'context-cold.md'],
170
+ ];
171
+ const THROTTLE_MS = 1500;
172
+ const _lastRun = new Map();
163
173
 
164
- const NAMES = {
165
- fileNaming: 'File naming',
166
- exportStyle: 'Export style',
167
- };
174
+ function _readConfig(cwd) {
175
+ try {
176
+ const cfg = JSON.parse(fs.readFileSync(path.join(cwd, 'gen-context.config.json'), 'utf8'));
177
+ return cfg && typeof cfg === 'object' ? cfg : {};
178
+ } catch (_) { return {}; }
179
+ }
168
180
 
169
- const _pct = (n) => `${Math.round(n * 100)}%`;
181
+ function _pkgVersion(cwd) {
182
+ try { return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).version || '0.0.0'; }
183
+ catch (_) { return '0.0.0'; }
184
+ }
170
185
 
171
- function _conventionLine(label, conv) {
172
- if (!conv || conv.total === 0 || !conv.dominant) return null;
173
- const note = TIER_NOTE[conv.tier] || conv.tier;
174
- let line = `- **${label}:** ${conv.dominant} (${_pct(conv.dominantPct)} — ${note}).`;
175
- const others = conv.variants.slice(1).filter((v) => v.pct > 0);
176
- if (others.length) {
177
- line += ` Variants: ${others.map((v) => `${v.label} ${_pct(v.pct)}`).join(', ')}.`;
186
+ /** Newest mtime among existing generated context files, or 0 if none. */
187
+ function _contextMtime(cwd) {
188
+ let newest = 0;
189
+ for (const parts of CONTEXT_PATHS) {
190
+ try { newest = Math.max(newest, fs.statSync(path.join(cwd, ...parts)).mtimeMs); } catch (_) {}
191
+ }
192
+ return newest;
193
+ }
194
+
195
+ function _walk(dir, exclude, out, depth, maxDepth) {
196
+ if (depth > maxDepth) return;
197
+ let entries;
198
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
199
+ for (const e of entries) {
200
+ if (exclude.has(e.name)) continue;
201
+ const full = path.join(dir, e.name);
202
+ if (e.isDirectory()) _walk(full, exclude, out, depth + 1, maxDepth);
203
+ else if (e.isFile() && langFor(e.name)) out.push(full);
178
204
  }
179
- return line;
180
205
  }
181
206
 
182
207
  /**
183
- * Render the conventions block (including its start/end markers).
184
- * @param {object} result an `extractConventions` result
185
- * @param {string} [version] SigMap version for the footer
186
- * @returns {string}
208
+ * Re-extract source files changed since the last generate; drop deleted files.
209
+ * @param {string} cwd
210
+ * @param {{force?:boolean, now?:number}} [opts]
211
+ * @returns {number} cache entries touched
187
212
  */
188
- function renderConventionsBlock(result, version) {
189
- const lines = [];
190
- for (const key of ['fileNaming', 'exportStyle']) {
191
- const l = _conventionLine(NAMES[key], result && result[key]);
192
- if (l) lines.push(l);
193
- }
194
- if (result && result.testFramework) {
195
- lines.push(`- **Test framework:** ${result.testFramework}.`);
213
+ function freshen(cwd, opts = {}) {
214
+ const now = opts.now != null ? opts.now : Date.now();
215
+ if (!opts.force) {
216
+ if (now - (_lastRun.get(cwd) || 0) < THROTTLE_MS) return 0;
196
217
  }
218
+ _lastRun.set(cwd, now);
197
219
 
198
- const body = lines.length
199
- ? lines
200
- : ['- No conventions detected yet (run `sigmap conventions` on a TS/JS/Python repo).'];
220
+ try {
221
+ const version = _pkgVersion(cwd);
222
+ const cache = loadCache(cwd, version);
223
+ const ctxMtime = _contextMtime(cwd);
224
+ // Nothing to heal: no generated context AND no live cache overlay.
225
+ if (ctxMtime === 0 && cache.size === 0) return 0;
201
226
 
202
- const ver = version ? ` v${version}` : '';
203
- return [
204
- START,
205
- '## Conventions (auto-detected by SigMap)',
206
- '',
207
- 'Match these when writing or editing code (TS/JS/Python):',
208
- '',
209
- ...body,
210
- '',
211
- `<sub>Generated by SigMap${ver} · run \`sigmap conventions --inject\` to refresh.</sub>`,
212
- END,
213
- ].join('\n');
214
- }
227
+ const cfg = _readConfig(cwd);
228
+ const srcDirs = Array.isArray(cfg.srcDirs) && cfg.srcDirs.length ? cfg.srcDirs : DEFAULT_SRC_DIRS;
229
+ const exclude = new Set([...DEFAULT_EXCLUDE, ...(Array.isArray(cfg.exclude) ? cfg.exclude : [])]);
230
+ const maxDepth = Number.isFinite(cfg.maxDepth) ? cfg.maxDepth : 8;
215
231
 
216
- /**
217
- * Inject (or replace) the conventions block in existing CLAUDE.md content.
218
- * Replaces an existing marked block in place; appends one when absent.
219
- * Idempotent never touches content outside the markers.
220
- * @param {string} existing current file content ('' if the file is new)
221
- * @param {string} block the block returned by `renderConventionsBlock`
222
- * @returns {string}
223
- */
224
- function injectConventions(existing, block) {
225
- const src = String(existing || '');
226
- const startIdx = src.indexOf(START);
227
- if (startIdx !== -1) {
228
- const endIdx = src.indexOf(END, startIdx);
229
- if (endIdx !== -1) {
230
- const before = src.slice(0, startIdx);
231
- const after = src.slice(endIdx + END.length);
232
- return before + block + after;
232
+ const files = [];
233
+ for (const d of srcDirs) {
234
+ const abs = path.isAbsolute(d) ? d : path.join(cwd, d);
235
+ if (fs.existsSync(abs)) _walk(abs, exclude, files, 0, maxDepth);
233
236
  }
237
+
238
+ // Candidates = files modified since the context was generated, or not yet cached.
239
+ const candidates = files.filter((f) => {
240
+ try { return fs.statSync(f).mtimeMs > ctxMtime || !cache.has(f); } catch (_) { return false; }
241
+ });
242
+ const { changed } = getChangedFiles(candidates, cache);
243
+
244
+ let touched = 0;
245
+ for (const f of changed) {
246
+ try {
247
+ const sigs = extractFile(f, fs.readFileSync(f, 'utf8'));
248
+ cache.set(f, { mtime: fs.statSync(f).mtimeMs, sigs });
249
+ touched++;
250
+ } catch (_) {}
251
+ }
252
+ // Note: deletions are NOT swept here — a cache entry may be a `notify`
253
+ // overlay for a file not yet on disk. Explicit removal is `notify_file_deleted`.
254
+
255
+ if (touched > 0) saveCache(cwd, version, cache);
256
+ return touched;
257
+ } catch (_) {
258
+ return 0;
234
259
  }
235
- if (src.trim() === '') return block + '\n';
236
- const sep = src.endsWith('\n') ? '\n' : '\n\n';
237
- return src + sep + block + '\n';
238
260
  }
239
261
 
240
- module.exports = { renderConventionsBlock, injectConventions, START, END };
262
+ module.exports = { freshen };
241
263
 
242
264
  };
243
265
 
266
+ // ── ./src/conventions/conflicts ──
244
267
  __factories["./src/conventions/conflicts"] = function(module, exports) {
245
268
 
246
269
  /**
@@ -355,6 +378,7 @@ __factories["./src/conventions/conflicts"] = function(module, exports) {
355
378
 
356
379
  };
357
380
 
381
+ // ── ./src/conventions/extract ──
358
382
  __factories["./src/conventions/extract"] = function(module, exports) {
359
383
 
360
384
  /**
@@ -399,24 +423,42 @@ __factories["./src/conventions/extract"] = function(module, exports) {
399
423
  return 'other';
400
424
  }
401
425
 
426
+ const MAX_EXAMPLES = 3;
427
+
402
428
  /**
403
429
  * Score a set of categorical observations into a dominant convention plus its
404
430
  * consistency tier. The reusable primitive (IMPL.md §5.2).
405
431
  * @param {string[]} labels observed category for each sample (e.g. naming styles)
432
+ * @param {string[]} [refs] optional identifier (e.g. file name) parallel to
433
+ * `labels`; when given, each variant carries up to 3 `examples`.
406
434
  * @returns {{ dominant: string|null, dominantPct: number, total: number,
407
- * variants: Array<{label:string, count:number, pct:number}>,
435
+ * variants: Array<{label:string, count:number, pct:number, examples?:string[]}>,
408
436
  * tier: 'consistent'|'mostly'|'inconsistent'|'unknown' }}
409
437
  */
410
- function scoreConvention(labels) {
411
- const list = (labels || []).filter((l) => l != null && l !== 'other');
412
- const total = list.length;
438
+ function scoreConvention(labels, refs) {
439
+ const all = labels || [];
440
+ const counts = new Map();
441
+ const examples = new Map();
442
+ let total = 0;
443
+ for (let i = 0; i < all.length; i++) {
444
+ const l = all[i];
445
+ if (l == null || l === 'other') continue;
446
+ total++;
447
+ counts.set(l, (counts.get(l) || 0) + 1);
448
+ if (refs && refs[i] != null) {
449
+ const ex = examples.get(l) || [];
450
+ if (ex.length < MAX_EXAMPLES) { ex.push(refs[i]); examples.set(l, ex); }
451
+ }
452
+ }
413
453
  if (total === 0) {
414
454
  return { dominant: null, dominantPct: 0, total: 0, variants: [], tier: 'unknown' };
415
455
  }
416
- const counts = new Map();
417
- for (const l of list) counts.set(l, (counts.get(l) || 0) + 1);
418
456
  const variants = [...counts.entries()]
419
- .map(([label, count]) => ({ label, count, pct: count / total }))
457
+ .map(([label, count]) => {
458
+ const v = { label, count, pct: count / total };
459
+ if (refs) v.examples = examples.get(label) || [];
460
+ return v;
461
+ })
420
462
  .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
421
463
  const top = variants[0];
422
464
  let tier = 'inconsistent';
@@ -488,157 +530,365 @@ __factories["./src/conventions/extract"] = function(module, exports) {
488
530
  function extractConventions(cwd, files) {
489
531
  const scoped = (files || []).filter((f) => SCOPED_EXTS.has(path.extname(f).toLowerCase()));
490
532
  const namingLabels = [];
533
+ const namingRefs = [];
491
534
  const exportLabels = [];
535
+ const exportRefs = [];
492
536
  for (const f of scoped) {
493
537
  const base = path.basename(f);
494
538
  // Skip test files for the naming convention (they have their own naming).
495
539
  if (!/\.(test|spec)\.[jt]sx?$|(^|\/)test_|_test\.py$/.test(f)) {
496
540
  namingLabels.push(classifyNaming(base));
541
+ namingRefs.push(base);
497
542
  }
498
543
  if (JS_TS_EXTS.has(path.extname(f).toLowerCase())) {
499
544
  let src = '';
500
545
  try { src = fs.readFileSync(f, 'utf8'); } catch (_) {}
501
546
  exportLabels.push(_jsExportStyle(src));
547
+ exportRefs.push(base);
502
548
  }
503
549
  }
504
550
  return {
505
- fileNaming: scoreConvention(namingLabels),
506
- exportStyle: scoreConvention(exportLabels),
551
+ fileNaming: scoreConvention(namingLabels, namingRefs),
552
+ exportStyle: scoreConvention(exportLabels, exportRefs),
507
553
  testFramework: _detectTestFramework(cwd, scoped),
508
554
  scope: ['typescript', 'javascript', 'python'],
509
555
  scannedFiles: scoped.length,
510
556
  };
511
557
  }
512
558
 
513
- module.exports = { classifyNaming, scoreConvention, extractConventions };
559
+ module.exports = { classifyNaming, scoreConvention, extractConventions };
560
+
561
+ };
562
+
563
+ // ── ./src/conventions/inject ──
564
+ __factories["./src/conventions/inject"] = function(module, exports) {
565
+
566
+ /**
567
+ * CLAUDE.md convention injection (IMPL.md §7 Phase 2 / §8 step 5).
568
+ *
569
+ * Renders the conventions detected by `extractConventions` into a
570
+ * marker-delimited markdown block and injects it into CLAUDE.md so an agent
571
+ * reading the file plans grounded in the repo's house style. Idempotent and
572
+ * marker-scoped — it never touches human content or the `## Auto-generated
573
+ * signatures` block. Pure string transforms; zero-dependency, bundle-safe.
574
+ */
575
+
576
+ const START = '<!-- sigmap-conventions:start -->';
577
+ const END = '<!-- sigmap-conventions:end -->';
578
+
579
+ const TIER_NOTE = {
580
+ consistent: 'consistent — match it',
581
+ mostly: 'dominant, with some drift',
582
+ inconsistent: 'no clear convention — check neighboring files',
583
+ };
584
+
585
+ const NAMES = {
586
+ fileNaming: 'File naming',
587
+ exportStyle: 'Export style',
588
+ };
589
+
590
+ const _pct = (n) => `${Math.round(n * 100)}%`;
591
+
592
+ function _conventionLine(label, conv) {
593
+ if (!conv || conv.total === 0 || !conv.dominant) return null;
594
+ const note = TIER_NOTE[conv.tier] || conv.tier;
595
+ let line = `- **${label}:** ${conv.dominant} (${_pct(conv.dominantPct)} — ${note}).`;
596
+ const others = conv.variants.slice(1).filter((v) => v.pct > 0);
597
+ if (others.length) {
598
+ line += ` Variants: ${others.map((v) => `${v.label} ${_pct(v.pct)}`).join(', ')}.`;
599
+ }
600
+ return line;
601
+ }
602
+
603
+ /**
604
+ * Render the conventions block (including its start/end markers).
605
+ * @param {object} result an `extractConventions` result
606
+ * @param {string} [version] SigMap version for the footer
607
+ * @returns {string}
608
+ */
609
+ function renderConventionsBlock(result, version) {
610
+ const lines = [];
611
+ for (const key of ['fileNaming', 'exportStyle']) {
612
+ const l = _conventionLine(NAMES[key], result && result[key]);
613
+ if (l) lines.push(l);
614
+ }
615
+ if (result && result.testFramework) {
616
+ lines.push(`- **Test framework:** ${result.testFramework}.`);
617
+ }
618
+
619
+ const body = lines.length
620
+ ? lines
621
+ : ['- No conventions detected yet (run `sigmap conventions` on a TS/JS/Python repo).'];
622
+
623
+ const ver = version ? ` v${version}` : '';
624
+ return [
625
+ START,
626
+ '## Conventions (auto-detected by SigMap)',
627
+ '',
628
+ 'Match these when writing or editing code (TS/JS/Python):',
629
+ '',
630
+ ...body,
631
+ '',
632
+ `<sub>Generated by SigMap${ver} · run \`sigmap conventions --inject\` to refresh.</sub>`,
633
+ END,
634
+ ].join('\n');
635
+ }
636
+
637
+ /**
638
+ * Inject (or replace) the conventions block in existing CLAUDE.md content.
639
+ * Replaces an existing marked block in place; appends one when absent.
640
+ * Idempotent — never touches content outside the markers.
641
+ * @param {string} existing current file content ('' if the file is new)
642
+ * @param {string} block the block returned by `renderConventionsBlock`
643
+ * @returns {string}
644
+ */
645
+ function injectConventions(existing, block) {
646
+ const src = String(existing || '');
647
+ const startIdx = src.indexOf(START);
648
+ if (startIdx !== -1) {
649
+ const endIdx = src.indexOf(END, startIdx);
650
+ if (endIdx !== -1) {
651
+ const before = src.slice(0, startIdx);
652
+ const after = src.slice(endIdx + END.length);
653
+ return before + block + after;
654
+ }
655
+ }
656
+ if (src.trim() === '') return block + '\n';
657
+ const sep = src.endsWith('\n') ? '\n' : '\n\n';
658
+ return src + sep + block + '\n';
659
+ }
660
+
661
+ module.exports = { renderConventionsBlock, injectConventions, START, END };
514
662
 
515
663
  };
516
664
 
517
- __factories["./src/cache/freshen"] = function(module, exports) {
665
+ // ── ./src/scaffold/propose ──
666
+ __factories["./src/scaffold/propose"] = function(module, exports) {
518
667
 
519
668
  /**
520
- * Read-time self-heal (IMPL.md Layer 1, "safety net" tier).
521
- *
522
- * Keeps the sig-cache in line with the current source tree so the index reflects
523
- * on-disk reality even when no write hook was called. Re-extracts files modified
524
- * since the context file was generated (bounded to actual session edits, not the
525
- * whole tree), drops cache entries for deleted files, and persists. buildSigIndex
526
- * already merges the cache, so the next read is fresh.
527
- *
528
- * Throttled per cwd. Skips entirely when there is no generated index to heal
529
- * (a cold repo should run `generate` or use the notify hooks).
669
+ * Scaffold proposal with a confidence floor (IMPL.md §5 Cause 3).
530
670
  *
531
- * Zero-dependency, bundle-safe (fs + dispatch + sig-cache).
671
+ * Proposes a convention-matched structure for a new module — filename in the
672
+ * repo's dominant naming style, the export style to use, and a matching test
673
+ * file — but only when the conventions are consistent enough. Below a hard
674
+ * floor it refuses and surfaces the conflict, because a wrong proposal
675
+ * systematizes bad code. Pure, zero-dependency, bundle-safe; reuses the
676
+ * conventions primitives.
532
677
  */
533
678
 
534
- const fs = require('fs');
535
- const path = require('path');
536
- const { loadCache, saveCache, getChangedFiles } = __require('./src/cache/sig-cache');
537
- const { extractFile, langFor } = __require('./src/extractors/dispatch');
679
+ const { toNamingStyle, analyzeConflicts } = __require('./src/conventions/conflicts');
538
680
 
539
- const DEFAULT_SRC_DIRS = ['src', 'app', 'lib', 'packages', 'services', 'api'];
540
- const DEFAULT_EXCLUDE = [
541
- 'node_modules', '.git', 'dist', 'build', 'out', '__pycache__',
542
- '.next', 'coverage', 'target', 'vendor', '.context',
543
- ];
544
- const CONTEXT_PATHS = [
545
- ['.github', 'copilot-instructions.md'],
546
- ['CLAUDE.md'], ['AGENTS.md'], ['.github', 'context-cold.md'],
547
- ];
548
- const THROTTLE_MS = 1500;
549
- const _lastRun = new Map();
681
+ // Soft threshold is configurable; the hard floor is not (IMPL.md §5.1).
682
+ const DEFAULT_THRESHOLD = 0.7;
683
+ const HARD_FLOOR = 0.5;
550
684
 
551
- function _readConfig(cwd) {
552
- try {
553
- const cfg = JSON.parse(fs.readFileSync(path.join(cwd, 'gen-context.config.json'), 'utf8'));
554
- return cfg && typeof cfg === 'object' ? cfg : {};
555
- } catch (_) { return {}; }
685
+ /** Tier for a consistency score (matches the conventions tiers). */
686
+ function _tier(pct) {
687
+ if (pct >= 0.9) return 'consistent';
688
+ if (pct >= 0.7) return 'mostly';
689
+ return 'inconsistent';
556
690
  }
557
691
 
558
- function _pkgVersion(cwd) {
559
- try { return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).version || '0.0.0'; }
560
- catch (_) { return '0.0.0'; }
692
+ /** Strip any extension/compound suffix from a requested name → bare stem. */
693
+ function _stem(name) {
694
+ const s = String(name || '').trim();
695
+ const slash = s.lastIndexOf('/');
696
+ const base = slash >= 0 ? s.slice(slash + 1) : s;
697
+ const dot = base.indexOf('.');
698
+ return dot > 0 ? base.slice(0, dot) : base;
561
699
  }
562
700
 
563
- /** Newest mtime among existing generated context files, or 0 if none. */
564
- function _contextMtime(cwd) {
565
- let newest = 0;
566
- for (const parts of CONTEXT_PATHS) {
567
- try { newest = Math.max(newest, fs.statSync(path.join(cwd, ...parts)).mtimeMs); } catch (_) {}
701
+ /** Test file path for a styled stem given the detected framework + ext. */
702
+ function _testFile(styledStem, framework, ext) {
703
+ if (framework === 'pytest' || framework === 'unittest') {
704
+ return `test_${toNamingStyle(styledStem, 'snake_case')}.py`;
568
705
  }
569
- return newest;
706
+ return `${styledStem}.test.${ext}`;
570
707
  }
571
708
 
572
- function _walk(dir, exclude, out, depth, maxDepth) {
573
- if (depth > maxDepth) return;
574
- let entries;
575
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
576
- for (const e of entries) {
577
- if (exclude.has(e.name)) continue;
578
- const full = path.join(dir, e.name);
579
- if (e.isDirectory()) _walk(full, exclude, out, depth + 1, maxDepth);
580
- else if (e.isFile() && langFor(e.name)) out.push(full);
709
+ /**
710
+ * Propose a convention-matched scaffold, gated by a confidence floor.
711
+ * @param {string} name desired module name (any casing; extension ignored)
712
+ * @param {object} conventions an `extractConventions` result
713
+ * @param {object} [opts]
714
+ * @param {number} [opts.threshold=0.7] soft threshold (clamped to ≥ hard floor)
715
+ * @param {boolean} [opts.force=false] allow proposing below the soft threshold
716
+ * (never below the hard floor)
717
+ * @param {string} [opts.ext='js'] file extension for the proposed files
718
+ * @returns {{ ok:boolean, refused:boolean, name:string, tier:string,
719
+ * confidence:number, threshold:number, hardFloor:number, forced:boolean,
720
+ * warning:string|null, reason:string, proposal:object|null, conflicts:object }}
721
+ */
722
+ function proposeScaffold(name, conventions, opts = {}) {
723
+ const threshold = Math.max(HARD_FLOOR, opts.threshold != null ? opts.threshold : DEFAULT_THRESHOLD);
724
+ const force = !!opts.force;
725
+ const ext = opts.ext || 'js';
726
+ const fileNaming = (conventions && conventions.fileNaming) || { dominant: null, dominantPct: 0, total: 0 };
727
+ const exportStyle = (conventions && conventions.exportStyle) || { dominant: null };
728
+ const confidence = fileNaming.dominantPct || 0;
729
+ const tier = fileNaming.total > 0 ? _tier(confidence) : 'unknown';
730
+ const conflicts = analyzeConflicts(conventions || {});
731
+
732
+ const base = {
733
+ ok: false, refused: true, name: String(name || ''), tier, confidence,
734
+ threshold, hardFloor: HARD_FLOOR, forced: false, warning: null,
735
+ reason: '', proposal: null, conflicts,
736
+ };
737
+
738
+ if (!fileNaming.dominant || fileNaming.total === 0) {
739
+ return { ...base, reason: 'no file-naming convention detected — cannot propose a name' };
740
+ }
741
+ if (confidence < HARD_FLOOR) {
742
+ return {
743
+ ...base,
744
+ reason: `file-naming consistency ${(confidence * 100).toFixed(0)}% is below the hard floor ${(HARD_FLOOR * 100).toFixed(0)}% — refusing (not overridable)`,
745
+ };
746
+ }
747
+ if (confidence < threshold && !force) {
748
+ return {
749
+ ...base,
750
+ reason: `file-naming consistency ${(confidence * 100).toFixed(0)}% is below the threshold ${(threshold * 100).toFixed(0)}% — refusing (use --force to override above the ${(HARD_FLOOR * 100).toFixed(0)}% floor)`,
751
+ };
581
752
  }
753
+
754
+ const styledStem = toNamingStyle(_stem(name), fileNaming.dominant);
755
+ const forced = confidence < threshold && force;
756
+ const proposal = {
757
+ filename: `${styledStem}.${ext}`,
758
+ namingStyle: fileNaming.dominant,
759
+ exportStyle: exportStyle.dominant || 'named',
760
+ testFile: _testFile(styledStem, conventions.testFramework, ext),
761
+ testFramework: conventions.testFramework || null,
762
+ };
763
+
764
+ return {
765
+ ...base,
766
+ ok: true,
767
+ refused: false,
768
+ forced,
769
+ warning: forced
770
+ ? `proposed below the ${(threshold * 100).toFixed(0)}% threshold (--force); conventions are only ${tier}`
771
+ : null,
772
+ reason: forced ? 'forced proposal above the hard floor' : `conventions are ${tier} — proposing`,
773
+ proposal,
774
+ };
582
775
  }
583
776
 
777
+ module.exports = { proposeScaffold, DEFAULT_THRESHOLD, HARD_FLOOR };
778
+
779
+ };
780
+
781
+ __factories["./src/plan/verify-plan"] = function(module, exports) {
782
+
584
783
  /**
585
- * Re-extract source files changed since the last generate; drop deleted files.
586
- * @param {string} cwd
587
- * @param {{force?:boolean, now?:number}} [opts]
588
- * @returns {number} cache entries touched
784
+ * verify-plan (IMPL.md §6.1 Gap 2, step 2 of the `create` pipeline).
785
+ *
786
+ * Checks a plan (markdown) against the LIVE index before execution: do the
787
+ * referenced files and symbols exist, is the blast radius acceptable, is the
788
+ * scope in bounds? Catches Cause 1+2 at plan time — cheaper than after the
789
+ * code is written. Reuses the verify primitives + the impact graph.
790
+ * Zero-dependency, bundle-safe.
589
791
  */
590
- function freshen(cwd, opts = {}) {
591
- const now = opts.now != null ? opts.now : Date.now();
592
- if (!opts.force) {
593
- if (now - (_lastRun.get(cwd) || 0) < THROTTLE_MS) return 0;
594
- }
595
- _lastRun.set(cwd, now);
596
792
 
597
- try {
598
- const version = _pkgVersion(cwd);
599
- const cache = loadCache(cwd, version);
600
- const ctxMtime = _contextMtime(cwd);
601
- // Nothing to heal: no generated context AND no live cache overlay.
602
- if (ctxMtime === 0 && cache.size === 0) return 0;
793
+ const fs = require('fs');
794
+ const path = require('path');
795
+ const { extractFilePaths, extractSymbols } = __require('./src/verify/parsers');
796
+ const { buildSymbolSet } = __require('./src/verify/hallucination-guard');
797
+ const { closestMatch } = __require('./src/verify/closest-match');
798
+ const { analyzeImpact } = __require('./src/graph/impact');
603
799
 
604
- const cfg = _readConfig(cwd);
605
- const srcDirs = Array.isArray(cfg.srcDirs) && cfg.srcDirs.length ? cfg.srcDirs : DEFAULT_SRC_DIRS;
606
- const exclude = new Set([...DEFAULT_EXCLUDE, ...(Array.isArray(cfg.exclude) ? cfg.exclude : [])]);
607
- const maxDepth = Number.isFinite(cfg.maxDepth) ? cfg.maxDepth : 8;
800
+ const DEFAULT_BLAST_THRESHOLD = 20; // transitive+direct dependents → "high blast radius"
801
+ const DEFAULT_SCOPE_THRESHOLD = 10; // distinct referenced files "broad scope"
608
802
 
609
- const files = [];
610
- for (const d of srcDirs) {
611
- const abs = path.isAbsolute(d) ? d : path.join(cwd, d);
612
- if (fs.existsSync(abs)) _walk(abs, exclude, files, 0, maxDepth);
613
- }
803
+ /** Resolve a referenced path against cwd (handles a leading "./"). */
804
+ function _fileExists(cwd, ref) {
805
+ const clean = ref.replace(/^\.\//, '');
806
+ for (const c of [path.resolve(cwd, clean), path.resolve(cwd, ref)]) {
807
+ try { if (fs.existsSync(c)) return true; } catch (_) {}
808
+ }
809
+ return false;
810
+ }
614
811
 
615
- // Candidates = files modified since the context was generated, or not yet cached.
616
- const candidates = files.filter((f) => {
617
- try { return fs.statSync(f).mtimeMs > ctxMtime || !cache.has(f); } catch (_) { return false; }
812
+ /**
813
+ * Verify a plan against the live index.
814
+ * @param {string} planText the plan as markdown
815
+ * @param {string} cwd repo root
816
+ * @param {object} [opts]
817
+ * @param {number} [opts.blastThreshold=20]
818
+ * @param {number} [opts.scopeThreshold=10]
819
+ * @param {(ref:string)=>boolean} [opts.fileExists] override for testing
820
+ * @returns {{ issues: object[], blast: object[], scope: object, summary: object }}
821
+ */
822
+ function verifyPlan(planText, cwd, opts = {}) {
823
+ const blastThreshold = opts.blastThreshold != null ? opts.blastThreshold : DEFAULT_BLAST_THRESHOLD;
824
+ const scopeThreshold = opts.scopeThreshold != null ? opts.scopeThreshold : DEFAULT_SCOPE_THRESHOLD;
825
+ const fileExists = opts.fileExists || ((ref) => _fileExists(cwd, ref));
826
+
827
+ const text = String(planText || '');
828
+ const filesRef = extractFilePaths(text); // [{ path, line }]
829
+ const symbolsRef = extractSymbols(text); // [{ name, line }]
830
+ const { set: symbolSet, symbolCandidates } = buildSymbolSet(cwd);
831
+
832
+ const issues = [];
833
+
834
+ // 1. Referenced files must exist.
835
+ const existingFiles = [];
836
+ for (const f of filesRef) {
837
+ if (fileExists(f.path)) existingFiles.push(f.path);
838
+ else issues.push({ type: 'missing-file', ref: f.path, line: f.line, severity: 'error' });
839
+ }
840
+
841
+ // 2. Referenced symbols must exist in the live index (suggest a near match).
842
+ for (const s of symbolsRef) {
843
+ if (symbolSet.has(s.name)) continue;
844
+ const match = closestMatch(s.name, symbolCandidates);
845
+ issues.push({
846
+ type: 'unknown-symbol', ref: s.name, line: s.line, severity: 'error',
847
+ suggestion: match ? match.name : null,
618
848
  });
619
- const { changed } = getChangedFiles(candidates, cache);
849
+ }
620
850
 
621
- let touched = 0;
622
- for (const f of changed) {
623
- try {
624
- const sigs = extractFile(f, fs.readFileSync(f, 'utf8'));
625
- cache.set(f, { mtime: fs.statSync(f).mtimeMs, sigs });
626
- touched++;
627
- } catch (_) {}
851
+ // 3. Blast radius for each existing referenced file (one graph build).
852
+ const blast = [];
853
+ if (existingFiles.length) {
854
+ let impacts = [];
855
+ try { impacts = analyzeImpact(existingFiles, cwd, {}); } catch (_) { impacts = []; }
856
+ for (const { file, impact } of impacts) {
857
+ const entry = { file, totalImpact: impact.totalImpact, tests: impact.tests.length };
858
+ blast.push(entry);
859
+ if (impact.totalImpact > blastThreshold) {
860
+ issues.push({ type: 'high-blast-radius', ref: file, count: impact.totalImpact, severity: 'warn' });
861
+ }
628
862
  }
629
- // Note: deletions are NOT swept here — a cache entry may be a `notify`
630
- // overlay for a file not yet on disk. Explicit removal is `notify_file_deleted`.
863
+ blast.sort((a, b) => b.totalImpact - a.totalImpact);
864
+ }
631
865
 
632
- if (touched > 0) saveCache(cwd, version, cache);
633
- return touched;
634
- } catch (_) {
635
- return 0;
866
+ // 4. Scope.
867
+ const scope = { files: filesRef.length, symbols: symbolsRef.length, threshold: scopeThreshold };
868
+ if (filesRef.length > scopeThreshold) {
869
+ issues.push({ type: 'broad-scope', count: filesRef.length, threshold: scopeThreshold, severity: 'warn' });
636
870
  }
871
+
872
+ const errors = issues.filter((i) => i.severity === 'error').length;
873
+ const warnings = issues.filter((i) => i.severity === 'warn').length;
874
+ return {
875
+ issues,
876
+ blast,
877
+ scope,
878
+ summary: {
879
+ filesReferenced: filesRef.length,
880
+ symbolsReferenced: symbolsRef.length,
881
+ errors,
882
+ warnings,
883
+ ok: errors === 0,
884
+ },
885
+ };
637
886
  }
638
887
 
639
- module.exports = { freshen };
888
+ module.exports = { verifyPlan, DEFAULT_BLAST_THRESHOLD, DEFAULT_SCOPE_THRESHOLD };
640
889
 
641
890
  };
891
+
642
892
  // ── ./src/extractors/dispatch ──
643
893
  __factories["./src/extractors/dispatch"] = function(module, exports) {
644
894
 
@@ -7119,7 +7369,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
7119
7369
 
7120
7370
  const SERVER_INFO = {
7121
7371
  name: 'sigmap',
7122
- version: '7.10.0',
7372
+ version: '7.12.0',
7123
7373
  description: 'SigMap MCP server — code signatures on demand',
7124
7374
  };
7125
7375
 
@@ -12797,7 +13047,7 @@ function __tryGit(args, opts = {}) {
12797
13047
  catch (_) { return ''; }
12798
13048
  }
12799
13049
 
12800
- const VERSION = '7.10.0';
13050
+ const VERSION = '7.12.0';
12801
13051
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
12802
13052
 
12803
13053
  function requireSourceOrBundled(key) {
@@ -16151,6 +16401,47 @@ function main() {
16151
16401
  }
16152
16402
 
16153
16403
  // `sigmap verify-ai-output <answer.md>` — Hallucination Guard (deterministic core)
16404
+ // Gap 2 (step 2): `sigmap verify-plan <plan.md>` — check a plan against the
16405
+ // live index before execution (files/symbols exist, blast radius, scope).
16406
+ if (args[0] === 'verify-plan') {
16407
+ const target = args[1] && !args[1].startsWith('--') ? args[1] : null;
16408
+ const jsonOut = args.includes('--json');
16409
+ if (!target) {
16410
+ console.error('[sigmap] Usage: sigmap verify-plan <plan.md|-> [--json]');
16411
+ process.exit(1);
16412
+ }
16413
+ let planText = '';
16414
+ try {
16415
+ planText = fs.readFileSync(target === '-' ? 0 : path.resolve(cwd, target), 'utf8');
16416
+ } catch (e) {
16417
+ console.error(`[sigmap] cannot read plan: ${e.message}`);
16418
+ process.exit(1);
16419
+ }
16420
+ const { verifyPlan } = requireSourceOrBundled('./src/plan/verify-plan');
16421
+ const result = verifyPlan(planText, cwd);
16422
+
16423
+ if (jsonOut) {
16424
+ process.stdout.write(JSON.stringify(result) + '\n');
16425
+ process.exit(result.summary.ok ? 0 : 1);
16426
+ }
16427
+
16428
+ const s = result.summary;
16429
+ console.log(`[sigmap] verify-plan — ${s.filesReferenced} file(s), ${s.symbolsReferenced} symbol(s) referenced`);
16430
+ if (result.issues.length === 0) {
16431
+ console.log(' ✓ plan checks out — all references exist, blast radius and scope in bounds');
16432
+ process.exit(0);
16433
+ }
16434
+ for (const i of result.issues) {
16435
+ const mark = i.severity === 'error' ? '✗' : '⚠';
16436
+ if (i.type === 'missing-file') console.log(` ${mark} missing file: ${i.ref} (line ${i.line})`);
16437
+ else if (i.type === 'unknown-symbol') console.log(` ${mark} unknown symbol: ${i.ref}()${i.suggestion ? ` — did you mean ${i.suggestion}()?` : ''} (line ${i.line})`);
16438
+ else if (i.type === 'high-blast-radius') console.log(` ${mark} high blast radius: ${i.ref} → ${i.count} dependents`);
16439
+ else if (i.type === 'broad-scope') console.log(` ${mark} broad scope: ${i.count} files (threshold ${i.threshold})`);
16440
+ }
16441
+ console.log(`\n ${s.errors} error(s), ${s.warnings} warning(s)`);
16442
+ process.exit(s.ok ? 0 : 1);
16443
+ }
16444
+
16154
16445
  if (args[0] === 'verify-ai-output') {
16155
16446
  const target = args[1];
16156
16447
  const jsonOut = args.includes('--json');
@@ -16208,6 +16499,60 @@ function main() {
16208
16499
  process.exit(1);
16209
16500
  }
16210
16501
 
16502
+ // Gap 2 (step 4): `sigmap review-pr` — diff audit (scope drift, god-nodes,
16503
+ // missing tests, security-sensitive files). Last guard stage of `create`.
16504
+ if (args[0] === 'review-pr') {
16505
+ const jsonOut = args.includes('--json');
16506
+ const staged = args.includes('--staged');
16507
+ const baseIdx = args.indexOf('--base');
16508
+ const baseArg = baseIdx !== -1 && args[baseIdx + 1] && !args[baseIdx + 1].startsWith('--') ? args[baseIdx + 1] : null;
16509
+
16510
+ let nameStatus = '';
16511
+ if (staged) {
16512
+ nameStatus = __tryGit(['diff', '--cached', '--name-status'], { cwd });
16513
+ } else {
16514
+ let base = baseArg;
16515
+ if (!base) {
16516
+ for (const ref of ['main', 'develop']) {
16517
+ if (__tryGit(['rev-parse', '--verify', '--quiet', ref], { cwd })) { base = ref; break; }
16518
+ }
16519
+ }
16520
+ const mb = base ? (__tryGit(['merge-base', 'HEAD', base], { cwd }) || base) : 'HEAD~1';
16521
+ nameStatus = __tryGit(['diff', '--name-status', `${mb}..HEAD`], { cwd });
16522
+ }
16523
+
16524
+ const changedFiles = nameStatus.split('\n').filter(Boolean).map((line) => {
16525
+ const parts = line.split('\t');
16526
+ const status = parts[0][0]; // M/A/D/R…
16527
+ const file = parts[parts.length - 1]; // rename → new path
16528
+ return { path: file, status };
16529
+ });
16530
+
16531
+ const { reviewPr } = requireSourceOrBundled('./src/review/review-pr');
16532
+ const result = reviewPr(changedFiles, cwd, {});
16533
+
16534
+ if (jsonOut) {
16535
+ process.stdout.write(JSON.stringify(result) + '\n');
16536
+ process.exit(result.summary.ok ? 0 : 1);
16537
+ }
16538
+
16539
+ const s = result.summary;
16540
+ console.log(`[sigmap] review-pr — ${s.filesChanged} file(s) changed (${s.sourceChanged} source, ${s.testsChanged} test)`);
16541
+ if (s.findings === 0) {
16542
+ console.log(' ✓ no findings — scope, tests, blast radius, and sensitive files all clear');
16543
+ process.exit(0);
16544
+ }
16545
+ const label = { 'missing-tests': 'missing tests', 'security-file': 'security file', 'god-node': 'god node', 'scope-drift': 'scope drift' };
16546
+ for (const f of result.findings) {
16547
+ if (f.type === 'missing-tests') console.log(` ⚠ ${label[f.type]}: ${f.file} changed with no matching test`);
16548
+ else if (f.type === 'security-file') console.log(` ⚠ ${label[f.type]}: ${f.file}`);
16549
+ else if (f.type === 'god-node') console.log(` ⚠ ${label[f.type]}: ${f.file} → ${f.count} dependents`);
16550
+ else if (f.type === 'scope-drift') console.log(` ⚠ ${label[f.type]}: ${f.count} top-level dirs (${f.dirs.join(', ')})`);
16551
+ }
16552
+ console.log(`\n ${s.findings} finding(s)`);
16553
+ process.exit(1);
16554
+ }
16555
+
16211
16556
  // Feature 1: `sigmap explain <file>` — why a file is included or excluded
16212
16557
  if (args[0] === 'explain' || args.includes('--explain')) {
16213
16558
  const target = args[0] === 'explain'