sigmap 7.10.0 → 7.11.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/CHANGELOG.md CHANGED
@@ -10,6 +10,15 @@ Format: [Semantic Versioning](https://semver.org/)
10
10
 
11
11
  ---
12
12
 
13
+ ## [7.11.0] — 2026-06-17
14
+
15
+ Minor release — `sigmap verify-plan` (grounded codegen, Gap 2).
16
+
17
+ ### Added
18
+ - **`sigmap verify-plan <plan.md>` — check a plan against the live index (#310):** the first piece of the `sigmap create` pipeline (Gap 2, step 2). Before an agent executes a plan, `verify-plan` validates it against the live index — referenced files and symbols exist, blast radius is acceptable, scope is in bounds — catching Cause 1+2 at plan time (cheaper than after the code is written). New zero-dependency, bundle-safe `src/plan/verify-plan.js` (`verifyPlan`): flags missing files and unknown symbols (with closest-match suggestions), computes per-file blast radius via the impact graph (flags high-blast-radius files), and flags broad scope. Plan input schema is **markdown** (consistent with `verify-ai-output`). CLI reads a file or stdin (`-`), supports `--json`, and exits 1 on blocking errors. The `sigmap create` orchestration and `review-pr` remain follow-ups.
19
+
20
+ ---
21
+
13
22
  ## [7.10.0] — 2026-06-17
14
23
 
15
24
  Minor release — `sigmap scaffold` with a confidence floor (grounded codegen, Layer 4).
package/gen-context.js CHANGED
@@ -25,222 +25,135 @@ 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
+ __factories["./src/cache/freshen"] = function(module, exports) {
29
31
 
30
32
  /**
31
- * Scaffold proposal with a confidence floor (IMPL.md §5 Cause 3).
33
+ * Read-time self-heal (IMPL.md Layer 1, "safety net" tier).
32
34
  *
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.
35
+ * Keeps the sig-cache in line with the current source tree so the index reflects
36
+ * on-disk reality even when no write hook was called. Re-extracts files modified
37
+ * since the context file was generated (bounded to actual session edits, not the
38
+ * whole tree), drops cache entries for deleted files, and persists. buildSigIndex
39
+ * already merges the cache, so the next read is fresh.
40
+ *
41
+ * Throttled per cwd. Skips entirely when there is no generated index to heal
42
+ * (a cold repo should run `generate` or use the notify hooks).
43
+ *
44
+ * Zero-dependency, bundle-safe (fs + dispatch + sig-cache).
39
45
  */
40
46
 
41
- const { toNamingStyle, analyzeConflicts } = __require('./src/conventions/conflicts');
47
+ const fs = require('fs');
48
+ const path = require('path');
49
+ const { loadCache, saveCache, getChangedFiles } = __require('./src/cache/sig-cache');
50
+ const { extractFile, langFor } = __require('./src/extractors/dispatch');
42
51
 
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;
52
+ const DEFAULT_SRC_DIRS = ['src', 'app', 'lib', 'packages', 'services', 'api'];
53
+ const DEFAULT_EXCLUDE = [
54
+ 'node_modules', '.git', 'dist', 'build', 'out', '__pycache__',
55
+ '.next', 'coverage', 'target', 'vendor', '.context',
56
+ ];
57
+ const CONTEXT_PATHS = [
58
+ ['.github', 'copilot-instructions.md'],
59
+ ['CLAUDE.md'], ['AGENTS.md'], ['.github', 'context-cold.md'],
60
+ ];
61
+ const THROTTLE_MS = 1500;
62
+ const _lastRun = new Map();
46
63
 
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';
64
+ function _readConfig(cwd) {
65
+ try {
66
+ const cfg = JSON.parse(fs.readFileSync(path.join(cwd, 'gen-context.config.json'), 'utf8'));
67
+ return cfg && typeof cfg === 'object' ? cfg : {};
68
+ } catch (_) { return {}; }
52
69
  }
53
70
 
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;
71
+ function _pkgVersion(cwd) {
72
+ try { return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).version || '0.0.0'; }
73
+ catch (_) { return '0.0.0'; }
61
74
  }
62
75
 
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`;
76
+ /** Newest mtime among existing generated context files, or 0 if none. */
77
+ function _contextMtime(cwd) {
78
+ let newest = 0;
79
+ for (const parts of CONTEXT_PATHS) {
80
+ try { newest = Math.max(newest, fs.statSync(path.join(cwd, ...parts)).mtimeMs); } catch (_) {}
67
81
  }
68
- return `${styledStem}.test.${ext}`;
82
+ return newest;
69
83
  }
70
84
 
71
- /**
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
75
- * @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 }}
83
- */
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
- };
85
+ function _walk(dir, exclude, out, depth, maxDepth) {
86
+ if (depth > maxDepth) return;
87
+ let entries;
88
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
89
+ for (const e of entries) {
90
+ if (exclude.has(e.name)) continue;
91
+ const full = path.join(dir, e.name);
92
+ if (e.isDirectory()) _walk(full, exclude, out, depth + 1, maxDepth);
93
+ else if (e.isFile() && langFor(e.name)) out.push(full);
114
94
  }
115
-
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
- };
125
-
126
- 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,
136
- };
137
95
  }
138
96
 
139
- module.exports = { proposeScaffold, DEFAULT_THRESHOLD, HARD_FLOOR };
140
-
141
- };
142
-
143
- __factories["./src/conventions/inject"] = function(module, exports) {
144
-
145
97
  /**
146
- * CLAUDE.md convention injection (IMPL.md §7 Phase 2 / §8 step 5).
147
- *
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.
98
+ * Re-extract source files changed since the last generate; drop deleted files.
99
+ * @param {string} cwd
100
+ * @param {{force?:boolean, now?:number}} [opts]
101
+ * @returns {number} cache entries touched
153
102
  */
154
-
155
- const START = '<!-- sigmap-conventions:start -->';
156
- const END = '<!-- sigmap-conventions:end -->';
157
-
158
- const TIER_NOTE = {
159
- consistent: 'consistent — match it',
160
- mostly: 'dominant, with some drift',
161
- inconsistent: 'no clear convention — check neighboring files',
162
- };
163
-
164
- const NAMES = {
165
- fileNaming: 'File naming',
166
- exportStyle: 'Export style',
167
- };
168
-
169
- const _pct = (n) => `${Math.round(n * 100)}%`;
170
-
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(', ')}.`;
103
+ function freshen(cwd, opts = {}) {
104
+ const now = opts.now != null ? opts.now : Date.now();
105
+ if (!opts.force) {
106
+ if (now - (_lastRun.get(cwd) || 0) < THROTTLE_MS) return 0;
178
107
  }
179
- return line;
180
- }
108
+ _lastRun.set(cwd, now);
181
109
 
182
- /**
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}
187
- */
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}.`);
196
- }
110
+ try {
111
+ const version = _pkgVersion(cwd);
112
+ const cache = loadCache(cwd, version);
113
+ const ctxMtime = _contextMtime(cwd);
114
+ // Nothing to heal: no generated context AND no live cache overlay.
115
+ if (ctxMtime === 0 && cache.size === 0) return 0;
197
116
 
198
- const body = lines.length
199
- ? lines
200
- : ['- No conventions detected yet (run `sigmap conventions` on a TS/JS/Python repo).'];
117
+ const cfg = _readConfig(cwd);
118
+ const srcDirs = Array.isArray(cfg.srcDirs) && cfg.srcDirs.length ? cfg.srcDirs : DEFAULT_SRC_DIRS;
119
+ const exclude = new Set([...DEFAULT_EXCLUDE, ...(Array.isArray(cfg.exclude) ? cfg.exclude : [])]);
120
+ const maxDepth = Number.isFinite(cfg.maxDepth) ? cfg.maxDepth : 8;
201
121
 
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
- }
122
+ const files = [];
123
+ for (const d of srcDirs) {
124
+ const abs = path.isAbsolute(d) ? d : path.join(cwd, d);
125
+ if (fs.existsSync(abs)) _walk(abs, exclude, files, 0, maxDepth);
126
+ }
215
127
 
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;
128
+ // Candidates = files modified since the context was generated, or not yet cached.
129
+ const candidates = files.filter((f) => {
130
+ try { return fs.statSync(f).mtimeMs > ctxMtime || !cache.has(f); } catch (_) { return false; }
131
+ });
132
+ const { changed } = getChangedFiles(candidates, cache);
133
+
134
+ let touched = 0;
135
+ for (const f of changed) {
136
+ try {
137
+ const sigs = extractFile(f, fs.readFileSync(f, 'utf8'));
138
+ cache.set(f, { mtime: fs.statSync(f).mtimeMs, sigs });
139
+ touched++;
140
+ } catch (_) {}
233
141
  }
142
+ // Note: deletions are NOT swept here — a cache entry may be a `notify`
143
+ // overlay for a file not yet on disk. Explicit removal is `notify_file_deleted`.
144
+
145
+ if (touched > 0) saveCache(cwd, version, cache);
146
+ return touched;
147
+ } catch (_) {
148
+ return 0;
234
149
  }
235
- if (src.trim() === '') return block + '\n';
236
- const sep = src.endsWith('\n') ? '\n' : '\n\n';
237
- return src + sep + block + '\n';
238
150
  }
239
151
 
240
- module.exports = { renderConventionsBlock, injectConventions, START, END };
152
+ module.exports = { freshen };
241
153
 
242
154
  };
243
155
 
156
+ // ── ./src/conventions/conflicts ──
244
157
  __factories["./src/conventions/conflicts"] = function(module, exports) {
245
158
 
246
159
  /**
@@ -355,6 +268,7 @@ __factories["./src/conventions/conflicts"] = function(module, exports) {
355
268
 
356
269
  };
357
270
 
271
+ // ── ./src/conventions/extract ──
358
272
  __factories["./src/conventions/extract"] = function(module, exports) {
359
273
 
360
274
  /**
@@ -399,24 +313,42 @@ __factories["./src/conventions/extract"] = function(module, exports) {
399
313
  return 'other';
400
314
  }
401
315
 
316
+ const MAX_EXAMPLES = 3;
317
+
402
318
  /**
403
319
  * Score a set of categorical observations into a dominant convention plus its
404
320
  * consistency tier. The reusable primitive (IMPL.md §5.2).
405
321
  * @param {string[]} labels observed category for each sample (e.g. naming styles)
322
+ * @param {string[]} [refs] optional identifier (e.g. file name) parallel to
323
+ * `labels`; when given, each variant carries up to 3 `examples`.
406
324
  * @returns {{ dominant: string|null, dominantPct: number, total: number,
407
- * variants: Array<{label:string, count:number, pct:number}>,
325
+ * variants: Array<{label:string, count:number, pct:number, examples?:string[]}>,
408
326
  * tier: 'consistent'|'mostly'|'inconsistent'|'unknown' }}
409
327
  */
410
- function scoreConvention(labels) {
411
- const list = (labels || []).filter((l) => l != null && l !== 'other');
412
- const total = list.length;
328
+ function scoreConvention(labels, refs) {
329
+ const all = labels || [];
330
+ const counts = new Map();
331
+ const examples = new Map();
332
+ let total = 0;
333
+ for (let i = 0; i < all.length; i++) {
334
+ const l = all[i];
335
+ if (l == null || l === 'other') continue;
336
+ total++;
337
+ counts.set(l, (counts.get(l) || 0) + 1);
338
+ if (refs && refs[i] != null) {
339
+ const ex = examples.get(l) || [];
340
+ if (ex.length < MAX_EXAMPLES) { ex.push(refs[i]); examples.set(l, ex); }
341
+ }
342
+ }
413
343
  if (total === 0) {
414
344
  return { dominant: null, dominantPct: 0, total: 0, variants: [], tier: 'unknown' };
415
345
  }
416
- const counts = new Map();
417
- for (const l of list) counts.set(l, (counts.get(l) || 0) + 1);
418
346
  const variants = [...counts.entries()]
419
- .map(([label, count]) => ({ label, count, pct: count / total }))
347
+ .map(([label, count]) => {
348
+ const v = { label, count, pct: count / total };
349
+ if (refs) v.examples = examples.get(label) || [];
350
+ return v;
351
+ })
420
352
  .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
421
353
  const top = variants[0];
422
354
  let tier = 'inconsistent';
@@ -488,157 +420,365 @@ __factories["./src/conventions/extract"] = function(module, exports) {
488
420
  function extractConventions(cwd, files) {
489
421
  const scoped = (files || []).filter((f) => SCOPED_EXTS.has(path.extname(f).toLowerCase()));
490
422
  const namingLabels = [];
423
+ const namingRefs = [];
491
424
  const exportLabels = [];
425
+ const exportRefs = [];
492
426
  for (const f of scoped) {
493
427
  const base = path.basename(f);
494
428
  // Skip test files for the naming convention (they have their own naming).
495
429
  if (!/\.(test|spec)\.[jt]sx?$|(^|\/)test_|_test\.py$/.test(f)) {
496
430
  namingLabels.push(classifyNaming(base));
431
+ namingRefs.push(base);
497
432
  }
498
433
  if (JS_TS_EXTS.has(path.extname(f).toLowerCase())) {
499
434
  let src = '';
500
435
  try { src = fs.readFileSync(f, 'utf8'); } catch (_) {}
501
436
  exportLabels.push(_jsExportStyle(src));
437
+ exportRefs.push(base);
438
+ }
439
+ }
440
+ return {
441
+ fileNaming: scoreConvention(namingLabels, namingRefs),
442
+ exportStyle: scoreConvention(exportLabels, exportRefs),
443
+ testFramework: _detectTestFramework(cwd, scoped),
444
+ scope: ['typescript', 'javascript', 'python'],
445
+ scannedFiles: scoped.length,
446
+ };
447
+ }
448
+
449
+ module.exports = { classifyNaming, scoreConvention, extractConventions };
450
+
451
+ };
452
+
453
+ // ── ./src/conventions/inject ──
454
+ __factories["./src/conventions/inject"] = function(module, exports) {
455
+
456
+ /**
457
+ * CLAUDE.md convention injection (IMPL.md §7 Phase 2 / §8 step 5).
458
+ *
459
+ * Renders the conventions detected by `extractConventions` into a
460
+ * marker-delimited markdown block and injects it into CLAUDE.md so an agent
461
+ * reading the file plans grounded in the repo's house style. Idempotent and
462
+ * marker-scoped — it never touches human content or the `## Auto-generated
463
+ * signatures` block. Pure string transforms; zero-dependency, bundle-safe.
464
+ */
465
+
466
+ const START = '<!-- sigmap-conventions:start -->';
467
+ const END = '<!-- sigmap-conventions:end -->';
468
+
469
+ const TIER_NOTE = {
470
+ consistent: 'consistent — match it',
471
+ mostly: 'dominant, with some drift',
472
+ inconsistent: 'no clear convention — check neighboring files',
473
+ };
474
+
475
+ const NAMES = {
476
+ fileNaming: 'File naming',
477
+ exportStyle: 'Export style',
478
+ };
479
+
480
+ const _pct = (n) => `${Math.round(n * 100)}%`;
481
+
482
+ function _conventionLine(label, conv) {
483
+ if (!conv || conv.total === 0 || !conv.dominant) return null;
484
+ const note = TIER_NOTE[conv.tier] || conv.tier;
485
+ let line = `- **${label}:** ${conv.dominant} (${_pct(conv.dominantPct)} — ${note}).`;
486
+ const others = conv.variants.slice(1).filter((v) => v.pct > 0);
487
+ if (others.length) {
488
+ line += ` Variants: ${others.map((v) => `${v.label} ${_pct(v.pct)}`).join(', ')}.`;
489
+ }
490
+ return line;
491
+ }
492
+
493
+ /**
494
+ * Render the conventions block (including its start/end markers).
495
+ * @param {object} result an `extractConventions` result
496
+ * @param {string} [version] SigMap version for the footer
497
+ * @returns {string}
498
+ */
499
+ function renderConventionsBlock(result, version) {
500
+ const lines = [];
501
+ for (const key of ['fileNaming', 'exportStyle']) {
502
+ const l = _conventionLine(NAMES[key], result && result[key]);
503
+ if (l) lines.push(l);
504
+ }
505
+ if (result && result.testFramework) {
506
+ lines.push(`- **Test framework:** ${result.testFramework}.`);
507
+ }
508
+
509
+ const body = lines.length
510
+ ? lines
511
+ : ['- No conventions detected yet (run `sigmap conventions` on a TS/JS/Python repo).'];
512
+
513
+ const ver = version ? ` v${version}` : '';
514
+ return [
515
+ START,
516
+ '## Conventions (auto-detected by SigMap)',
517
+ '',
518
+ 'Match these when writing or editing code (TS/JS/Python):',
519
+ '',
520
+ ...body,
521
+ '',
522
+ `<sub>Generated by SigMap${ver} · run \`sigmap conventions --inject\` to refresh.</sub>`,
523
+ END,
524
+ ].join('\n');
525
+ }
526
+
527
+ /**
528
+ * Inject (or replace) the conventions block in existing CLAUDE.md content.
529
+ * Replaces an existing marked block in place; appends one when absent.
530
+ * Idempotent — never touches content outside the markers.
531
+ * @param {string} existing current file content ('' if the file is new)
532
+ * @param {string} block the block returned by `renderConventionsBlock`
533
+ * @returns {string}
534
+ */
535
+ function injectConventions(existing, block) {
536
+ const src = String(existing || '');
537
+ const startIdx = src.indexOf(START);
538
+ if (startIdx !== -1) {
539
+ const endIdx = src.indexOf(END, startIdx);
540
+ if (endIdx !== -1) {
541
+ const before = src.slice(0, startIdx);
542
+ const after = src.slice(endIdx + END.length);
543
+ return before + block + after;
502
544
  }
503
545
  }
504
- return {
505
- fileNaming: scoreConvention(namingLabels),
506
- exportStyle: scoreConvention(exportLabels),
507
- testFramework: _detectTestFramework(cwd, scoped),
508
- scope: ['typescript', 'javascript', 'python'],
509
- scannedFiles: scoped.length,
510
- };
546
+ if (src.trim() === '') return block + '\n';
547
+ const sep = src.endsWith('\n') ? '\n' : '\n\n';
548
+ return src + sep + block + '\n';
511
549
  }
512
550
 
513
- module.exports = { classifyNaming, scoreConvention, extractConventions };
551
+ module.exports = { renderConventionsBlock, injectConventions, START, END };
514
552
 
515
553
  };
516
554
 
517
- __factories["./src/cache/freshen"] = function(module, exports) {
555
+ // ── ./src/scaffold/propose ──
556
+ __factories["./src/scaffold/propose"] = function(module, exports) {
518
557
 
519
558
  /**
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).
559
+ * Scaffold proposal with a confidence floor (IMPL.md §5 Cause 3).
530
560
  *
531
- * Zero-dependency, bundle-safe (fs + dispatch + sig-cache).
561
+ * Proposes a convention-matched structure for a new module — filename in the
562
+ * repo's dominant naming style, the export style to use, and a matching test
563
+ * file — but only when the conventions are consistent enough. Below a hard
564
+ * floor it refuses and surfaces the conflict, because a wrong proposal
565
+ * systematizes bad code. Pure, zero-dependency, bundle-safe; reuses the
566
+ * conventions primitives.
532
567
  */
533
568
 
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');
569
+ const { toNamingStyle, analyzeConflicts } = __require('./src/conventions/conflicts');
538
570
 
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();
571
+ // Soft threshold is configurable; the hard floor is not (IMPL.md §5.1).
572
+ const DEFAULT_THRESHOLD = 0.7;
573
+ const HARD_FLOOR = 0.5;
550
574
 
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 {}; }
575
+ /** Tier for a consistency score (matches the conventions tiers). */
576
+ function _tier(pct) {
577
+ if (pct >= 0.9) return 'consistent';
578
+ if (pct >= 0.7) return 'mostly';
579
+ return 'inconsistent';
556
580
  }
557
581
 
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'; }
582
+ /** Strip any extension/compound suffix from a requested name → bare stem. */
583
+ function _stem(name) {
584
+ const s = String(name || '').trim();
585
+ const slash = s.lastIndexOf('/');
586
+ const base = slash >= 0 ? s.slice(slash + 1) : s;
587
+ const dot = base.indexOf('.');
588
+ return dot > 0 ? base.slice(0, dot) : base;
561
589
  }
562
590
 
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 (_) {}
591
+ /** Test file path for a styled stem given the detected framework + ext. */
592
+ function _testFile(styledStem, framework, ext) {
593
+ if (framework === 'pytest' || framework === 'unittest') {
594
+ return `test_${toNamingStyle(styledStem, 'snake_case')}.py`;
568
595
  }
569
- return newest;
596
+ return `${styledStem}.test.${ext}`;
570
597
  }
571
598
 
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);
599
+ /**
600
+ * Propose a convention-matched scaffold, gated by a confidence floor.
601
+ * @param {string} name desired module name (any casing; extension ignored)
602
+ * @param {object} conventions an `extractConventions` result
603
+ * @param {object} [opts]
604
+ * @param {number} [opts.threshold=0.7] soft threshold (clamped to ≥ hard floor)
605
+ * @param {boolean} [opts.force=false] allow proposing below the soft threshold
606
+ * (never below the hard floor)
607
+ * @param {string} [opts.ext='js'] file extension for the proposed files
608
+ * @returns {{ ok:boolean, refused:boolean, name:string, tier:string,
609
+ * confidence:number, threshold:number, hardFloor:number, forced:boolean,
610
+ * warning:string|null, reason:string, proposal:object|null, conflicts:object }}
611
+ */
612
+ function proposeScaffold(name, conventions, opts = {}) {
613
+ const threshold = Math.max(HARD_FLOOR, opts.threshold != null ? opts.threshold : DEFAULT_THRESHOLD);
614
+ const force = !!opts.force;
615
+ const ext = opts.ext || 'js';
616
+ const fileNaming = (conventions && conventions.fileNaming) || { dominant: null, dominantPct: 0, total: 0 };
617
+ const exportStyle = (conventions && conventions.exportStyle) || { dominant: null };
618
+ const confidence = fileNaming.dominantPct || 0;
619
+ const tier = fileNaming.total > 0 ? _tier(confidence) : 'unknown';
620
+ const conflicts = analyzeConflicts(conventions || {});
621
+
622
+ const base = {
623
+ ok: false, refused: true, name: String(name || ''), tier, confidence,
624
+ threshold, hardFloor: HARD_FLOOR, forced: false, warning: null,
625
+ reason: '', proposal: null, conflicts,
626
+ };
627
+
628
+ if (!fileNaming.dominant || fileNaming.total === 0) {
629
+ return { ...base, reason: 'no file-naming convention detected — cannot propose a name' };
630
+ }
631
+ if (confidence < HARD_FLOOR) {
632
+ return {
633
+ ...base,
634
+ reason: `file-naming consistency ${(confidence * 100).toFixed(0)}% is below the hard floor ${(HARD_FLOOR * 100).toFixed(0)}% — refusing (not overridable)`,
635
+ };
636
+ }
637
+ if (confidence < threshold && !force) {
638
+ return {
639
+ ...base,
640
+ 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)`,
641
+ };
581
642
  }
643
+
644
+ const styledStem = toNamingStyle(_stem(name), fileNaming.dominant);
645
+ const forced = confidence < threshold && force;
646
+ const proposal = {
647
+ filename: `${styledStem}.${ext}`,
648
+ namingStyle: fileNaming.dominant,
649
+ exportStyle: exportStyle.dominant || 'named',
650
+ testFile: _testFile(styledStem, conventions.testFramework, ext),
651
+ testFramework: conventions.testFramework || null,
652
+ };
653
+
654
+ return {
655
+ ...base,
656
+ ok: true,
657
+ refused: false,
658
+ forced,
659
+ warning: forced
660
+ ? `proposed below the ${(threshold * 100).toFixed(0)}% threshold (--force); conventions are only ${tier}`
661
+ : null,
662
+ reason: forced ? 'forced proposal above the hard floor' : `conventions are ${tier} — proposing`,
663
+ proposal,
664
+ };
582
665
  }
583
666
 
667
+ module.exports = { proposeScaffold, DEFAULT_THRESHOLD, HARD_FLOOR };
668
+
669
+ };
670
+
671
+ __factories["./src/plan/verify-plan"] = function(module, exports) {
672
+
584
673
  /**
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
674
+ * verify-plan (IMPL.md §6.1 Gap 2, step 2 of the `create` pipeline).
675
+ *
676
+ * Checks a plan (markdown) against the LIVE index before execution: do the
677
+ * referenced files and symbols exist, is the blast radius acceptable, is the
678
+ * scope in bounds? Catches Cause 1+2 at plan time — cheaper than after the
679
+ * code is written. Reuses the verify primitives + the impact graph.
680
+ * Zero-dependency, bundle-safe.
589
681
  */
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
682
 
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;
683
+ const fs = require('fs');
684
+ const path = require('path');
685
+ const { extractFilePaths, extractSymbols } = __require('./src/verify/parsers');
686
+ const { buildSymbolSet } = __require('./src/verify/hallucination-guard');
687
+ const { closestMatch } = __require('./src/verify/closest-match');
688
+ const { analyzeImpact } = __require('./src/graph/impact');
603
689
 
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;
690
+ const DEFAULT_BLAST_THRESHOLD = 20; // transitive+direct dependents → "high blast radius"
691
+ const DEFAULT_SCOPE_THRESHOLD = 10; // distinct referenced files "broad scope"
608
692
 
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
- }
693
+ /** Resolve a referenced path against cwd (handles a leading "./"). */
694
+ function _fileExists(cwd, ref) {
695
+ const clean = ref.replace(/^\.\//, '');
696
+ for (const c of [path.resolve(cwd, clean), path.resolve(cwd, ref)]) {
697
+ try { if (fs.existsSync(c)) return true; } catch (_) {}
698
+ }
699
+ return false;
700
+ }
614
701
 
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; }
702
+ /**
703
+ * Verify a plan against the live index.
704
+ * @param {string} planText the plan as markdown
705
+ * @param {string} cwd repo root
706
+ * @param {object} [opts]
707
+ * @param {number} [opts.blastThreshold=20]
708
+ * @param {number} [opts.scopeThreshold=10]
709
+ * @param {(ref:string)=>boolean} [opts.fileExists] override for testing
710
+ * @returns {{ issues: object[], blast: object[], scope: object, summary: object }}
711
+ */
712
+ function verifyPlan(planText, cwd, opts = {}) {
713
+ const blastThreshold = opts.blastThreshold != null ? opts.blastThreshold : DEFAULT_BLAST_THRESHOLD;
714
+ const scopeThreshold = opts.scopeThreshold != null ? opts.scopeThreshold : DEFAULT_SCOPE_THRESHOLD;
715
+ const fileExists = opts.fileExists || ((ref) => _fileExists(cwd, ref));
716
+
717
+ const text = String(planText || '');
718
+ const filesRef = extractFilePaths(text); // [{ path, line }]
719
+ const symbolsRef = extractSymbols(text); // [{ name, line }]
720
+ const { set: symbolSet, symbolCandidates } = buildSymbolSet(cwd);
721
+
722
+ const issues = [];
723
+
724
+ // 1. Referenced files must exist.
725
+ const existingFiles = [];
726
+ for (const f of filesRef) {
727
+ if (fileExists(f.path)) existingFiles.push(f.path);
728
+ else issues.push({ type: 'missing-file', ref: f.path, line: f.line, severity: 'error' });
729
+ }
730
+
731
+ // 2. Referenced symbols must exist in the live index (suggest a near match).
732
+ for (const s of symbolsRef) {
733
+ if (symbolSet.has(s.name)) continue;
734
+ const match = closestMatch(s.name, symbolCandidates);
735
+ issues.push({
736
+ type: 'unknown-symbol', ref: s.name, line: s.line, severity: 'error',
737
+ suggestion: match ? match.name : null,
618
738
  });
619
- const { changed } = getChangedFiles(candidates, cache);
739
+ }
620
740
 
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 (_) {}
741
+ // 3. Blast radius for each existing referenced file (one graph build).
742
+ const blast = [];
743
+ if (existingFiles.length) {
744
+ let impacts = [];
745
+ try { impacts = analyzeImpact(existingFiles, cwd, {}); } catch (_) { impacts = []; }
746
+ for (const { file, impact } of impacts) {
747
+ const entry = { file, totalImpact: impact.totalImpact, tests: impact.tests.length };
748
+ blast.push(entry);
749
+ if (impact.totalImpact > blastThreshold) {
750
+ issues.push({ type: 'high-blast-radius', ref: file, count: impact.totalImpact, severity: 'warn' });
751
+ }
628
752
  }
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`.
753
+ blast.sort((a, b) => b.totalImpact - a.totalImpact);
754
+ }
631
755
 
632
- if (touched > 0) saveCache(cwd, version, cache);
633
- return touched;
634
- } catch (_) {
635
- return 0;
756
+ // 4. Scope.
757
+ const scope = { files: filesRef.length, symbols: symbolsRef.length, threshold: scopeThreshold };
758
+ if (filesRef.length > scopeThreshold) {
759
+ issues.push({ type: 'broad-scope', count: filesRef.length, threshold: scopeThreshold, severity: 'warn' });
636
760
  }
761
+
762
+ const errors = issues.filter((i) => i.severity === 'error').length;
763
+ const warnings = issues.filter((i) => i.severity === 'warn').length;
764
+ return {
765
+ issues,
766
+ blast,
767
+ scope,
768
+ summary: {
769
+ filesReferenced: filesRef.length,
770
+ symbolsReferenced: symbolsRef.length,
771
+ errors,
772
+ warnings,
773
+ ok: errors === 0,
774
+ },
775
+ };
637
776
  }
638
777
 
639
- module.exports = { freshen };
778
+ module.exports = { verifyPlan, DEFAULT_BLAST_THRESHOLD, DEFAULT_SCOPE_THRESHOLD };
640
779
 
641
780
  };
781
+
642
782
  // ── ./src/extractors/dispatch ──
643
783
  __factories["./src/extractors/dispatch"] = function(module, exports) {
644
784
 
@@ -7119,7 +7259,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
7119
7259
 
7120
7260
  const SERVER_INFO = {
7121
7261
  name: 'sigmap',
7122
- version: '7.10.0',
7262
+ version: '7.11.0',
7123
7263
  description: 'SigMap MCP server — code signatures on demand',
7124
7264
  };
7125
7265
 
@@ -12797,7 +12937,7 @@ function __tryGit(args, opts = {}) {
12797
12937
  catch (_) { return ''; }
12798
12938
  }
12799
12939
 
12800
- const VERSION = '7.10.0';
12940
+ const VERSION = '7.11.0';
12801
12941
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
12802
12942
 
12803
12943
  function requireSourceOrBundled(key) {
@@ -16151,6 +16291,47 @@ function main() {
16151
16291
  }
16152
16292
 
16153
16293
  // `sigmap verify-ai-output <answer.md>` — Hallucination Guard (deterministic core)
16294
+ // Gap 2 (step 2): `sigmap verify-plan <plan.md>` — check a plan against the
16295
+ // live index before execution (files/symbols exist, blast radius, scope).
16296
+ if (args[0] === 'verify-plan') {
16297
+ const target = args[1] && !args[1].startsWith('--') ? args[1] : null;
16298
+ const jsonOut = args.includes('--json');
16299
+ if (!target) {
16300
+ console.error('[sigmap] Usage: sigmap verify-plan <plan.md|-> [--json]');
16301
+ process.exit(1);
16302
+ }
16303
+ let planText = '';
16304
+ try {
16305
+ planText = fs.readFileSync(target === '-' ? 0 : path.resolve(cwd, target), 'utf8');
16306
+ } catch (e) {
16307
+ console.error(`[sigmap] cannot read plan: ${e.message}`);
16308
+ process.exit(1);
16309
+ }
16310
+ const { verifyPlan } = requireSourceOrBundled('./src/plan/verify-plan');
16311
+ const result = verifyPlan(planText, cwd);
16312
+
16313
+ if (jsonOut) {
16314
+ process.stdout.write(JSON.stringify(result) + '\n');
16315
+ process.exit(result.summary.ok ? 0 : 1);
16316
+ }
16317
+
16318
+ const s = result.summary;
16319
+ console.log(`[sigmap] verify-plan — ${s.filesReferenced} file(s), ${s.symbolsReferenced} symbol(s) referenced`);
16320
+ if (result.issues.length === 0) {
16321
+ console.log(' ✓ plan checks out — all references exist, blast radius and scope in bounds');
16322
+ process.exit(0);
16323
+ }
16324
+ for (const i of result.issues) {
16325
+ const mark = i.severity === 'error' ? '✗' : '⚠';
16326
+ if (i.type === 'missing-file') console.log(` ${mark} missing file: ${i.ref} (line ${i.line})`);
16327
+ else if (i.type === 'unknown-symbol') console.log(` ${mark} unknown symbol: ${i.ref}()${i.suggestion ? ` — did you mean ${i.suggestion}()?` : ''} (line ${i.line})`);
16328
+ else if (i.type === 'high-blast-radius') console.log(` ${mark} high blast radius: ${i.ref} → ${i.count} dependents`);
16329
+ else if (i.type === 'broad-scope') console.log(` ${mark} broad scope: ${i.count} files (threshold ${i.threshold})`);
16330
+ }
16331
+ console.log(`\n ${s.errors} error(s), ${s.warnings} warning(s)`);
16332
+ process.exit(s.ok ? 0 : 1);
16333
+ }
16334
+
16154
16335
  if (args[0] === 'verify-ai-output') {
16155
16336
  const target = args[1];
16156
16337
  const jsonOut = args.includes('--json');
package/llms-full.txt CHANGED
@@ -9,7 +9,7 @@ the files relevant to the task — cutting tokens ~97% while keeping answers
9
9
  grounded. Deterministic, offline, no embeddings or vector database. Works with
10
10
  Claude, Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
11
11
 
12
- # Version: 7.10.0 | Benchmark: sigmap-v7.0-main (2026-06-14)
12
+ # Version: 7.11.0 | Benchmark: sigmap-v7.0-main (2026-06-14)
13
13
  # Source: auto-generated from package.json, version.json, src/mcp/tools.js, src/config/defaults.js
14
14
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
15
15
 
package/llms.txt CHANGED
@@ -9,7 +9,7 @@ the files relevant to the task — cutting tokens ~97% while keeping answers
9
9
  grounded. Deterministic, offline, no embeddings or vector database. Works with
10
10
  Claude, Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
11
11
 
12
- # Version: 7.10.0 | Benchmark: sigmap-v7.0-main (2026-06-14)
12
+ # Version: 7.11.0 | Benchmark: sigmap-v7.0-main (2026-06-14)
13
13
  # Source: auto-generated from package.json, version.json, src/mcp/tools.js, src/config/defaults.js
14
14
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "7.10.0",
3
+ "version": "7.11.0",
4
4
  "description": "97% token reduction for AI coding. Extracts function & class signatures with TF-IDF ranking to feed only the right files to Claude, Cursor, Copilot, Aider, Windsurf, local LLMs & MCP. Zero dependencies, runs offline via npx.",
5
5
  "main": "packages/core/index.js",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "7.10.0",
3
+ "version": "7.11.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "7.10.0",
3
+ "version": "7.11.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
package/src/mcp/server.js CHANGED
@@ -18,7 +18,7 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '7.10.0',
21
+ version: '7.11.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * verify-plan (IMPL.md §6.1 — Gap 2, step 2 of the `create` pipeline).
5
+ *
6
+ * Checks a plan (markdown) against the LIVE index before execution: do the
7
+ * referenced files and symbols exist, is the blast radius acceptable, is the
8
+ * scope in bounds? Catches Cause 1+2 at plan time — cheaper than after the
9
+ * code is written. Reuses the verify primitives + the impact graph.
10
+ * Zero-dependency, bundle-safe.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { extractFilePaths, extractSymbols } = require('../verify/parsers');
16
+ const { buildSymbolSet } = require('../verify/hallucination-guard');
17
+ const { closestMatch } = require('../verify/closest-match');
18
+ const { analyzeImpact } = require('../graph/impact');
19
+
20
+ const DEFAULT_BLAST_THRESHOLD = 20; // transitive+direct dependents → "high blast radius"
21
+ const DEFAULT_SCOPE_THRESHOLD = 10; // distinct referenced files → "broad scope"
22
+
23
+ /** Resolve a referenced path against cwd (handles a leading "./"). */
24
+ function _fileExists(cwd, ref) {
25
+ const clean = ref.replace(/^\.\//, '');
26
+ for (const c of [path.resolve(cwd, clean), path.resolve(cwd, ref)]) {
27
+ try { if (fs.existsSync(c)) return true; } catch (_) {}
28
+ }
29
+ return false;
30
+ }
31
+
32
+ /**
33
+ * Verify a plan against the live index.
34
+ * @param {string} planText the plan as markdown
35
+ * @param {string} cwd repo root
36
+ * @param {object} [opts]
37
+ * @param {number} [opts.blastThreshold=20]
38
+ * @param {number} [opts.scopeThreshold=10]
39
+ * @param {(ref:string)=>boolean} [opts.fileExists] override for testing
40
+ * @returns {{ issues: object[], blast: object[], scope: object, summary: object }}
41
+ */
42
+ function verifyPlan(planText, cwd, opts = {}) {
43
+ const blastThreshold = opts.blastThreshold != null ? opts.blastThreshold : DEFAULT_BLAST_THRESHOLD;
44
+ const scopeThreshold = opts.scopeThreshold != null ? opts.scopeThreshold : DEFAULT_SCOPE_THRESHOLD;
45
+ const fileExists = opts.fileExists || ((ref) => _fileExists(cwd, ref));
46
+
47
+ const text = String(planText || '');
48
+ const filesRef = extractFilePaths(text); // [{ path, line }]
49
+ const symbolsRef = extractSymbols(text); // [{ name, line }]
50
+ const { set: symbolSet, symbolCandidates } = buildSymbolSet(cwd);
51
+
52
+ const issues = [];
53
+
54
+ // 1. Referenced files must exist.
55
+ const existingFiles = [];
56
+ for (const f of filesRef) {
57
+ if (fileExists(f.path)) existingFiles.push(f.path);
58
+ else issues.push({ type: 'missing-file', ref: f.path, line: f.line, severity: 'error' });
59
+ }
60
+
61
+ // 2. Referenced symbols must exist in the live index (suggest a near match).
62
+ for (const s of symbolsRef) {
63
+ if (symbolSet.has(s.name)) continue;
64
+ const match = closestMatch(s.name, symbolCandidates);
65
+ issues.push({
66
+ type: 'unknown-symbol', ref: s.name, line: s.line, severity: 'error',
67
+ suggestion: match ? match.name : null,
68
+ });
69
+ }
70
+
71
+ // 3. Blast radius for each existing referenced file (one graph build).
72
+ const blast = [];
73
+ if (existingFiles.length) {
74
+ let impacts = [];
75
+ try { impacts = analyzeImpact(existingFiles, cwd, {}); } catch (_) { impacts = []; }
76
+ for (const { file, impact } of impacts) {
77
+ const entry = { file, totalImpact: impact.totalImpact, tests: impact.tests.length };
78
+ blast.push(entry);
79
+ if (impact.totalImpact > blastThreshold) {
80
+ issues.push({ type: 'high-blast-radius', ref: file, count: impact.totalImpact, severity: 'warn' });
81
+ }
82
+ }
83
+ blast.sort((a, b) => b.totalImpact - a.totalImpact);
84
+ }
85
+
86
+ // 4. Scope.
87
+ const scope = { files: filesRef.length, symbols: symbolsRef.length, threshold: scopeThreshold };
88
+ if (filesRef.length > scopeThreshold) {
89
+ issues.push({ type: 'broad-scope', count: filesRef.length, threshold: scopeThreshold, severity: 'warn' });
90
+ }
91
+
92
+ const errors = issues.filter((i) => i.severity === 'error').length;
93
+ const warnings = issues.filter((i) => i.severity === 'warn').length;
94
+ return {
95
+ issues,
96
+ blast,
97
+ scope,
98
+ summary: {
99
+ filesReferenced: filesRef.length,
100
+ symbolsReferenced: symbolsRef.length,
101
+ errors,
102
+ warnings,
103
+ ok: errors === 0,
104
+ },
105
+ };
106
+ }
107
+
108
+ module.exports = { verifyPlan, DEFAULT_BLAST_THRESHOLD, DEFAULT_SCOPE_THRESHOLD };