sigmap 7.9.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,24 @@ 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
+
22
+ ## [7.10.0] — 2026-06-17
23
+
24
+ Minor release — `sigmap scaffold` with a confidence floor (grounded codegen, Layer 4).
25
+
26
+ ### Added
27
+ - **`sigmap scaffold <name>` — convention-matched proposal with a confidence floor (#307):** the first slice of Layer 4 (Cause 3 — guessing structure for new code). Proposes a convention-matched structure for a new module — filename in the repo's dominant naming style, the export style to use, and a matching test file — but **only when the conventions are consistent enough**. New zero-dependency, bundle-safe `src/scaffold/propose.js` (`proposeScaffold`): the governing confidence is file-naming consistency, with a soft threshold (default 0.70, overridable via `--threshold`) and a **non-overridable hard floor of 0.50**. Below the threshold it refuses and surfaces the conflict (reusing `analyzeConflicts`); `--force` allows a proposal between the floor and the threshold (flagged with a warning), but never below the floor — a wrong proposal systematizes bad code. CLI supports `--ext`, `--threshold`, `--force`, and `--json` (refusal exits 1). Scaffold persistence (`.sigmap/scaffold/latest.md`), `--naming-pattern` override, and the `verify-plan` → `create` → `review-pr` pipeline remain follow-ups.
28
+
29
+ ---
30
+
13
31
  ## [7.9.0] — 2026-06-17
14
32
 
15
33
  Minor release — `sigmap conventions --inject` (grounded codegen, Layer 3).
package/gen-context.js CHANGED
@@ -24,107 +24,136 @@ function __require(key) {
24
24
  // ── ./src/conventions/extract ──
25
25
  // ── ./src/conventions/conflicts ──
26
26
  // ── ./src/conventions/inject ──
27
- __factories["./src/conventions/inject"] = function(module, exports) {
27
+ // ── ./src/scaffold/propose ──
28
+ // ── ./src/plan/verify-plan ──
29
+ // ── ./src/cache/freshen ──
30
+ __factories["./src/cache/freshen"] = function(module, exports) {
28
31
 
29
32
  /**
30
- * CLAUDE.md convention injection (IMPL.md §7 Phase 2 / §8 step 5).
33
+ * Read-time self-heal (IMPL.md Layer 1, "safety net" tier).
31
34
  *
32
- * Renders the conventions detected by `extractConventions` into a
33
- * marker-delimited markdown block and injects it into CLAUDE.md so an agent
34
- * reading the file plans grounded in the repo's house style. Idempotent and
35
- * marker-scoped it never touches human content or the `## Auto-generated
36
- * signatures` block. Pure string transforms; zero-dependency, bundle-safe.
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).
37
45
  */
38
46
 
39
- const START = '<!-- sigmap-conventions:start -->';
40
- const END = '<!-- sigmap-conventions:end -->';
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');
41
51
 
42
- const TIER_NOTE = {
43
- consistent: 'consistent match it',
44
- mostly: 'dominant, with some drift',
45
- inconsistent: 'no clear convention — check neighboring files',
46
- };
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();
47
63
 
48
- const NAMES = {
49
- fileNaming: 'File naming',
50
- exportStyle: 'Export style',
51
- };
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 {}; }
69
+ }
52
70
 
53
- const _pct = (n) => `${Math.round(n * 100)}%`;
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'; }
74
+ }
54
75
 
55
- function _conventionLine(label, conv) {
56
- if (!conv || conv.total === 0 || !conv.dominant) return null;
57
- const note = TIER_NOTE[conv.tier] || conv.tier;
58
- let line = `- **${label}:** ${conv.dominant} (${_pct(conv.dominantPct)} — ${note}).`;
59
- const others = conv.variants.slice(1).filter((v) => v.pct > 0);
60
- if (others.length) {
61
- line += ` Variants: ${others.map((v) => `${v.label} ${_pct(v.pct)}`).join(', ')}.`;
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 (_) {}
81
+ }
82
+ return newest;
83
+ }
84
+
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);
62
94
  }
63
- return line;
64
95
  }
65
96
 
66
97
  /**
67
- * Render the conventions block (including its start/end markers).
68
- * @param {object} result an `extractConventions` result
69
- * @param {string} [version] SigMap version for the footer
70
- * @returns {string}
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
71
102
  */
72
- function renderConventionsBlock(result, version) {
73
- const lines = [];
74
- for (const key of ['fileNaming', 'exportStyle']) {
75
- const l = _conventionLine(NAMES[key], result && result[key]);
76
- if (l) lines.push(l);
77
- }
78
- if (result && result.testFramework) {
79
- lines.push(`- **Test framework:** ${result.testFramework}.`);
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;
80
107
  }
108
+ _lastRun.set(cwd, now);
81
109
 
82
- const body = lines.length
83
- ? lines
84
- : ['- No conventions detected yet (run `sigmap conventions` on a TS/JS/Python repo).'];
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;
85
116
 
86
- const ver = version ? ` v${version}` : '';
87
- return [
88
- START,
89
- '## Conventions (auto-detected by SigMap)',
90
- '',
91
- 'Match these when writing or editing code (TS/JS/Python):',
92
- '',
93
- ...body,
94
- '',
95
- `<sub>Generated by SigMap${ver} · run \`sigmap conventions --inject\` to refresh.</sub>`,
96
- END,
97
- ].join('\n');
98
- }
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;
99
121
 
100
- /**
101
- * Inject (or replace) the conventions block in existing CLAUDE.md content.
102
- * Replaces an existing marked block in place; appends one when absent.
103
- * Idempotent never touches content outside the markers.
104
- * @param {string} existing current file content ('' if the file is new)
105
- * @param {string} block the block returned by `renderConventionsBlock`
106
- * @returns {string}
107
- */
108
- function injectConventions(existing, block) {
109
- const src = String(existing || '');
110
- const startIdx = src.indexOf(START);
111
- if (startIdx !== -1) {
112
- const endIdx = src.indexOf(END, startIdx);
113
- if (endIdx !== -1) {
114
- const before = src.slice(0, startIdx);
115
- const after = src.slice(endIdx + END.length);
116
- return before + block + after;
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
+ }
127
+
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 (_) {}
117
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;
118
149
  }
119
- if (src.trim() === '') return block + '\n';
120
- const sep = src.endsWith('\n') ? '\n' : '\n\n';
121
- return src + sep + block + '\n';
122
150
  }
123
151
 
124
- module.exports = { renderConventionsBlock, injectConventions, START, END };
152
+ module.exports = { freshen };
125
153
 
126
154
  };
127
155
 
156
+ // ── ./src/conventions/conflicts ──
128
157
  __factories["./src/conventions/conflicts"] = function(module, exports) {
129
158
 
130
159
  /**
@@ -239,6 +268,7 @@ __factories["./src/conventions/conflicts"] = function(module, exports) {
239
268
 
240
269
  };
241
270
 
271
+ // ── ./src/conventions/extract ──
242
272
  __factories["./src/conventions/extract"] = function(module, exports) {
243
273
 
244
274
  /**
@@ -283,24 +313,42 @@ __factories["./src/conventions/extract"] = function(module, exports) {
283
313
  return 'other';
284
314
  }
285
315
 
316
+ const MAX_EXAMPLES = 3;
317
+
286
318
  /**
287
319
  * Score a set of categorical observations into a dominant convention plus its
288
320
  * consistency tier. The reusable primitive (IMPL.md §5.2).
289
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`.
290
324
  * @returns {{ dominant: string|null, dominantPct: number, total: number,
291
- * variants: Array<{label:string, count:number, pct:number}>,
325
+ * variants: Array<{label:string, count:number, pct:number, examples?:string[]}>,
292
326
  * tier: 'consistent'|'mostly'|'inconsistent'|'unknown' }}
293
327
  */
294
- function scoreConvention(labels) {
295
- const list = (labels || []).filter((l) => l != null && l !== 'other');
296
- 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
+ }
297
343
  if (total === 0) {
298
344
  return { dominant: null, dominantPct: 0, total: 0, variants: [], tier: 'unknown' };
299
345
  }
300
- const counts = new Map();
301
- for (const l of list) counts.set(l, (counts.get(l) || 0) + 1);
302
346
  const variants = [...counts.entries()]
303
- .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
+ })
304
352
  .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
305
353
  const top = variants[0];
306
354
  let tier = 'inconsistent';
@@ -372,22 +420,26 @@ __factories["./src/conventions/extract"] = function(module, exports) {
372
420
  function extractConventions(cwd, files) {
373
421
  const scoped = (files || []).filter((f) => SCOPED_EXTS.has(path.extname(f).toLowerCase()));
374
422
  const namingLabels = [];
423
+ const namingRefs = [];
375
424
  const exportLabels = [];
425
+ const exportRefs = [];
376
426
  for (const f of scoped) {
377
427
  const base = path.basename(f);
378
428
  // Skip test files for the naming convention (they have their own naming).
379
429
  if (!/\.(test|spec)\.[jt]sx?$|(^|\/)test_|_test\.py$/.test(f)) {
380
430
  namingLabels.push(classifyNaming(base));
431
+ namingRefs.push(base);
381
432
  }
382
433
  if (JS_TS_EXTS.has(path.extname(f).toLowerCase())) {
383
434
  let src = '';
384
435
  try { src = fs.readFileSync(f, 'utf8'); } catch (_) {}
385
436
  exportLabels.push(_jsExportStyle(src));
437
+ exportRefs.push(base);
386
438
  }
387
439
  }
388
440
  return {
389
- fileNaming: scoreConvention(namingLabels),
390
- exportStyle: scoreConvention(exportLabels),
441
+ fileNaming: scoreConvention(namingLabels, namingRefs),
442
+ exportStyle: scoreConvention(exportLabels, exportRefs),
391
443
  testFramework: _detectTestFramework(cwd, scoped),
392
444
  scope: ['typescript', 'javascript', 'python'],
393
445
  scannedFiles: scoped.length,
@@ -398,131 +450,335 @@ __factories["./src/conventions/extract"] = function(module, exports) {
398
450
 
399
451
  };
400
452
 
401
- __factories["./src/cache/freshen"] = function(module, exports) {
453
+ // ── ./src/conventions/inject ──
454
+ __factories["./src/conventions/inject"] = function(module, exports) {
402
455
 
403
456
  /**
404
- * Read-time self-heal (IMPL.md Layer 1, "safety net" tier).
405
- *
406
- * Keeps the sig-cache in line with the current source tree so the index reflects
407
- * on-disk reality even when no write hook was called. Re-extracts files modified
408
- * since the context file was generated (bounded to actual session edits, not the
409
- * whole tree), drops cache entries for deleted files, and persists. buildSigIndex
410
- * already merges the cache, so the next read is fresh.
411
- *
412
- * Throttled per cwd. Skips entirely when there is no generated index to heal
413
- * (a cold repo should run `generate` or use the notify hooks).
457
+ * CLAUDE.md convention injection (IMPL.md §7 Phase 2 / §8 step 5).
414
458
  *
415
- * Zero-dependency, bundle-safe (fs + dispatch + sig-cache).
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.
416
464
  */
417
465
 
418
- const fs = require('fs');
419
- const path = require('path');
420
- const { loadCache, saveCache, getChangedFiles } = __require('./src/cache/sig-cache');
421
- const { extractFile, langFor } = __require('./src/extractors/dispatch');
466
+ const START = '<!-- sigmap-conventions:start -->';
467
+ const END = '<!-- sigmap-conventions:end -->';
422
468
 
423
- const DEFAULT_SRC_DIRS = ['src', 'app', 'lib', 'packages', 'services', 'api'];
424
- const DEFAULT_EXCLUDE = [
425
- 'node_modules', '.git', 'dist', 'build', 'out', '__pycache__',
426
- '.next', 'coverage', 'target', 'vendor', '.context',
427
- ];
428
- const CONTEXT_PATHS = [
429
- ['.github', 'copilot-instructions.md'],
430
- ['CLAUDE.md'], ['AGENTS.md'], ['.github', 'context-cold.md'],
431
- ];
432
- const THROTTLE_MS = 1500;
433
- const _lastRun = new Map();
469
+ const TIER_NOTE = {
470
+ consistent: 'consistent match it',
471
+ mostly: 'dominant, with some drift',
472
+ inconsistent: 'no clear convention — check neighboring files',
473
+ };
434
474
 
435
- function _readConfig(cwd) {
436
- try {
437
- const cfg = JSON.parse(fs.readFileSync(path.join(cwd, 'gen-context.config.json'), 'utf8'));
438
- return cfg && typeof cfg === 'object' ? cfg : {};
439
- } catch (_) { return {}; }
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;
440
491
  }
441
492
 
442
- function _pkgVersion(cwd) {
443
- try { return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).version || '0.0.0'; }
444
- catch (_) { return '0.0.0'; }
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');
445
525
  }
446
526
 
447
- /** Newest mtime among existing generated context files, or 0 if none. */
448
- function _contextMtime(cwd) {
449
- let newest = 0;
450
- for (const parts of CONTEXT_PATHS) {
451
- try { newest = Math.max(newest, fs.statSync(path.join(cwd, ...parts)).mtimeMs); } catch (_) {}
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;
544
+ }
452
545
  }
453
- return newest;
546
+ if (src.trim() === '') return block + '\n';
547
+ const sep = src.endsWith('\n') ? '\n' : '\n\n';
548
+ return src + sep + block + '\n';
454
549
  }
455
550
 
456
- function _walk(dir, exclude, out, depth, maxDepth) {
457
- if (depth > maxDepth) return;
458
- let entries;
459
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
460
- for (const e of entries) {
461
- if (exclude.has(e.name)) continue;
462
- const full = path.join(dir, e.name);
463
- if (e.isDirectory()) _walk(full, exclude, out, depth + 1, maxDepth);
464
- else if (e.isFile() && langFor(e.name)) out.push(full);
551
+ module.exports = { renderConventionsBlock, injectConventions, START, END };
552
+
553
+ };
554
+
555
+ // ── ./src/scaffold/propose ──
556
+ __factories["./src/scaffold/propose"] = function(module, exports) {
557
+
558
+ /**
559
+ * Scaffold proposal with a confidence floor (IMPL.md §5 — Cause 3).
560
+ *
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.
567
+ */
568
+
569
+ const { toNamingStyle, analyzeConflicts } = __require('./src/conventions/conflicts');
570
+
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;
574
+
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';
580
+ }
581
+
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;
589
+ }
590
+
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`;
465
595
  }
596
+ return `${styledStem}.test.${ext}`;
466
597
  }
467
598
 
468
599
  /**
469
- * Re-extract source files changed since the last generate; drop deleted files.
470
- * @param {string} cwd
471
- * @param {{force?:boolean, now?:number}} [opts]
472
- * @returns {number} cache entries touched
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 }}
473
611
  */
474
- function freshen(cwd, opts = {}) {
475
- const now = opts.now != null ? opts.now : Date.now();
476
- if (!opts.force) {
477
- if (now - (_lastRun.get(cwd) || 0) < THROTTLE_MS) return 0;
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
+ };
478
642
  }
479
- _lastRun.set(cwd, now);
480
643
 
481
- try {
482
- const version = _pkgVersion(cwd);
483
- const cache = loadCache(cwd, version);
484
- const ctxMtime = _contextMtime(cwd);
485
- // Nothing to heal: no generated context AND no live cache overlay.
486
- if (ctxMtime === 0 && cache.size === 0) return 0;
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
+ };
487
653
 
488
- const cfg = _readConfig(cwd);
489
- const srcDirs = Array.isArray(cfg.srcDirs) && cfg.srcDirs.length ? cfg.srcDirs : DEFAULT_SRC_DIRS;
490
- const exclude = new Set([...DEFAULT_EXCLUDE, ...(Array.isArray(cfg.exclude) ? cfg.exclude : [])]);
491
- const maxDepth = Number.isFinite(cfg.maxDepth) ? cfg.maxDepth : 8;
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
+ };
665
+ }
492
666
 
493
- const files = [];
494
- for (const d of srcDirs) {
495
- const abs = path.isAbsolute(d) ? d : path.join(cwd, d);
496
- if (fs.existsSync(abs)) _walk(abs, exclude, files, 0, maxDepth);
497
- }
667
+ module.exports = { proposeScaffold, DEFAULT_THRESHOLD, HARD_FLOOR };
668
+
669
+ };
498
670
 
499
- // Candidates = files modified since the context was generated, or not yet cached.
500
- const candidates = files.filter((f) => {
501
- try { return fs.statSync(f).mtimeMs > ctxMtime || !cache.has(f); } catch (_) { return false; }
671
+ __factories["./src/plan/verify-plan"] = function(module, exports) {
672
+
673
+ /**
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.
681
+ */
682
+
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');
689
+
690
+ const DEFAULT_BLAST_THRESHOLD = 20; // transitive+direct dependents → "high blast radius"
691
+ const DEFAULT_SCOPE_THRESHOLD = 10; // distinct referenced files → "broad scope"
692
+
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
+ }
701
+
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,
502
738
  });
503
- const { changed } = getChangedFiles(candidates, cache);
739
+ }
504
740
 
505
- let touched = 0;
506
- for (const f of changed) {
507
- try {
508
- const sigs = extractFile(f, fs.readFileSync(f, 'utf8'));
509
- cache.set(f, { mtime: fs.statSync(f).mtimeMs, sigs });
510
- touched++;
511
- } 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
+ }
512
752
  }
513
- // Note: deletions are NOT swept here — a cache entry may be a `notify`
514
- // overlay for a file not yet on disk. Explicit removal is `notify_file_deleted`.
753
+ blast.sort((a, b) => b.totalImpact - a.totalImpact);
754
+ }
515
755
 
516
- if (touched > 0) saveCache(cwd, version, cache);
517
- return touched;
518
- } catch (_) {
519
- 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' });
520
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
+ };
521
776
  }
522
777
 
523
- module.exports = { freshen };
778
+ module.exports = { verifyPlan, DEFAULT_BLAST_THRESHOLD, DEFAULT_SCOPE_THRESHOLD };
524
779
 
525
780
  };
781
+
526
782
  // ── ./src/extractors/dispatch ──
527
783
  __factories["./src/extractors/dispatch"] = function(module, exports) {
528
784
 
@@ -7003,7 +7259,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
7003
7259
 
7004
7260
  const SERVER_INFO = {
7005
7261
  name: 'sigmap',
7006
- version: '7.9.0',
7262
+ version: '7.11.0',
7007
7263
  description: 'SigMap MCP server — code signatures on demand',
7008
7264
  };
7009
7265
 
@@ -12681,7 +12937,7 @@ function __tryGit(args, opts = {}) {
12681
12937
  catch (_) { return ''; }
12682
12938
  }
12683
12939
 
12684
- const VERSION = '7.9.0';
12940
+ const VERSION = '7.11.0';
12685
12941
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
12686
12942
 
12687
12943
  function requireSourceOrBundled(key) {
@@ -15941,6 +16197,56 @@ function main() {
15941
16197
  process.exit(0);
15942
16198
  }
15943
16199
 
16200
+ // Layer 4: `sigmap scaffold <name>` — propose a convention-matched structure
16201
+ // for a new module, gated by a confidence floor (refuses if conventions are
16202
+ // too inconsistent; a wrong proposal systematizes bad code).
16203
+ if (args[0] === 'scaffold') {
16204
+ const jsonOut = args.includes('--json');
16205
+ const name = args[1] && !args[1].startsWith('--') ? args[1] : null;
16206
+ if (!name) {
16207
+ console.error('[sigmap] usage: sigmap scaffold <name> [--ext <e>] [--threshold <n>] [--force] [--json]');
16208
+ process.exit(1);
16209
+ }
16210
+ const thIdx = args.indexOf('--threshold');
16211
+ const threshold = thIdx !== -1 && args[thIdx + 1] ? parseFloat(args[thIdx + 1]) : undefined;
16212
+ const extIdx = args.indexOf('--ext');
16213
+ const ext = extIdx !== -1 && args[extIdx + 1] ? args[extIdx + 1].replace(/^\./, '') : undefined;
16214
+ const force = args.includes('--force');
16215
+
16216
+ const { extractConventions } = requireSourceOrBundled('./src/conventions/extract');
16217
+ const { proposeScaffold } = requireSourceOrBundled('./src/scaffold/propose');
16218
+ const conventions = extractConventions(cwd, buildFileList(cwd, config));
16219
+ const decision = proposeScaffold(name, conventions, { threshold, ext, force });
16220
+
16221
+ if (jsonOut) {
16222
+ process.stdout.write(JSON.stringify(decision) + '\n');
16223
+ process.exit(decision.ok ? 0 : 1);
16224
+ }
16225
+
16226
+ const pctS = (n) => `${(n * 100).toFixed(0)}%`;
16227
+ if (decision.ok) {
16228
+ console.log(`[sigmap] scaffold "${decision.name}" — conventions ${decision.tier} (${pctS(decision.confidence)})`);
16229
+ if (decision.warning) console.log(` ⚠ ${decision.warning}`);
16230
+ const p = decision.proposal;
16231
+ console.log(` file: ${p.filename} (${p.namingStyle})`);
16232
+ console.log(` export style: ${p.exportStyle}`);
16233
+ console.log(` test file: ${p.testFile}${p.testFramework ? ` (${p.testFramework})` : ''}`);
16234
+ process.exit(0);
16235
+ }
16236
+
16237
+ console.log(`[sigmap] scaffold "${decision.name}" — REFUSED`);
16238
+ console.log(` ${decision.reason}`);
16239
+ if (decision.conflicts && decision.conflicts.hasConflicts) {
16240
+ for (const c of decision.conflicts.conventions) {
16241
+ console.log(`\n ${c.name} — dominant: ${c.dominant} ${pctS(c.dominantPct)} [${c.tier}]`);
16242
+ for (const v of c.variants) {
16243
+ console.log(` ${v.pattern.padEnd(12)} ${pctS(v.pct).padStart(4)}${v.examples.length ? ` e.g. ${v.examples.join(', ')}` : ''}`);
16244
+ }
16245
+ }
16246
+ }
16247
+ process.exit(1);
16248
+ }
16249
+
15944
16250
  // v7.0.0: `sigmap squeeze <file|->` — minimize a pasted stacktrace / CI-log / JSON blob
15945
16251
  if (args[0] === 'squeeze') {
15946
16252
  const jsonOut = args.includes('--json');
@@ -15985,6 +16291,47 @@ function main() {
15985
16291
  }
15986
16292
 
15987
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
+
15988
16335
  if (args[0] === 'verify-ai-output') {
15989
16336
  const target = args[1];
15990
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.9.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.9.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.9.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.9.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.9.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.9.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 };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Scaffold proposal with a confidence floor (IMPL.md §5 — Cause 3).
5
+ *
6
+ * Proposes a convention-matched structure for a new module — filename in the
7
+ * repo's dominant naming style, the export style to use, and a matching test
8
+ * file — but only when the conventions are consistent enough. Below a hard
9
+ * floor it refuses and surfaces the conflict, because a wrong proposal
10
+ * systematizes bad code. Pure, zero-dependency, bundle-safe; reuses the
11
+ * conventions primitives.
12
+ */
13
+
14
+ const { toNamingStyle, analyzeConflicts } = require('../conventions/conflicts');
15
+
16
+ // Soft threshold is configurable; the hard floor is not (IMPL.md §5.1).
17
+ const DEFAULT_THRESHOLD = 0.7;
18
+ const HARD_FLOOR = 0.5;
19
+
20
+ /** Tier for a consistency score (matches the conventions tiers). */
21
+ function _tier(pct) {
22
+ if (pct >= 0.9) return 'consistent';
23
+ if (pct >= 0.7) return 'mostly';
24
+ return 'inconsistent';
25
+ }
26
+
27
+ /** Strip any extension/compound suffix from a requested name → bare stem. */
28
+ function _stem(name) {
29
+ const s = String(name || '').trim();
30
+ const slash = s.lastIndexOf('/');
31
+ const base = slash >= 0 ? s.slice(slash + 1) : s;
32
+ const dot = base.indexOf('.');
33
+ return dot > 0 ? base.slice(0, dot) : base;
34
+ }
35
+
36
+ /** Test file path for a styled stem given the detected framework + ext. */
37
+ function _testFile(styledStem, framework, ext) {
38
+ if (framework === 'pytest' || framework === 'unittest') {
39
+ return `test_${toNamingStyle(styledStem, 'snake_case')}.py`;
40
+ }
41
+ return `${styledStem}.test.${ext}`;
42
+ }
43
+
44
+ /**
45
+ * Propose a convention-matched scaffold, gated by a confidence floor.
46
+ * @param {string} name desired module name (any casing; extension ignored)
47
+ * @param {object} conventions an `extractConventions` result
48
+ * @param {object} [opts]
49
+ * @param {number} [opts.threshold=0.7] soft threshold (clamped to ≥ hard floor)
50
+ * @param {boolean} [opts.force=false] allow proposing below the soft threshold
51
+ * (never below the hard floor)
52
+ * @param {string} [opts.ext='js'] file extension for the proposed files
53
+ * @returns {{ ok:boolean, refused:boolean, name:string, tier:string,
54
+ * confidence:number, threshold:number, hardFloor:number, forced:boolean,
55
+ * warning:string|null, reason:string, proposal:object|null, conflicts:object }}
56
+ */
57
+ function proposeScaffold(name, conventions, opts = {}) {
58
+ const threshold = Math.max(HARD_FLOOR, opts.threshold != null ? opts.threshold : DEFAULT_THRESHOLD);
59
+ const force = !!opts.force;
60
+ const ext = opts.ext || 'js';
61
+ const fileNaming = (conventions && conventions.fileNaming) || { dominant: null, dominantPct: 0, total: 0 };
62
+ const exportStyle = (conventions && conventions.exportStyle) || { dominant: null };
63
+ const confidence = fileNaming.dominantPct || 0;
64
+ const tier = fileNaming.total > 0 ? _tier(confidence) : 'unknown';
65
+ const conflicts = analyzeConflicts(conventions || {});
66
+
67
+ const base = {
68
+ ok: false, refused: true, name: String(name || ''), tier, confidence,
69
+ threshold, hardFloor: HARD_FLOOR, forced: false, warning: null,
70
+ reason: '', proposal: null, conflicts,
71
+ };
72
+
73
+ if (!fileNaming.dominant || fileNaming.total === 0) {
74
+ return { ...base, reason: 'no file-naming convention detected — cannot propose a name' };
75
+ }
76
+ if (confidence < HARD_FLOOR) {
77
+ return {
78
+ ...base,
79
+ reason: `file-naming consistency ${(confidence * 100).toFixed(0)}% is below the hard floor ${(HARD_FLOOR * 100).toFixed(0)}% — refusing (not overridable)`,
80
+ };
81
+ }
82
+ if (confidence < threshold && !force) {
83
+ return {
84
+ ...base,
85
+ 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)`,
86
+ };
87
+ }
88
+
89
+ const styledStem = toNamingStyle(_stem(name), fileNaming.dominant);
90
+ const forced = confidence < threshold && force;
91
+ const proposal = {
92
+ filename: `${styledStem}.${ext}`,
93
+ namingStyle: fileNaming.dominant,
94
+ exportStyle: exportStyle.dominant || 'named',
95
+ testFile: _testFile(styledStem, conventions.testFramework, ext),
96
+ testFramework: conventions.testFramework || null,
97
+ };
98
+
99
+ return {
100
+ ...base,
101
+ ok: true,
102
+ refused: false,
103
+ forced,
104
+ warning: forced
105
+ ? `proposed below the ${(threshold * 100).toFixed(0)}% threshold (--force); conventions are only ${tier}`
106
+ : null,
107
+ reason: forced ? 'forced proposal above the hard floor' : `conventions are ${tier} — proposing`,
108
+ proposal,
109
+ };
110
+ }
111
+
112
+ module.exports = { proposeScaffold, DEFAULT_THRESHOLD, HARD_FLOOR };