sigmap 8.9.1 → 8.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/gen-context.js CHANGED
@@ -1501,6 +1501,9 @@ __factories["./src/config/defaults"] = function(module, exports) {
1501
1501
  // Include a compact `name@version` list of installed direct deps (D8)
1502
1502
  versionPins: true,
1503
1503
 
1504
+ // Terse signature encoding — deterministic compaction of sig lines (D7, opt-in)
1505
+ terse: false,
1506
+
1504
1507
  // Include TODO/FIXME/HACK/XXX comments as compact section
1505
1508
  todos: true,
1506
1509
 
@@ -2056,8 +2059,16 @@ __factories["./src/conventions/extract"] = function(module, exports) {
2056
2059
 
2057
2060
  /**
2058
2061
  * Classify a file's base name (without extension) into a naming style.
2062
+ *
2063
+ * A single lowercase word (`user`, `index`, `loader`) is classified
2064
+ * `single-word`, NOT `camelCase`: it has no case boundary or separator, so it is
2065
+ * compatible with camelCase, kebab-case AND snake_case at once and expresses no
2066
+ * distinguishable convention. `scoreConvention` treats it as style-neutral
2067
+ * (excluded), so a repo of single-word files reports "unknown" rather than a
2068
+ * spurious "100% camelCase".
2069
+ *
2059
2070
  * @param {string} basename a file basename, e.g. "user-service.ts"
2060
- * @returns {'PascalCase'|'camelCase'|'kebab-case'|'snake_case'|'other'}
2071
+ * @returns {'PascalCase'|'camelCase'|'kebab-case'|'snake_case'|'single-word'|'other'}
2061
2072
  */
2062
2073
  function classifyNaming(basename) {
2063
2074
  let stem = String(basename || '');
@@ -2068,7 +2079,7 @@ __factories["./src/conventions/extract"] = function(module, exports) {
2068
2079
  if (/[_]/.test(stem) && /^[a-z0-9]+(?:_[a-z0-9]+)+$/.test(stem)) return 'snake_case';
2069
2080
  if (/^[A-Z][A-Za-z0-9]*$/.test(stem) && /[a-z]/.test(stem)) return 'PascalCase';
2070
2081
  if (/^[a-z][A-Za-z0-9]*$/.test(stem) && /[A-Z]/.test(stem)) return 'camelCase';
2071
- if (/^[a-z][a-z0-9]*$/.test(stem)) return 'camelCase'; // single lowercase word
2082
+ if (/^[a-z][a-z0-9]*$/.test(stem)) return 'single-word'; // style-neutral (no case boundary)
2072
2083
  return 'other';
2073
2084
  }
2074
2085
 
@@ -2091,7 +2102,9 @@ __factories["./src/conventions/extract"] = function(module, exports) {
2091
2102
  let total = 0;
2092
2103
  for (let i = 0; i < all.length; i++) {
2093
2104
  const l = all[i];
2094
- if (l == null || l === 'other') continue;
2105
+ // 'other' = unclassifiable; 'single-word' = style-neutral. Both express no
2106
+ // distinguishable convention and are excluded from the score.
2107
+ if (l == null || l === 'other' || l === 'single-word') continue;
2095
2108
  total++;
2096
2109
  counts.set(l, (counts.get(l) || 0) + 1);
2097
2110
  if (refs && refs[i] != null) {
@@ -2259,7 +2272,10 @@ __factories["./src/conventions/fix"] = function(module, exports) {
2259
2272
  if (TEST_RE.test(f)) continue;
2260
2273
  const base = path.basename(f);
2261
2274
  const style = classifyNaming(base);
2262
- if (style === 'other' || style === dominant) continue;
2275
+ // Skip unclassifiable ('other') and style-neutral single-word names — a
2276
+ // single lowercase word already satisfies any convention, so renaming it
2277
+ // (e.g. user.js → user.js) is a no-op at best and noise at worst.
2278
+ if (style === 'other' || style === 'single-word' || style === dominant) continue;
2263
2279
  const rel = path.relative(cwd, f).replace(/\\/g, '/');
2264
2280
  renames.push({ from: rel, to: _renamePath(rel, dominant), fromStyle: style });
2265
2281
  }
@@ -6071,6 +6087,7 @@ __factories["./src/extractors/java"] = function(module, exports) {
6071
6087
  __factories["./src/extractors/javascript"] = function(module, exports) {
6072
6088
 
6073
6089
  const { lineAt, withAnchor } = __require('./src/extractors/line-anchor');
6090
+ const { capWithNotice, capMembersWithNotice } = __require('./src/util/truncate');
6074
6091
 
6075
6092
  /**
6076
6093
  * Extract signatures from JavaScript source code.
@@ -6150,9 +6167,8 @@ __factories["./src/extractors/javascript"] = function(module, exports) {
6150
6167
  anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
6151
6168
  }
6152
6169
 
6153
- return sigs
6154
- .map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s))
6155
- .slice(0, 25);
6170
+ const withAnchors = sigs.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s));
6171
+ return capWithNotice(withAnchors, 25, 'signatures');
6156
6172
  }
6157
6173
 
6158
6174
  function extractBlock(src, startIndex) {
@@ -6183,7 +6199,7 @@ __factories["./src/extractors/javascript"] = function(module, exports) {
6183
6199
  const retStr = formatReturnHint(returnHints.get(m[1]));
6184
6200
  members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
6185
6201
  }
6186
- return members.slice(0, 8);
6202
+ return capMembersWithNotice(members, 8, 'methods');
6187
6203
  }
6188
6204
 
6189
6205
  function buildReturnHints(src) {
@@ -8063,6 +8079,7 @@ __factories["./src/extractors/toml"] = function(module, exports) {
8063
8079
  __factories["./src/extractors/typescript"] = function(module, exports) {
8064
8080
 
8065
8081
  const { lineAt, withAnchor } = __require('./src/extractors/line-anchor');
8082
+ const { capWithNotice, capMembersWithNotice } = __require('./src/util/truncate');
8066
8083
 
8067
8084
  /**
8068
8085
  * Extract signatures from TypeScript source code.
@@ -8214,9 +8231,8 @@ __factories["./src/extractors/typescript"] = function(module, exports) {
8214
8231
  }
8215
8232
  }
8216
8233
 
8217
- return sigs
8218
- .map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s))
8219
- .slice(0, 35);
8234
+ const withAnchors = sigs.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s));
8235
+ return capWithNotice(withAnchors, 35, 'signatures');
8220
8236
  }
8221
8237
 
8222
8238
  function extractBlock(src, startIndex) {
@@ -8246,7 +8262,7 @@ __factories["./src/extractors/typescript"] = function(module, exports) {
8246
8262
  const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
8247
8263
  members.push({ text: `${m[1]}(${normalizeParams(m[2])})`, start, end: m.index + m[0].length });
8248
8264
  }
8249
- return members.slice(0, 8);
8265
+ return capMembersWithNotice(members, 8, 'members');
8250
8266
  }
8251
8267
 
8252
8268
  const _CTRL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'do', 'try', 'catch', 'finally', 'else', 'return']);
@@ -8272,7 +8288,7 @@ __factories["./src/extractors/typescript"] = function(module, exports) {
8272
8288
  const retStr = retType ? ` → ${retType}` : '';
8273
8289
  members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
8274
8290
  }
8275
- return members.slice(0, 8);
8291
+ return capMembersWithNotice(members, 8, 'methods');
8276
8292
  }
8277
8293
 
8278
8294
  function normalizeParams(params) {
@@ -9998,6 +10014,96 @@ __factories["./src/format/llms-txt"] = function(module, exports) {
9998
10014
 
9999
10015
  };
10000
10016
 
10017
+ // ── ./src/format/terse ──
10018
+ __factories["./src/format/terse"] = function(module, exports) {
10019
+
10020
+ /**
10021
+ * Terse signature encoder (D7).
10022
+ *
10023
+ * Deterministic compaction of signature lines for the generated context —
10024
+ * opt-in via `--terse` / `terse: true`. Every transform is a fixed string
10025
+ * rewrite (no heuristics, no LLM), so terse output stays byte-stable.
10026
+ *
10027
+ * The line anchor (` :start-end`) and everything after it (Python/R doc
10028
+ * hints) are preserved byte-exactly: `parseAnchor`, `get_lines`, and the
10029
+ * evidence pack keep working on terse output. Symbol extraction is safe too —
10030
+ * `extractName` in src/extractors/prdiff.js already recognizes `fn <name>`.
10031
+ */
10032
+
10033
+ /** First ` :start[-end]` anchor token (two-space prefix, as emitted by line-anchor.js). */
10034
+ const ANCHOR_RE = /\s{2}:\d+(?:-\d+)?(?=\s|$)/;
10035
+
10036
+ /**
10037
+ * Split a signature into the compactable text and the byte-preserved suffix
10038
+ * (anchor + any trailing doc hint).
10039
+ * @param {string} sig
10040
+ * @returns {{ text: string, suffix: string }}
10041
+ */
10042
+ function splitAnchor(sig) {
10043
+ const s = String(sig);
10044
+ const m = ANCHOR_RE.exec(s);
10045
+ if (!m) return { text: s, suffix: '' };
10046
+ return { text: s.slice(0, m.index), suffix: s.slice(m.index) };
10047
+ }
10048
+
10049
+ /**
10050
+ * Compact one signature line. Leading whitespace (member indentation) is kept.
10051
+ * @param {string} sig
10052
+ * @returns {string}
10053
+ */
10054
+ function encodeTerseSig(sig) {
10055
+ const { text, suffix } = splitAnchor(sig);
10056
+ let t = text
10057
+ .replace(/\basync function\b/g, 'async fn')
10058
+ .replace(/\bfunction\b/g, 'fn')
10059
+ .replace(/\s+→\s+/g, '→')
10060
+ .replace(/,\s+/g, ',')
10061
+ .replace(/\s+=\s+/g, '=')
10062
+ .replace(/\{\s+/g, '{')
10063
+ .replace(/\s+\}/g, '}')
10064
+ .replace(/(\S)\s{2,}(?=\S)/g, '$1 ')
10065
+ .replace(/^module\.exports=/, 'exports=');
10066
+ return t + suffix;
10067
+ }
10068
+
10069
+ /**
10070
+ * Compact an array of signature lines.
10071
+ * @param {string[]} sigs
10072
+ * @returns {string[]}
10073
+ */
10074
+ function encodeTerseSigs(sigs) {
10075
+ return (sigs || []).map(encodeTerseSig);
10076
+ }
10077
+
10078
+ /** Estimated tokens of joined signature lines (same chars/4 rule as elsewhere). */
10079
+ function _tokens(sigs) {
10080
+ return Math.ceil(sigs.join('\n').length / 4);
10081
+ }
10082
+
10083
+ /**
10084
+ * Measure the real reduction terse encoding buys over a set of signature
10085
+ * lists — the D7 "measure first" gate. Never quote a number this didn't produce.
10086
+ * @param {string[][]} sigsList one string[] per file
10087
+ * @returns {{ beforeTokens: number, afterTokens: number, reductionPct: number }}
10088
+ */
10089
+ function measureTerse(sigsList) {
10090
+ let beforeTokens = 0;
10091
+ let afterTokens = 0;
10092
+ for (const sigs of sigsList || []) {
10093
+ if (!sigs || !sigs.length) continue;
10094
+ beforeTokens += _tokens(sigs);
10095
+ afterTokens += _tokens(encodeTerseSigs(sigs));
10096
+ }
10097
+ const reductionPct = beforeTokens > 0
10098
+ ? Math.round(((beforeTokens - afterTokens) / beforeTokens) * 1000) / 10
10099
+ : 0;
10100
+ return { beforeTokens, afterTokens, reductionPct };
10101
+ }
10102
+
10103
+ module.exports = { encodeTerseSig, encodeTerseSigs, measureTerse, splitAnchor };
10104
+
10105
+ };
10106
+
10001
10107
  // ── ./src/format/usage-guidance ──
10002
10108
  __factories["./src/format/usage-guidance"] = function(module, exports) {
10003
10109
 
@@ -10232,20 +10338,19 @@ __factories["./src/graph/builder"] = function(module, exports) {
10232
10338
  const R_EXTS = new Set(['.r', '.R']);
10233
10339
 
10234
10340
  /**
10235
- * Resolve a JS/TS relative import string to an absolute path in fileSet.
10236
- * @param {string} dir - directory of the importing file
10237
- * @param {string} importStr - raw import string (e.g. './utils', '../store')
10341
+ * Probe an absolute base path for a JS/TS module file in fileSet, trying the
10342
+ * usual extension and index-file candidates.
10343
+ * @param {string} base - absolute path (no extension) to probe
10238
10344
  * @param {Set<string>} fileSet
10239
10345
  * @returns {string|null}
10240
10346
  */
10241
- function resolveJsPath(dir, importStr, fileSet) {
10242
- const base = path.resolve(dir, importStr);
10347
+ function probeJs(base, fileSet) {
10243
10348
  const candidates = [
10244
10349
  base,
10245
10350
  base + '.ts', base + '.tsx',
10246
10351
  base + '.js', base + '.jsx', base + '.mjs', base + '.cjs',
10247
- path.join(base, 'index.ts'),
10248
- path.join(base, 'index.js'),
10352
+ path.join(base, 'index.ts'), path.join(base, 'index.tsx'),
10353
+ path.join(base, 'index.js'), path.join(base, 'index.jsx'),
10249
10354
  ];
10250
10355
  for (const c of candidates) {
10251
10356
  const normC = normalizePath(c);
@@ -10254,6 +10359,103 @@ __factories["./src/graph/builder"] = function(module, exports) {
10254
10359
  return null;
10255
10360
  }
10256
10361
 
10362
+ /**
10363
+ * Resolve a JS/TS relative import string to an absolute path in fileSet.
10364
+ * @param {string} dir - directory of the importing file
10365
+ * @param {string} importStr - raw import string (e.g. './utils', '../store')
10366
+ * @param {Set<string>} fileSet
10367
+ * @returns {string|null}
10368
+ */
10369
+ function resolveJsPath(dir, importStr, fileSet) {
10370
+ return probeJs(path.resolve(dir, importStr), fileSet);
10371
+ }
10372
+
10373
+ /**
10374
+ * Strip comments and trailing commas so a tsconfig/jsconfig (JSONC) parses.
10375
+ * Deliberately conservative — leaves string contents alone.
10376
+ */
10377
+ function stripJsonc(src) {
10378
+ let out = '';
10379
+ let inStr = false, quote = '', inLine = false, inBlock = false;
10380
+ for (let i = 0; i < src.length; i++) {
10381
+ const c = src[i], n = src[i + 1];
10382
+ if (inLine) { if (c === '\n') { inLine = false; out += c; } continue; }
10383
+ if (inBlock) { if (c === '*' && n === '/') { inBlock = false; i++; } continue; }
10384
+ if (inStr) { out += c; if (c === '\\') { out += (n || ''); i++; } else if (c === quote) inStr = false; continue; }
10385
+ if (c === '"' || c === "'") { inStr = true; quote = c; out += c; continue; }
10386
+ if (c === '/' && n === '/') { inLine = true; i++; continue; }
10387
+ if (c === '/' && n === '*') { inBlock = true; i++; continue; }
10388
+ out += c;
10389
+ }
10390
+ // remove trailing commas before } or ]
10391
+ return out.replace(/,(\s*[}\]])/g, '$1');
10392
+ }
10393
+
10394
+ /**
10395
+ * Load the JS/TS path-alias map from tsconfig.json / jsconfig.json.
10396
+ * Resolves `compilerOptions.paths` and `baseUrl` into absolute target bases so
10397
+ * bare/aliased imports (e.g. `@/utils`, `components/Button`) can be resolved to
10398
+ * on-disk files. Returns null when no config or no aliasing is configured.
10399
+ *
10400
+ * @param {string} cwd
10401
+ * @returns {{ baseUrl: string|null, entries: Array<{prefix:string,wildcard:boolean,targets:string[]}> }|null}
10402
+ */
10403
+ function loadAliasMap(cwd) {
10404
+ if (!cwd) return null;
10405
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
10406
+ let json;
10407
+ try { json = JSON.parse(stripJsonc(fs.readFileSync(path.join(cwd, name), 'utf8'))); }
10408
+ catch (_) { continue; }
10409
+ const co = (json && json.compilerOptions) || {};
10410
+ const baseUrl = co.baseUrl ? path.resolve(cwd, co.baseUrl) : null;
10411
+ const base = baseUrl || cwd;
10412
+ const entries = [];
10413
+ for (const [pattern, targets] of Object.entries(co.paths || {})) {
10414
+ const wildcard = pattern.includes('*');
10415
+ const prefix = pattern.replace(/\*.*$/, '');
10416
+ const tgs = (Array.isArray(targets) ? targets : [])
10417
+ .map((t) => path.resolve(base, String(t).replace(/\*.*$/, '')));
10418
+ if (tgs.length) entries.push({ prefix, wildcard, targets: tgs });
10419
+ }
10420
+ if (baseUrl || entries.length) return { baseUrl, entries };
10421
+ return null;
10422
+ }
10423
+ return null;
10424
+ }
10425
+
10426
+ /**
10427
+ * Resolve a non-relative JS/TS import specifier through the alias map.
10428
+ * @param {string} spec - e.g. '@/utils', '@app/Button', 'components/Nav'
10429
+ * @param {object|null} aliasMap - from loadAliasMap
10430
+ * @param {Set<string>} fileSet
10431
+ * @returns {string|null}
10432
+ */
10433
+ function resolveAlias(spec, aliasMap, fileSet) {
10434
+ if (!aliasMap) return null;
10435
+ for (const e of aliasMap.entries) {
10436
+ if (e.wildcard) {
10437
+ if (spec.startsWith(e.prefix)) {
10438
+ const rest = spec.slice(e.prefix.length);
10439
+ for (const t of e.targets) {
10440
+ const r = probeJs(rest ? path.join(t, rest) : t, fileSet);
10441
+ if (r) return r;
10442
+ }
10443
+ }
10444
+ } else if (spec === e.prefix) {
10445
+ for (const t of e.targets) {
10446
+ const r = probeJs(t, fileSet);
10447
+ if (r) return r;
10448
+ }
10449
+ }
10450
+ }
10451
+ // Bare import resolved from baseUrl (tsconfig baseUrl without an explicit alias).
10452
+ if (aliasMap.baseUrl) {
10453
+ const r = probeJs(path.join(aliasMap.baseUrl, spec), fileSet);
10454
+ if (r) return r;
10455
+ }
10456
+ return null;
10457
+ }
10458
+
10257
10459
  /**
10258
10460
  * Resolve an R `source(...)` argument to an absolute path in fileSet.
10259
10461
  * Tries the dir-relative path first, then a cwd-relative path so that
@@ -10299,21 +10501,29 @@ __factories["./src/graph/builder"] = function(module, exports) {
10299
10501
 
10300
10502
  // ── JS / TS ───────────────────────────────────────────────────────────────
10301
10503
  if (JS_EXTS.has(ext)) {
10504
+ const aliasMap = ctx && ctx.aliasMap;
10505
+ // Resolve any specifier: relative → dir-relative; otherwise via tsconfig/
10506
+ // jsconfig path aliases + baseUrl. Bare npm packages (react, lodash) fall
10507
+ // through to null because they are not in fileSet, so no false edges.
10508
+ const resolveSpec = (spec) => spec.startsWith('.')
10509
+ ? resolveJsPath(dir, spec, fileSet)
10510
+ : resolveAlias(spec, aliasMap, fileSet);
10511
+
10302
10512
  const stripped = content
10303
10513
  .replace(/\/\/.*$/gm, '')
10304
10514
  .replace(/\/\*[\s\S]*?\*\//g, '');
10305
10515
 
10306
- // ES imports: import ... from './foo' or import './side-effect'
10307
- const reEs = /(?:^|[\r\n])\s*import\s+(?:[^'";\r\n]*?\s+from\s+)?['"](\.[^'"]+)['"]/g;
10308
10516
  let m;
10517
+ // ES imports: import ... from 'x' | import 'x' | export ... from 'x'
10518
+ const reEs = /(?:^|[\r\n])\s*(?:import|export)\s+(?:[^'";\r\n]*?\s+from\s+)?['"]([^'"]+)['"]/g;
10309
10519
  while ((m = reEs.exec(stripped)) !== null) {
10310
- const r = resolveJsPath(dir, m[1], fileSet);
10520
+ const r = resolveSpec(m[1]);
10311
10521
  if (r) found.push(r);
10312
10522
  }
10313
- // CommonJS: __require('./src/graph/foo')
10314
- const reCjs = /\brequire\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g;
10315
- while ((m = reCjs.exec(stripped)) !== null) {
10316
- const r = resolveJsPath(dir, m[1], fileSet);
10523
+ // CommonJS require('x') and dynamic import('x').
10524
+ const reCall = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
10525
+ while ((m = reCall.exec(stripped)) !== null) {
10526
+ const r = resolveSpec(m[1]);
10317
10527
  if (r) found.push(r);
10318
10528
  }
10319
10529
  }
@@ -10485,6 +10695,10 @@ __factories["./src/graph/builder"] = function(module, exports) {
10485
10695
  const fileSet = new Set(files.map((f) => path.resolve(f)));
10486
10696
  // Create a normalized version for cross-platform case-insensitive lookups
10487
10697
  const fileSetNormalized = new Set([...fileSet].map(normalizePath));
10698
+ // Resolve the JS/TS path-alias map once (tsconfig/jsconfig paths + baseUrl),
10699
+ // unless a caller supplied one explicitly via ctx.
10700
+ const aliasMap = (ctx && 'aliasMap' in ctx) ? ctx.aliasMap : loadAliasMap(cwd);
10701
+ const effectiveCtx = Object.assign({}, ctx, { aliasMap });
10488
10702
  const forward = new Map();
10489
10703
  const reverse = new Map();
10490
10704
 
@@ -10505,7 +10719,7 @@ __factories["./src/graph/builder"] = function(module, exports) {
10505
10719
  }
10506
10720
 
10507
10721
  const normFilePath = normalizePath(filePath);
10508
- const deps = extractFileDeps(filePath, content, fileSetNormalized, cwd, ctx);
10722
+ const deps = extractFileDeps(filePath, content, fileSetNormalized, cwd, effectiveCtx);
10509
10723
  if (deps.length > 0) {
10510
10724
  forward.set(normFilePath, deps);
10511
10725
  for (const dep of deps) {
@@ -10584,7 +10798,7 @@ __factories["./src/graph/builder"] = function(module, exports) {
10584
10798
  return build(files, cwd, ctx);
10585
10799
  }
10586
10800
 
10587
- module.exports = { build, buildFromCwd, extractFileDeps, normalizePath };
10801
+ module.exports = { build, buildFromCwd, extractFileDeps, normalizePath, loadAliasMap, resolveAlias };
10588
10802
 
10589
10803
  };
10590
10804
 
@@ -11182,11 +11396,11 @@ __factories["./src/graph/impact"] = function(module, exports) {
11182
11396
  lines.push('');
11183
11397
 
11184
11398
  if (result.direct.length === 0 && result.transitive.length === 0) {
11185
- lines.push('_No files import this filezero blast radius._');
11399
+ lines.push('_No importers found via relative + aliased imports (lower bound dynamic/computed imports are not tracked)._');
11186
11400
  return lines.join('\n');
11187
11401
  }
11188
11402
 
11189
- lines.push(`**Total impacted files:** ${result.totalImpact}`);
11403
+ lines.push(`**Total impacted files:** ${result.totalImpact} _(lower bound — resolves relative + tsconfig/jsconfig-aliased imports)_`);
11190
11404
  lines.push('');
11191
11405
 
11192
11406
  if (result.direct.length > 0) {
@@ -11241,155 +11455,230 @@ __factories["./src/graph/impact"] = function(module, exports) {
11241
11455
  __factories["./src/health/scorer"] = function(module, exports) {
11242
11456
 
11243
11457
  /**
11244
- * SigMap health scorer.
11458
+ * SigMap health scorer (v8.11 — auditable composite).
11245
11459
  *
11246
- * Computes a composite 0-100 health score for the current project by combining:
11247
- * 1. Days since context file was last regenerated (staleness penalty 30 pts)
11248
- * 2. Average token reduction percentage (low-reduction penalty 20 pts)
11249
- * 3. Over-budget run rate (budget penalty 20 pts)
11460
+ * Computes a 0-100 health score for a project. Every deduction is recorded in a
11461
+ * `components[]` breakdown so the number is auditable (which signal cost what),
11462
+ * and purely-informational metrics live under `diagnostics` rather than being
11463
+ * dressed up as if they affect the grade.
11250
11464
  *
11251
- * Strategy-aware: thresholds adjust based on the active strategy so that
11252
- * hot-cold (90% reduction intentional) is not penalized as 'low reduction'.
11465
+ * Scored signals (each appears in `components` only when it fires):
11466
+ * 1. context never generated — no adapter output exists though source does (45 pts)
11467
+ * 2. staleness — freshest adapter output older than 7 days (≤30 pts)
11468
+ * 3. low token reduction — avg reduction under threshold (full strategy) (20 pts)
11469
+ * 4. cold-context staleness — hot-cold context-cold.md older than 1 day (≤10 pts)
11470
+ * 5. over-budget rate — >20% of runs exceeded the token budget (20 pts)
11471
+ * 6. sustained over-budget — ≥3 consecutive over-budget runs (5 pts)
11253
11472
  *
11254
- * Grade scale: A 90 | B 75 | C 60 | D < 60
11473
+ * Diagnostics (informational, NOT scored): p50/p95 token count, and
11474
+ * languageCoverage (share of SigMap's supported languages present in the repo —
11475
+ * this is language diversity, not extractor quality, and was previously
11476
+ * mislabeled "extractorCoverage").
11255
11477
  *
11256
- * Never throws returns graceful result with nulls for unavailable metrics.
11478
+ * Freshness looks at the freshest of ANY adapter output (not just Copilot), so a
11479
+ * Claude/Codex/Cursor user is not falsely flagged as "never generated".
11257
11480
  *
11258
- * @param {string} cwd - Working directory (root of the project)
11259
- * @returns {{
11260
- * score: number,
11261
- * grade: 'A'|'B'|'C'|'D',
11262
- * strategy: string,
11263
- * tokenReductionPct: number|null,
11264
- * daysSinceRegen: number|null,
11265
- * strategyFreshnessDays: number|null,
11266
- * totalRuns: number,
11267
- * overBudgetRuns: number,
11268
- * }}
11481
+ * Grade scale: A 90 | B 75 | C ≥ 60 | D < 60. Never throws.
11482
+ *
11483
+ * @param {string} cwd
11484
+ * @returns {object} { score, grade, components, strategy, tokenReductionPct,
11485
+ * daysSinceRegen, strategyFreshnessDays, totalRuns, overBudgetRuns,
11486
+ * overBudgetStreak, languageCoverage, extractorCoverage, diagnostics }
11269
11487
  */
11270
- function score(cwd) {
11271
- const fs = require('fs');
11272
- const path = require('path');
11273
11488
 
11274
- let tokenReductionPct = null;
11275
- let daysSinceRegen = null;
11276
- let strategyFreshnessDays = null;
11277
- let overBudgetRuns = 0;
11278
- let totalRuns = 0;
11279
- let p50TokenCount = 0;
11280
- let p95TokenCount = 0;
11281
- let overBudgetStreak = 0;
11282
- let extractorCoverage = 0;
11489
+ const fs = require('fs');
11490
+ const path = require('path');
11491
+
11492
+ // Every path a SigMap adapter may write context to (freshness looks at the
11493
+ // freshest existing one). Mirrors the ranker's adapter-output probe order.
11494
+ const CONTEXT_FILES = [
11495
+ ['.github', 'copilot-instructions.md'],
11496
+ ['CLAUDE.md'],
11497
+ ['AGENTS.md'],
11498
+ ['.cursorrules'],
11499
+ ['.windsurfrules'],
11500
+ ['.github', 'openai-context.md'],
11501
+ ['.github', 'gemini-context.md'],
11502
+ ['llm-full.txt'],
11503
+ ['llm.txt'],
11504
+ ];
11505
+
11506
+ function gradeFor(points) {
11507
+ if (points >= 90) return 'A';
11508
+ if (points >= 75) return 'B';
11509
+ if (points >= 60) return 'C';
11510
+ return 'D';
11511
+ }
11512
+
11513
+ /**
11514
+ * Pure scoring core. Given gathered signals, return the score, grade, and the
11515
+ * labeled list of deductions. No IO — unit-testable in isolation.
11516
+ *
11517
+ * @param {object} s gathered signals
11518
+ * @returns {{ score:number, grade:'A'|'B'|'C'|'D', components:Array }}
11519
+ */
11520
+ function composeHealth(s) {
11521
+ const components = [];
11522
+ const add = (id, label, penalty, detail) => {
11523
+ const p = Math.round(penalty);
11524
+ if (p > 0) components.push({ id, label, penalty: p, detail });
11525
+ };
11526
+
11527
+ // 1. Context never generated — a project with source files but no context of
11528
+ // any kind is not "healthy"; it hasn't been set up. Gated on hasSource so
11529
+ // an empty/new directory is not penalised for having nothing to index.
11530
+ if (s.daysSinceRegen === null && s.hasSource) {
11531
+ add('not-generated', 'context never generated', 45,
11532
+ 'no adapter output found — run `sigmap` to generate context');
11533
+ }
11534
+
11535
+ // 2. Staleness — freshest adapter output older than the 7-day window.
11536
+ if (s.daysSinceRegen !== null && s.daysSinceRegen > 7) {
11537
+ add('staleness', 'context stale', Math.min(30, Math.floor((s.daysSinceRegen - 7) * 4)),
11538
+ `${s.daysSinceRegen}d since last regen (>7d)`);
11539
+ }
11540
+
11541
+ // 3. Low token reduction — only meaningful for the 'full' strategy; hot-cold
11542
+ // and per-module intentionally produce small/partial outputs.
11543
+ const reductionThreshold = s.strategy === 'full' ? 60 : 0;
11544
+ if (s.tokenReductionPct !== null && s.tokenReductionPct < reductionThreshold) {
11545
+ add('low-reduction', 'low token reduction', 20,
11546
+ `${s.tokenReductionPct}% avg reduction (<${reductionThreshold}%)`);
11547
+ }
11548
+
11549
+ // 4. Cold-context staleness (hot-cold only).
11550
+ if (s.strategy === 'hot-cold' && s.strategyFreshnessDays !== null && s.strategyFreshnessDays > 1) {
11551
+ add('cold-freshness', 'cold context stale', Math.min(10, Math.floor(s.strategyFreshnessDays - 1) * 3),
11552
+ `context-cold.md ${s.strategyFreshnessDays}d old`);
11553
+ }
11554
+
11555
+ // 5. Over-budget rate.
11556
+ if (s.overBudgetRuns > 0 && s.totalRuns > 0) {
11557
+ const rate = (s.overBudgetRuns / s.totalRuns) * 100;
11558
+ if (rate > 20) add('over-budget', 'runs over budget', 20,
11559
+ `${Math.round(rate)}% of runs exceeded budget (>20%)`);
11560
+ }
11283
11561
 
11284
- // ── Detect active strategy ────────────────────────────────────────────────
11562
+ // 6. Sustained over-budget streak — previously computed but never scored.
11563
+ if (s.overBudgetStreak >= 3) {
11564
+ add('over-budget-streak', 'sustained over-budget', 5,
11565
+ `${s.overBudgetStreak} consecutive over-budget runs`);
11566
+ }
11567
+
11568
+ const penalty = components.reduce((sum, c) => sum + c.penalty, 0);
11569
+ const score = Math.max(0, Math.min(100, 100 - penalty));
11570
+ return { score, grade: gradeFor(score), components };
11571
+ }
11572
+
11573
+ /**
11574
+ * Gather health signals from disk and score them. Never throws.
11575
+ * @param {string} cwd
11576
+ */
11577
+ function score(cwd) {
11285
11578
  let strategy = 'full';
11286
11579
  try {
11287
11580
  const cfgPath = path.join(cwd, 'gen-context.config.json');
11288
11581
  if (fs.existsSync(cfgPath)) {
11289
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
11290
- strategy = cfg.strategy || 'full';
11582
+ strategy = JSON.parse(fs.readFileSync(cfgPath, 'utf8')).strategy || 'full';
11291
11583
  }
11292
11584
  } catch (_) {}
11293
11585
 
11294
- // ── Read usage log via tracking logger ──────────────────────────────────
11586
+ // ── Usage-log signals (only present when tracking has recorded runs) ────────
11587
+ let tokenReductionPct = null;
11588
+ let overBudgetRuns = 0;
11589
+ let totalRuns = 0;
11590
+ let p50TokenCount = 0;
11591
+ let p95TokenCount = 0;
11592
+ let overBudgetStreak = 0;
11295
11593
  try {
11296
11594
  const { readLog, summarize } = __require('./src/tracking/logger');
11297
- const { percentile, overBudgetStreak: calcOverBudgetStreak } = __require('./src/format/dashboard');
11595
+ const { percentile, overBudgetStreak: calcStreak } = __require('./src/format/dashboard');
11298
11596
  const entries = readLog(cwd);
11299
- const s = summarize(entries);
11300
- // Only set tokenReductionPct when there is actual history; a brand-new/
11301
- // untracked project should not be penalised for "0% reduction".
11302
- if (s.totalRuns > 0) tokenReductionPct = s.avgReductionPct;
11303
- overBudgetRuns = s.overBudgetRuns;
11304
- totalRuns = s.totalRuns;
11597
+ const sum = summarize(entries);
11598
+ if (sum.totalRuns > 0) tokenReductionPct = sum.avgReductionPct;
11599
+ overBudgetRuns = sum.overBudgetRuns;
11600
+ totalRuns = sum.totalRuns;
11305
11601
  const finals = entries.map((e) => Number(e.finalTokens)).filter(Number.isFinite);
11306
11602
  p50TokenCount = Math.round(percentile(finals, 50));
11307
11603
  p95TokenCount = Math.round(percentile(finals, 95));
11308
- overBudgetStreak = calcOverBudgetStreak(entries);
11309
- } catch (_) {
11310
- // No usage log yet — proceed with nulls
11311
- }
11604
+ overBudgetStreak = calcStreak(entries);
11605
+ } catch (_) {}
11312
11606
 
11607
+ // ── Language coverage (DIAGNOSTIC — share of supported languages present,
11608
+ // i.e. diversity, not extractor quality). Also yields hasSource. ──────────
11609
+ let languageCoverage = null;
11610
+ let hasSource = false;
11313
11611
  try {
11314
11612
  const { computeExtractorCoverage } = __require('./src/format/dashboard');
11315
- extractorCoverage = computeExtractorCoverage(cwd).pct;
11316
- } catch (_) {
11317
- extractorCoverage = 0;
11318
- }
11613
+ const cov = computeExtractorCoverage(cwd);
11614
+ languageCoverage = { covered: cov.covered, supported: cov.supported, pct: cov.pct };
11615
+ hasSource = Object.values(cov.perLanguage || {}).some((n) => n > 0);
11616
+ } catch (_) {}
11319
11617
 
11320
- // ── Days since primary context file was last regenerated ─────────────────
11618
+ // ── Freshness across ALL adapter outputs (freshest wins) ───────────────────
11619
+ let daysSinceRegen = null;
11321
11620
  try {
11322
- const ctxFile = path.join(cwd, '.github', 'copilot-instructions.md');
11323
- if (fs.existsSync(ctxFile)) {
11324
- const mtime = fs.statSync(ctxFile).mtimeMs;
11325
- daysSinceRegen = parseFloat(((Date.now() - mtime) / (1000 * 60 * 60 * 24)).toFixed(1));
11621
+ let newest = null;
11622
+ for (const parts of CONTEXT_FILES) {
11623
+ const p = path.join(cwd, ...parts);
11624
+ try {
11625
+ if (fs.existsSync(p)) {
11626
+ const m = fs.statSync(p).mtimeMs;
11627
+ if (newest === null || m > newest) newest = m;
11628
+ }
11629
+ } catch (_) {}
11630
+ }
11631
+ if (newest !== null) {
11632
+ daysSinceRegen = parseFloat(((Date.now() - newest) / (1000 * 60 * 60 * 24)).toFixed(1));
11326
11633
  }
11327
11634
  } catch (_) {}
11328
11635
 
11329
- // ── Strategy freshness: context-cold.md age (hot-cold only) ─────────────
11636
+ // ── Cold-context freshness (hot-cold strategy only) ────────────────────────
11637
+ let strategyFreshnessDays = null;
11330
11638
  if (strategy === 'hot-cold') {
11331
11639
  try {
11332
11640
  const coldFile = path.join(cwd, '.github', 'context-cold.md');
11333
11641
  if (fs.existsSync(coldFile)) {
11334
- const mtime = fs.statSync(coldFile).mtimeMs;
11335
- strategyFreshnessDays = parseFloat(((Date.now() - mtime) / (1000 * 60 * 60 * 24)).toFixed(1));
11642
+ const m = fs.statSync(coldFile).mtimeMs;
11643
+ strategyFreshnessDays = parseFloat(((Date.now() - m) / (1000 * 60 * 60 * 24)).toFixed(1));
11336
11644
  }
11337
11645
  } catch (_) {}
11338
11646
  }
11339
11647
 
11340
- // ── Compute composite score ───────────────────────────────────────────────
11341
- let points = 100;
11342
-
11343
- // Staleness penalty: -4 pts per day over the 7-day freshness window (max -30)
11344
- if (daysSinceRegen !== null && daysSinceRegen > 7) {
11345
- points -= Math.min(30, Math.floor((daysSinceRegen - 7) * 4));
11346
- }
11347
-
11348
- // Low-reduction penalty — threshold depends on strategy:
11349
- // - hot-cold: primary output is intentionally tiny; measure cold freshness instead
11350
- // - per-module: per-file budgets; global reduction < 60% expected, no penalty
11351
- // - full: standard 60% threshold
11352
- const reductionThreshold = (strategy === 'full') ? 60 : 0; // disable for hot-cold/per-module
11353
- if (tokenReductionPct !== null && tokenReductionPct < reductionThreshold) {
11354
- points -= 20;
11355
- }
11356
-
11357
- // hot-cold strategy freshness penalty: context-cold.md older than 1 day (-10 pts)
11358
- if (strategy === 'hot-cold' && strategyFreshnessDays !== null && strategyFreshnessDays > 1) {
11359
- points -= Math.min(10, Math.floor(strategyFreshnessDays - 1) * 3);
11360
- }
11361
-
11362
- // Over-budget penalty: more than 20% of runs exceeded the token budget (-20)
11363
- if (overBudgetRuns > 0 && totalRuns > 0) {
11364
- const overBudgetRate = (overBudgetRuns / totalRuns) * 100;
11365
- if (overBudgetRate > 20) points -= 20;
11366
- }
11367
-
11368
- points = Math.max(0, Math.min(100, Math.round(points)));
11369
-
11370
- let grade;
11371
- if (points >= 90) grade = 'A';
11372
- else if (points >= 75) grade = 'B';
11373
- else if (points >= 60) grade = 'C';
11374
- else grade = 'D';
11648
+ const { score: points, grade, components } = composeHealth({
11649
+ strategy,
11650
+ daysSinceRegen,
11651
+ strategyFreshnessDays,
11652
+ tokenReductionPct,
11653
+ overBudgetRuns,
11654
+ totalRuns,
11655
+ overBudgetStreak,
11656
+ hasSource,
11657
+ });
11375
11658
 
11376
11659
  return {
11377
11660
  score: points,
11378
11661
  grade,
11662
+ components,
11379
11663
  strategy,
11380
11664
  tokenReductionPct,
11381
11665
  daysSinceRegen,
11382
11666
  strategyFreshnessDays,
11383
11667
  totalRuns,
11384
11668
  overBudgetRuns,
11669
+ overBudgetStreak,
11670
+ languageCoverage,
11671
+ // Back-compat top-level fields (also surfaced, honestly grouped, under
11672
+ // `diagnostics`). `extractorCoverage` keeps its old name but its value is
11673
+ // language-diversity pct (never extractor quality) — prefer `languageCoverage`.
11385
11674
  p50TokenCount,
11386
11675
  p95TokenCount,
11387
- overBudgetStreak,
11388
- extractorCoverage,
11676
+ extractorCoverage: languageCoverage ? languageCoverage.pct : 0,
11677
+ diagnostics: { p50TokenCount, p95TokenCount, languageCoverage },
11389
11678
  };
11390
11679
  }
11391
11680
 
11392
- module.exports = { score };
11681
+ module.exports = { score, composeHealth };
11393
11682
 
11394
11683
  };
11395
11684
 
@@ -11462,6 +11751,7 @@ __factories["./src/judge/judge-engine"] = function(module, exports) {
11462
11751
  const fs = require('fs');
11463
11752
  const path = require('path');
11464
11753
  const { boostFiles, normalizeFile, penalizeFiles } = __require('./src/learning/weights');
11754
+ const parsers = __require('./src/verify/parsers');
11465
11755
 
11466
11756
  const STOP = new Set([
11467
11757
  'the','a','an','in','on','at','to','of','for','and','or','but',
@@ -11484,6 +11774,57 @@ __factories["./src/judge/judge-engine"] = function(module, exports) {
11484
11774
  return parseFloat((matched.length / respTokens.length).toFixed(3));
11485
11775
  }
11486
11776
 
11777
+ /**
11778
+ * Claim-level grounding (v8.10) — the structural half of the judge.
11779
+ *
11780
+ * `groundedness` above measures lexical *word* overlap: "does the answer reuse
11781
+ * context vocabulary?" That is a weak proxy — an answer can echo context words
11782
+ * while asserting a symbol, file, or import the context never mentions (a
11783
+ * hallucination), and still score high. This function extracts the answer's
11784
+ * *concrete, checkable claims* — the same high-precision claims the hallucination
11785
+ * guard checks (backtick-wrapped `foo()` calls, `path/to/file.ext` references,
11786
+ * and `import … from 'mod'` statements) — and verifies each one appears in the
11787
+ * provided context. A claim the context never grounds is a hallucination signal
11788
+ * that pure word-overlap cannot see.
11789
+ *
11790
+ * Deterministic, offline, zero-dependency. Reuses `src/verify/parsers`.
11791
+ *
11792
+ * @param {string} response
11793
+ * @param {string} context
11794
+ * @returns {{ total: number, grounded: number, ungrounded: Array<{kind:string, value:string}> }}
11795
+ */
11796
+ function claimGrounding(response, context) {
11797
+ if (!response || !context) return { total: 0, grounded: 0, ungrounded: [] };
11798
+ const ctxLower = context.toLowerCase();
11799
+
11800
+ const raw = [];
11801
+ for (const s of parsers.extractSymbols(response)) raw.push({ kind: 'symbol', value: s.name });
11802
+ for (const f of parsers.extractFilePaths(response)) raw.push({ kind: 'file', value: f.path });
11803
+ for (const i of parsers.extractImports(response)) raw.push({ kind: 'import', value: i.module });
11804
+
11805
+ const seen = new Set();
11806
+ const claims = raw.filter((c) => {
11807
+ const key = `${c.kind}::${c.value}`;
11808
+ if (seen.has(key)) return false;
11809
+ seen.add(key);
11810
+ return true;
11811
+ });
11812
+
11813
+ const ungrounded = [];
11814
+ let grounded = 0;
11815
+ for (const c of claims) {
11816
+ // A file claim is grounded if its basename appears in context (the answer
11817
+ // may cite a different directory than the map records). Symbols and modules
11818
+ // are matched on the token itself.
11819
+ const needle = c.value.toLowerCase();
11820
+ const base = c.kind === 'file' ? (c.value.split('/').pop() || c.value).toLowerCase() : needle;
11821
+ if (ctxLower.includes(base) || ctxLower.includes(needle)) grounded++;
11822
+ else ungrounded.push({ kind: c.kind, value: c.value });
11823
+ }
11824
+
11825
+ return { total: claims.length, grounded, ungrounded };
11826
+ }
11827
+
11487
11828
  const GENERIC_MARKERS = [
11488
11829
  'however, based on my knowledge',
11489
11830
  'generally speaking',
@@ -11535,8 +11876,16 @@ __factories["./src/judge/judge-engine"] = function(module, exports) {
11535
11876
  }
11536
11877
  }
11537
11878
 
11879
+ // Structural claim grounding: any concrete symbol/file/import the answer
11880
+ // states that the context never mentions is a hallucination the lexical
11881
+ // score above cannot detect. Each ungrounded claim fails the verdict.
11882
+ const claims = claimGrounding(response, context);
11883
+ for (const c of claims.ungrounded) {
11884
+ reasons.push(`${c.kind} claim not grounded in context: ${c.value}${c.kind === 'symbol' ? '()' : ''}`);
11885
+ }
11886
+
11538
11887
  const verdict = score >= threshold && reasons.length === 0 ? 'pass' : 'fail';
11539
- const result = { score, verdict, reasons };
11888
+ const result = { score, verdict, reasons, claims };
11540
11889
 
11541
11890
  if (opts.learn) {
11542
11891
  const learning = {
@@ -11578,7 +11927,7 @@ __factories["./src/judge/judge-engine"] = function(module, exports) {
11578
11927
  return result;
11579
11928
  }
11580
11929
 
11581
- module.exports = { groundedness, judge };
11930
+ module.exports = { groundedness, claimGrounding, judge };
11582
11931
 
11583
11932
  };
11584
11933
 
@@ -13694,7 +14043,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
13694
14043
 
13695
14044
  const SERVER_INFO = {
13696
14045
  name: 'sigmap',
13697
- version: '8.9.1',
14046
+ version: '8.11.0',
13698
14047
  description: 'SigMap MCP server — code signatures on demand',
13699
14048
  };
13700
14049
 
@@ -14292,7 +14641,7 @@ __factories["./src/plan/planner"] = function(module, exports) {
14292
14641
 
14293
14642
  module.exports = { createPlan };
14294
14643
 
14295
- function createPlan(goal, cwd, config) {
14644
+ function createPlan(goal, cwd, config = {}) {
14296
14645
  // Step 1: Detect intent and rank files for the goal
14297
14646
  const intent = detectIntent(goal);
14298
14647
  const sigIndex = buildSigIndex(cwd);
@@ -14306,30 +14655,59 @@ __factories["./src/plan/planner"] = function(module, exports) {
14306
14655
  const highConf = ranked.filter(r => r.confidence === 'high').slice(0, 5);
14307
14656
  const medConf = ranked.filter(r => r.confidence === 'medium').slice(0, 5);
14308
14657
 
14309
- // Step 3: Compute impact radius for highest-confidence file
14658
+ // Step 3: Impact radius union the reverse-dependency blast radius of EVERY
14659
+ // high-confidence file (not just the top one), bounded to 3 hops. Note the
14660
+ // dependency graph resolves relative imports only, so this is a *lower bound*
14661
+ // on real coupling (aliased/bare/dynamic imports are invisible). Previously
14662
+ // this passed `{ maxDepth: 3 }`, which getImpact ignores — it reads `depth`,
14663
+ // so the traversal silently ran unbounded (depth 0). Fixed to `depth: 3`.
14310
14664
  let impact = null;
14311
14665
  if (highConf.length > 0) {
14312
- const entryFile = highConf[0].file;
14313
14666
  try {
14314
14667
  const graph = buildFromCwd(cwd);
14315
- impact = getImpact(entryFile, graph, { maxDepth: 3, cwd });
14668
+ // getImpact normalizes graph paths to lowercase, so on a case-varying
14669
+ // filesystem (e.g. macOS `/Users`) its returned paths climb out of cwd.
14670
+ // Re-anchor every impacted path to a clean, case-insensitive repo-relative
14671
+ // form so dedup against the entry set works and output is readable.
14672
+ const clean = (f) => {
14673
+ const abs = path.resolve(cwd, f);
14674
+ return abs.toLowerCase().startsWith(cwd.toLowerCase())
14675
+ ? abs.slice(cwd.length).replace(/^[/\\]/, '')
14676
+ : path.relative(cwd, abs);
14677
+ };
14678
+ const entrySet = new Set(highConf.map(r => r.file));
14679
+ const direct = new Set();
14680
+ const transitive = new Set();
14681
+ for (const r of highConf) {
14682
+ const imp = getImpact(r.file, graph, { depth: 3, cwd });
14683
+ for (const f of (imp.direct || [])) direct.add(clean(f));
14684
+ for (const f of (imp.transitive || [])) transitive.add(clean(f));
14685
+ }
14686
+ // The files we plan to change are not their own blast radius; and a file
14687
+ // reached directly from one entry outranks a transitive reach from another.
14688
+ for (const e of entrySet) { direct.delete(e); transitive.delete(e); }
14689
+ for (const f of direct) transitive.delete(f);
14690
+ impact = { direct: [...direct], transitive: [...transitive] };
14316
14691
  } catch (_) {
14317
14692
  // Graph build failed, continue without impact
14318
14693
  }
14319
14694
  }
14320
14695
 
14321
- // Step 4: Identify likely-affected tests
14322
- let testedFiles = [];
14696
+ // Step 4: Flag which files-to-inspect have detectable test coverage. The test
14697
+ // index maps test-*name tokens*, not test files, so `isTested` can only tell
14698
+ // us a source file is covered — it cannot name the test file. We therefore
14699
+ // report the covered SOURCE files honestly rather than pretending to list the
14700
+ // tests to run.
14701
+ let coveredFiles = [];
14323
14702
  try {
14324
14703
  const testIndex = buildTestIndex(cwd, config.testDirs || ['test', 'tests', '__tests__', 'spec']);
14325
- testedFiles = highConf.filter(r => {
14326
- const sigs = r.sigs || [];
14327
- const fnNames = sigs.map(s => {
14704
+ coveredFiles = highConf.filter(r => {
14705
+ const fnNames = (r.sigs || []).map(s => {
14328
14706
  const m = s.match(/(?:function|def|fn)\s+(\w+)/);
14329
14707
  return m ? m[1] : null;
14330
14708
  }).filter(Boolean);
14331
14709
  return fnNames.some(fn => isTested(fn, testIndex));
14332
- });
14710
+ }).map(r => r.file);
14333
14711
  } catch (_) {
14334
14712
  // Coverage index failed, continue without test info
14335
14713
  }
@@ -14339,11 +14717,11 @@ __factories["./src/plan/planner"] = function(module, exports) {
14339
14717
  intent,
14340
14718
  inspectFirst: highConf.map(r => r.file),
14341
14719
  likelyToChange: medConf.map(r => r.file),
14342
- impactRadius: impact ? {
14343
- direct: [...(impact.direct || [])],
14344
- transitive: [...(impact.transitive || [])],
14345
- } : null,
14346
- testsAffected: testedFiles.map(r => r.file),
14720
+ impactRadius: impact,
14721
+ coveredFiles,
14722
+ // `testsAffected` retained for backward compatibility; it is the set of
14723
+ // covered source files, NOT the test files (which the index cannot name).
14724
+ testsAffected: coveredFiles,
14347
14725
  };
14348
14726
  }
14349
14727
 
@@ -15392,7 +15770,8 @@ __factories["./src/review/pr-evidence"] = function(module, exports) {
15392
15770
  L.push('### Review findings');
15393
15771
  for (const f of evidence.review.findings) {
15394
15772
  if (f.type === 'missing-tests') L.push(`- ⚠️ **missing tests** — \`${f.file}\` changed with no matching test`);
15395
- else if (f.type === 'security-file') L.push(`- ⚠️ **security-sensitive file** — \`${f.file}\``);
15773
+ else if (f.type === 'security-file') L.push(`- ⚠️ **sensitive path touched** (path heuristic, not a content scan) — \`${f.file}\``);
15774
+ else if (f.type === 'secret-detected') L.push(`- 🔑 **secret detected** (${f.secret}) — \`${f.file}\``);
15396
15775
  else if (f.type === 'god-node') L.push(`- ⚠️ **god node** — \`${f.file}\` → ${f.count} dependents (high blast radius)`);
15397
15776
  else if (f.type === 'scope-drift') L.push(`- ⚠️ **scope drift** — ${f.count} top-level dirs touched (${f.dirs.join(', ')})`);
15398
15777
  }
@@ -15447,8 +15826,10 @@ __factories["./src/review/review-pr"] = function(module, exports) {
15447
15826
  * zero-dependency, bundle-safe; reuses the impact graph for blast radius.
15448
15827
  */
15449
15828
 
15829
+ const fs = require('fs');
15450
15830
  const path = require('path');
15451
15831
  const { analyzeImpact } = __require('./src/graph/impact');
15832
+ const { PATTERNS } = __require('./src/security/patterns');
15452
15833
 
15453
15834
  const SECURITY_PATTERNS = [
15454
15835
  /(^|\/)\.env(\.|$)/i,
@@ -15499,10 +15880,30 @@ __factories["./src/review/review-pr"] = function(module, exports) {
15499
15880
  if (!covered) findings.push({ type: 'missing-tests', file: s, severity: 'warn' });
15500
15881
  }
15501
15882
 
15502
- // 2. Security-sensitive files.
15883
+ // 2a. Sensitive-path heuristic — flags files whose PATH looks security-relevant
15884
+ // (.env, auth/, lockfiles, workflows, key material). This is a path heuristic,
15885
+ // NOT a content scan: it flags touching the path regardless of what changed,
15886
+ // and cannot see a secret hidden in an innocently-named file. `basis` records
15887
+ // that honestly so consumers don't mistake it for a content check.
15503
15888
  for (const f of live) {
15504
15889
  if (SECURITY_PATTERNS.some((re) => re.test(f.path))) {
15505
- findings.push({ type: 'security-file', file: f.path, severity: 'warn' });
15890
+ findings.push({ type: 'security-file', file: f.path, severity: 'warn', basis: 'path-heuristic' });
15891
+ }
15892
+ }
15893
+
15894
+ // 2b. Real secret scan — read each changed file's CONTENT and match known
15895
+ // secret patterns. This is the actual security check (content, not filename):
15896
+ // it catches a hardcoded key in a file the path heuristic would never flag.
15897
+ const readFile = opts.readFile || ((p) => fs.readFileSync(path.resolve(cwd, p), 'utf8'));
15898
+ for (const f of live) {
15899
+ let content;
15900
+ try { content = readFile(f.path); } catch (_) { continue; } // absent/unreadable → skip
15901
+ if (typeof content !== 'string' || content.length > 2_000_000) continue; // skip huge/binary
15902
+ for (const pat of PATTERNS) {
15903
+ if (pat.regex.test(content)) {
15904
+ findings.push({ type: 'secret-detected', file: f.path, secret: pat.name, severity: 'high', basis: 'content-scan' });
15905
+ break; // one hit is enough to flag the file
15906
+ }
15506
15907
  }
15507
15908
  }
15508
15909
 
@@ -17122,6 +17523,52 @@ __factories["./src/util/git"] = function(module, exports) {
17122
17523
 
17123
17524
  };
17124
17525
 
17526
+ // ── ./src/util/truncate ──
17527
+ __factories["./src/util/truncate"] = function(module, exports) {
17528
+
17529
+ /**
17530
+ * Visible truncation for extractor caps (v8.11).
17531
+ *
17532
+ * Extractors cap per-file signatures and per-class members to protect the token
17533
+ * budget. Historically that truncation was SILENT — the tail of a large file
17534
+ * simply vanished with no trace, so "5 of 40 methods extracted" looked identical
17535
+ * to "fully extracted". These helpers keep the cap but append a visible marker
17536
+ * so the loss is always disclosed.
17537
+ *
17538
+ * Zero-dependency, bundle-safe.
17539
+ */
17540
+
17541
+ /**
17542
+ * Cap a string array, appending a `… +N more <label>` marker when items drop.
17543
+ * @param {string[]} items
17544
+ * @param {number} limit
17545
+ * @param {string} label e.g. 'signatures'
17546
+ * @returns {string[]}
17547
+ */
17548
+ function capWithNotice(items, limit, label) {
17549
+ if (!Array.isArray(items) || items.length <= limit) return items;
17550
+ const dropped = items.length - limit;
17551
+ return items.slice(0, limit).concat(`… +${dropped} more ${label}`);
17552
+ }
17553
+
17554
+ /**
17555
+ * Cap an array of member objects ({ text, ... }), appending a marker member
17556
+ * when items drop so the class block discloses the omission.
17557
+ * @param {Array<{text:string}>} members
17558
+ * @param {number} limit
17559
+ * @param {string} [label='methods']
17560
+ * @returns {Array<{text:string}>}
17561
+ */
17562
+ function capMembersWithNotice(members, limit, label = 'methods') {
17563
+ if (!Array.isArray(members) || members.length <= limit) return members;
17564
+ const dropped = members.length - limit;
17565
+ return members.slice(0, limit).concat({ text: `… +${dropped} more ${label}`, start: 0, end: 0 });
17566
+ }
17567
+
17568
+ module.exports = { capWithNotice, capMembersWithNotice };
17569
+
17570
+ };
17571
+
17125
17572
  // ── ./src/verify/closest-match ──
17126
17573
  __factories["./src/verify/closest-match"] = function(module, exports) {
17127
17574
 
@@ -18286,7 +18733,7 @@ function __tryGit(args, opts = {}) {
18286
18733
  catch (_) { return ''; }
18287
18734
  }
18288
18735
 
18289
- const VERSION = '8.9.1';
18736
+ const VERSION = '8.11.0';
18290
18737
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
18291
18738
 
18292
18739
  function requireSourceOrBundled(key) {
@@ -18963,6 +19410,21 @@ function formatOutput(fileEntries, cwd, routingEnabled, config, extras) {
18963
19410
  groups[group].push({ rel, sigs: entry.sigs });
18964
19411
  }
18965
19412
 
19413
+ // D7: terse signature encoding — anchors preserved byte-exactly
19414
+ const terseOn = !!(config && config.terse);
19415
+ let encodeTerseSigs = null;
19416
+ if (terseOn) {
19417
+ try {
19418
+ const terseMod = requireSourceOrBundled('./src/format/terse');
19419
+ encodeTerseSigs = terseMod.encodeTerseSigs;
19420
+ const m = terseMod.measureTerse(fileEntries.map((e) => e.sigs));
19421
+ console.warn(`[sigmap] terse: sig block ${m.beforeTokens} → ${m.afterTokens} tokens (-${m.reductionPct}%)`);
19422
+ } catch (err) {
19423
+ encodeTerseSigs = null;
19424
+ console.warn(`[sigmap] terse encoding skipped: ${err.message}`);
19425
+ }
19426
+ }
19427
+
18966
19428
  for (const [group, entries] of Object.entries(groups).sort()) {
18967
19429
  lines.push(`## ${group}`);
18968
19430
  lines.push('');
@@ -18972,7 +19434,7 @@ function formatOutput(fileEntries, cwd, routingEnabled, config, extras) {
18972
19434
  const usedByStr = usedBy && usedBy.length ? ` (used by: ${usedBy.join(', ')})` : '';
18973
19435
  lines.push(`### ${rel}${usedByStr}`);
18974
19436
  lines.push('```');
18975
- lines.push(...sigs);
19437
+ lines.push(...(encodeTerseSigs ? encodeTerseSigs(sigs) : sigs));
18976
19438
  lines.push('```');
18977
19439
  lines.push('');
18978
19440
  }
@@ -20086,6 +20548,7 @@ Usage:
20086
20548
  ${cmd} --monorepo Generate per-package context (monorepo)
20087
20549
  ${cmd} --each Run for every repo in the current directory
20088
20550
  ${cmd} --routing Include model routing hints in output
20551
+ ${cmd} --terse Compact signature encoding (deterministic; line anchors preserved)
20089
20552
  ${cmd} --format cache Also write Anthropic prompt-cache JSON
20090
20553
  ${cmd} --track Append run metrics to .context/usage.ndjson
20091
20554
  ${cmd} --watch Generate + watch for file changes
@@ -20292,15 +20755,9 @@ function registerMcp(cwd, scriptPath) {
20292
20755
  // ---------------------------------------------------------------------------
20293
20756
  // v4.2 helpers
20294
20757
  // ---------------------------------------------------------------------------
20295
- const MODEL_COSTS = {
20296
- 'gpt-4': 0.030,
20297
- 'gpt-4o': 0.005,
20298
- 'gpt-4o-mini': 0.000150,
20299
- 'claude-3-5-sonnet': 0.003,
20300
- 'claude-3-haiku': 0.00025,
20301
- 'claude-opus-4': 0.015,
20302
- 'gemini-1.5-pro': 0.00125,
20303
- };
20758
+ // Pricing lives in src/tracking/pricing.js (the verified, single-source table
20759
+ // shared with the `gain` dashboard). The old inline MODEL_COSTS table was
20760
+ // removed — it disagreed with pricing.js (e.g. gpt-4o at $5 vs $2.50 /Mtok).
20304
20761
 
20305
20762
  function buildMiniContext(ranked, cwd) {
20306
20763
  const lines = ['# SigMap Query Context', `Generated: ${new Date().toISOString()}`, ''];
@@ -20478,6 +20935,11 @@ function main() {
20478
20935
  config.testCoverage = true;
20479
20936
  }
20480
20937
 
20938
+ // --terse: compact signature encoding without editing config (D7)
20939
+ if (args.includes('--terse')) {
20940
+ config.terse = true;
20941
+ }
20942
+
20481
20943
  // ── --output <file> — parse early so every subsequent block can use it ─────
20482
20944
  // Resolves the custom output path and merges it into config.customOutput.
20483
20945
  // Also persists the resolved relative path to gen-context.config.json so
@@ -20671,8 +21133,14 @@ function main() {
20671
21133
  }
20672
21134
  if (rawTok === 0) rawTok = getRawTokenCount(cwd, config);
20673
21135
  const savings = rawTok > 0 ? Math.round((1 - ctxTok / rawTok) * 100) : 0;
20674
- const model = args[args.indexOf('--model') + 1] || 'gpt-4o';
20675
- const rateK = MODEL_COSTS[model] || MODEL_COSTS['gpt-4o'];
21136
+ const __mIdx = args.indexOf('--model');
21137
+ const model = (__mIdx !== -1 && args[__mIdx + 1] && !args[__mIdx + 1].startsWith('--')) ? args[__mIdx + 1] : 'gpt-4o';
21138
+ // Single source of truth for pricing — the verified pricing.js table, shared
21139
+ // with the `gain` dashboard (previously an inline MODEL_COSTS table disagreed
21140
+ // with it, e.g. gpt-4o priced at $5/Mtok here vs $2.50/Mtok there).
21141
+ const { resolvePrice: __resolvePrice } = requireSourceOrBundled('./src/tracking/pricing');
21142
+ const __price = __resolvePrice(model);
21143
+ const rateK = __price.perMtok / 1000; // USD per 1K tokens
20676
21144
  const costRaw = ((rawTok / 1000) * rateK).toFixed(4);
20677
21145
  const costCtx = ((ctxTok / 1000) * rateK).toFixed(4);
20678
21146
 
@@ -20682,6 +21150,8 @@ function main() {
20682
21150
  process.stdout.write(JSON.stringify({
20683
21151
  intent, coverage: coveragePct, contextTokens: ctxTok,
20684
21152
  costBefore: costRaw, costAfter: costCtx, savingsPct: savings,
21153
+ pricedModel: __price.model,
21154
+ costBasis: 'estimate — counterfactual = full content of ranked files; input tokens only',
20685
21155
  riskLevel, contextPath: path.relative(cwd, outPath),
20686
21156
  }) + '\n');
20687
21157
  } else {
@@ -20697,6 +21167,7 @@ function main() {
20697
21167
  ` Coverage : ${coveragePct}%`,
20698
21168
  ` Risk : ${riskLevel}`,
20699
21169
  ` Cost : $${costCtx}/query (was $${costRaw} · saved ${savings}%)`,
21170
+ ` ${' '.repeat(9)} est. @ ${__price.model} $${__price.perMtok}/Mtok input; "was" = full ranked files`,
20700
21171
  bar,
20701
21172
  ].join('\n'));
20702
21173
  }
@@ -20825,21 +21296,57 @@ function main() {
20825
21296
  process.exit(0);
20826
21297
  }
20827
21298
 
20828
- // v4.2: `sigmap suggest-profile` — auto-detect task type from git state
21299
+ // v4.2: `sigmap suggest-profile` — infer the task profile from the actual
21300
+ // staged CHANGES (test ratio, breadth, doc/config mix), not just one
21301
+ // commit-message keyword. The commit message is only a secondary disambiguator
21302
+ // for source-focused edits, and the sole (weak) signal when nothing is staged.
20829
21303
  if (args[0] === 'suggest-profile') {
20830
21304
  const short = args.includes('--short');
20831
- let msg = '', diff = '';
21305
+ let msg = '', diffRaw = '';
20832
21306
  try {
20833
- msg = __git(['log', '-1', '--format=%s'], { cwd, timeout: 3000 }).trim();
20834
- diff = __git(['diff', '--cached', '--name-only'], { cwd, timeout: 3000 });
21307
+ msg = __git(['log', '-1', '--format=%s'], { cwd, timeout: 3000 }).trim();
21308
+ diffRaw = __git(['diff', '--cached', '--name-only'], { cwd, timeout: 3000 });
20835
21309
  } catch (_) {}
20836
21310
 
21311
+ const files = diffRaw.split('\n').map((s) => s.trim()).filter(Boolean);
21312
+ const isTest = (f) => /\.(test|spec)\.[jt]sx?$|(^|\/)test_|_test\.(py|go)$|(^|\/)(tests?|__tests__|spec)\//i.test(f);
21313
+ const isSrc = (f) => /\.(js|jsx|ts|tsx|mjs|cjs|py|go|rs|java|rb|php)$/i.test(f) && !isTest(f);
21314
+ const isDocCfg = (f) => /\.(md|json|ya?ml|toml|cfg|ini)$/i.test(f) || /(^|\/)\.github\//i.test(f);
21315
+ const fixMsg = /fix|bug|error|crash|exception/i.test(msg);
21316
+ const archMsg = /refactor|architect|redesign|module/i.test(msg);
21317
+
21318
+ const testN = files.filter(isTest).length;
21319
+ const srcN = files.filter(isSrc).length;
21320
+ const docCfgN = files.filter(isDocCfg).length;
21321
+ const dirs = new Set(files.map((f) => (f.includes('/') ? f.split('/')[0] : '.')));
21322
+
20837
21323
  let profile = 'default';
20838
- let reason = 'no strong signal in git state';
20839
- if (/fix|bug|error|crash|exception/i.test(msg)) { profile = 'debug'; reason = `commit: "${msg.slice(0, 60)}"`; }
20840
- else if (/refactor|architect|redesign|module/i.test(msg)) { profile = 'architecture'; reason = `commit: "${msg.slice(0, 60)}"`; }
20841
- else if (/review|pr|pull.request|check/i.test(msg)) { profile = 'review'; reason = `commit: "${msg.slice(0, 60)}"`; }
20842
- else if (diff.includes('.spec.') || diff.includes('.test.')) { profile = 'debug'; reason = 'staged test files detected'; }
21324
+ let reason;
21325
+ if (files.length === 0) {
21326
+ if (fixMsg) { profile = 'debug'; reason = `no staged files; commit hint: "${msg.slice(0, 50)}"`; }
21327
+ else if (archMsg) { profile = 'architecture'; reason = `no staged files; commit hint: "${msg.slice(0, 50)}"`; }
21328
+ else if (/review|pr|pull.request|check/i.test(msg)) { profile = 'review'; reason = `no staged files; commit hint: "${msg.slice(0, 50)}"`; }
21329
+ else { reason = 'no staged files and no strong commit-message signal'; }
21330
+ } else if (dirs.size >= 4) {
21331
+ profile = 'architecture';
21332
+ reason = `changes span ${dirs.size} top-level dirs (${[...dirs].slice(0, 4).join(', ')}…)`;
21333
+ } else if (testN > 0 && testN >= srcN) {
21334
+ profile = 'debug';
21335
+ reason = `${testN} test file(s) staged (≥ ${srcN} source) — test-driven change`;
21336
+ } else if (srcN === 0 && docCfgN > 0) {
21337
+ profile = 'review';
21338
+ reason = `only docs/config staged (${docCfgN} file(s)) — meta change`;
21339
+ } else if (srcN > 0 && archMsg) {
21340
+ profile = 'architecture';
21341
+ reason = `${srcN} source file(s) + refactor-style commit`;
21342
+ } else if (srcN > 0 && fixMsg) {
21343
+ profile = 'debug';
21344
+ reason = `${srcN} source file(s) + fix-style commit`;
21345
+ } else if (srcN > 0) {
21346
+ reason = `${srcN} source file(s) staged, no strong task signal`;
21347
+ } else {
21348
+ reason = 'staged changes do not match a known task profile';
21349
+ }
20843
21350
 
20844
21351
  if (short) {
20845
21352
  console.log(profile);
@@ -20939,61 +21446,57 @@ function main() {
20939
21446
  process.exit(1);
20940
21447
  }
20941
21448
 
20942
- const { detectIntent, buildSigIndex, rank } = requireSourceOrBundled('./src/retrieval/ranker');
20943
-
20944
- const intent = detectIntent(goal);
20945
- const intentWeights = getIntentWeights(intent);
21449
+ const { createPlan } = requireSourceOrBundled('./src/plan/planner');
21450
+ const plan = createPlan(goal, cwd, config);
20946
21451
 
20947
- const sigIndex = buildSigIndex(cwd);
20948
- if (sigIndex.size === 0) {
21452
+ if (plan.error) {
20949
21453
  console.error('[sigmap] no context file found. Run: sigmap (to generate first)');
20950
21454
  process.exit(1);
20951
21455
  }
20952
21456
 
20953
- const ranked = rank(goal, sigIndex, { topK: 15, weights: intentWeights, cwd });
20954
-
20955
- // Separate into confidence levels
20956
- const highConf = ranked.filter(r => r.confidence === 'high').slice(0, 5);
20957
- const medConf = ranked.filter(r => r.confidence === 'medium').slice(0, 5);
20958
-
20959
- // Compute impact radius (simplified — no graph for now)
20960
- let impact = null;
20961
-
20962
- // Identify likely-affected tests (simplified — checks for .test. or .spec. in filename)
20963
- const testedFiles = highConf.filter(r => /\.(test|spec)\.(js|ts|py)$|_test\.(js|ts|py)$/.test(r.file));
21457
+ const relOf = (f) => path.relative(cwd, path.isAbsolute(f) ? f : path.join(cwd, f));
21458
+ const impact = plan.impactRadius;
20964
21459
 
20965
21460
  if (args.includes('--json')) {
20966
21461
  process.stdout.write(JSON.stringify({
20967
- goal, intent,
20968
- inspectFirst: highConf.map(r => r.file),
20969
- likelyToChange: medConf.map(r => r.file),
21462
+ goal: plan.goal,
21463
+ intent: plan.intent,
21464
+ inspectFirst: plan.inspectFirst,
21465
+ likelyToChange: plan.likelyToChange,
20970
21466
  impactRadius: impact,
20971
- testsAffected: testedFiles.map(r => r.file),
21467
+ coveredFiles: plan.coveredFiles,
21468
+ testsAffected: plan.testsAffected,
20972
21469
  }, null, 2) + '\n');
20973
21470
  } else {
20974
21471
  const bar = '─'.repeat(50);
20975
21472
  console.log(bar);
20976
21473
  console.log(` sigmap plan "${goal}"`);
20977
- console.log(` Intent : ${intent}`);
21474
+ console.log(` Intent : ${plan.intent}`);
20978
21475
  console.log(bar);
20979
21476
  console.log('');
20980
21477
  console.log(' Inspect first (highest relevance):');
20981
- if (highConf.length === 0) {
21478
+ if (plan.inspectFirst.length === 0) {
20982
21479
  console.log(' (no files found)');
20983
21480
  } else {
20984
- highConf.forEach((r, i) => console.log(` ${i + 1}. ${path.relative(cwd, r.file)}`));
21481
+ plan.inspectFirst.forEach((f, i) => console.log(` ${i + 1}. ${relOf(f)}`));
20985
21482
  }
20986
21483
  console.log('');
20987
21484
  console.log(' Likely to change:');
20988
- if (medConf.length === 0) {
21485
+ if (plan.likelyToChange.length === 0) {
20989
21486
  console.log(' (no files found)');
20990
21487
  } else {
20991
- medConf.forEach((r, i) => console.log(` ${i + 1}. ${path.relative(cwd, r.file)}`));
21488
+ plan.likelyToChange.forEach((f, i) => console.log(` ${i + 1}. ${relOf(f)}`));
20992
21489
  }
20993
- if (testedFiles.length > 0) {
21490
+ if (impact && (impact.direct.length || impact.transitive.length)) {
20994
21491
  console.log('');
20995
- console.log(' Tests to run after change:');
20996
- testedFiles.forEach(r => console.log(` • ${path.relative(cwd, r.file)}`));
21492
+ console.log(' Impact radius (relative-import dependents, ≤3 hops — lower bound):');
21493
+ impact.direct.forEach(f => console.log(` • ${relOf(f)} (direct)`));
21494
+ impact.transitive.forEach(f => console.log(` • ${relOf(f)} (transitive)`));
21495
+ }
21496
+ if (plan.coveredFiles.length > 0) {
21497
+ console.log('');
21498
+ console.log(' Files with test coverage (re-run their suites after changing):');
21499
+ plan.coveredFiles.forEach(f => console.log(` • ${relOf(f)}`));
20997
21500
  }
20998
21501
  console.log('');
20999
21502
  console.log(bar);
@@ -21151,18 +21654,18 @@ function main() {
21151
21654
  }
21152
21655
 
21153
21656
  if (entries.length === 0) {
21154
- console.log('[sigmap] No learned weights yet. Run: sigmap learn --good <file>');
21657
+ console.log('[sigmap] No manual weights set. Run: sigmap learn --good <file> to boost a file.');
21155
21658
  process.exit(0);
21156
21659
  }
21157
21660
 
21158
- console.log('[sigmap] Learned file weights (xmultiplier vs baseline):');
21661
+ console.log('[sigmap] Manual file weights boost/penalty multipliers vs baseline:');
21159
21662
  for (const [file, mult] of entries) {
21160
21663
  const bar = mult >= 1
21161
21664
  ? `+${'█'.repeat(Math.max(1, Math.round((mult - 1) * 10)))}`
21162
21665
  : `-${'░'.repeat(Math.max(1, Math.round((1 - mult) * 10)))}`;
21163
21666
  console.log(` ${file.padEnd(50)} x${mult.toFixed(2)} ${bar}`);
21164
21667
  }
21165
- console.log(`\n Total files with learned weights: ${entries.length}`);
21668
+ console.log(`\n ${entries.length} file(s) manually boosted/penalized (set via \`sigmap learn\`; decays toward 1.0 over time — not automatic learning).`);
21166
21669
  console.log(' To reset: sigmap learn --reset');
21167
21670
  process.exit(0);
21168
21671
  }
@@ -21192,7 +21695,17 @@ function main() {
21192
21695
  if (coveragePct < 70)
21193
21696
  warnings.push(`coverage ${coveragePct}% is below recommended 70% — increase maxTokens or expand srcDirs`);
21194
21697
 
21195
- // Optional query symbol check
21698
+ // Optional query check. Two complementary signals:
21699
+ // (a) cased-symbol coverage — if the query literally names a camelCase /
21700
+ // PascalCase symbol (loginUser, AuthMiddleware), confirm it lands in
21701
+ // the top-5. This is a no-op for lowercase natural-language queries.
21702
+ // (b) retrieval-confidence report — works for ANY query, including plain
21703
+ // NL like "login rate limit". Built from the ranker's own score
21704
+ // distribution: a zero top score means the current context has no
21705
+ // lexical match at all; a near-tie between rank-1 and rank-2 means the
21706
+ // ranking is flat and coverage is ambiguous. This replaces the old
21707
+ // behaviour where an NL query silently produced no output whatsoever.
21708
+ let queryReport = null;
21196
21709
  const valQueryIdx = args.indexOf('--query');
21197
21710
  if (valQueryIdx !== -1) {
21198
21711
  const q = (args[valQueryIdx + 1] || '').trim();
@@ -21200,6 +21713,8 @@ function main() {
21200
21713
  try {
21201
21714
  const { rank, buildSigIndex } = requireSourceOrBundled('./src/retrieval/ranker');
21202
21715
  const ranked = rank(q, buildSigIndex(cwd), { topK: 5, cwd });
21716
+
21717
+ // (a) cased-symbol coverage
21203
21718
  const symbols = extractQuerySymbols(q);
21204
21719
  const missing = symbols.filter((sym) =>
21205
21720
  !ranked.some((r) => r.sigs && r.sigs.some((s) => s.toLowerCase().includes(sym.toLowerCase())))
@@ -21208,12 +21723,38 @@ function main() {
21208
21723
  warnings.push(`query "${q}" references symbols not in top-5 context: ${missing.join(', ')}`);
21209
21724
  else if (symbols.length > 0)
21210
21725
  console.log(`[sigmap] ✓ query coverage OK — all ${symbols.length} symbols found`);
21726
+
21727
+ // (b) retrieval-confidence report
21728
+ const top = ranked[0] || null;
21729
+ const topScore = top ? (top.score || 0) : 0;
21730
+ const secondScore = ranked[1] ? (ranked[1].score || 0) : 0;
21731
+ const gapRatio = topScore > 0 ? (topScore - secondScore) / topScore : 0;
21732
+ let queryConfidence;
21733
+ if (topScore <= 0) queryConfidence = 'none';
21734
+ else if (ranked.length > 1 && gapRatio < 0.1) queryConfidence = 'low';
21735
+ else queryConfidence = (top && top.confidence) || 'medium';
21736
+
21737
+ queryReport = {
21738
+ text: q,
21739
+ topFile: top ? top.file : null,
21740
+ topScore: parseFloat(topScore.toFixed(3)),
21741
+ confidence: queryConfidence,
21742
+ };
21743
+
21744
+ if (queryConfidence === 'none')
21745
+ warnings.push(`query "${q}" has no lexical match in the current context — expand srcDirs or raise maxTokens`);
21746
+ else if (queryConfidence === 'low')
21747
+ warnings.push(`query "${q}" ranks flat (top ${top.file} score ${queryReport.topScore}, no dominant match) — context may not cover it well`);
21748
+ else if (!args.includes('--json'))
21749
+ console.log(`[sigmap] ✓ query "${q}" → ${top.file} (score ${queryReport.topScore}, confidence ${queryConfidence})`);
21211
21750
  } catch (_) {}
21212
21751
  }
21213
21752
  }
21214
21753
 
21215
21754
  if (args.includes('--json')) {
21216
- process.stdout.write(JSON.stringify({ valid: issues.length === 0, issues, warnings, coverage: coveragePct }) + '\n');
21755
+ const payload = { valid: issues.length === 0, issues, warnings, coverage: coveragePct };
21756
+ if (queryReport) payload.query = queryReport;
21757
+ process.stdout.write(JSON.stringify(payload) + '\n');
21217
21758
  } else {
21218
21759
  for (const w of warnings) console.warn(`[sigmap] ⚠ ${w}`);
21219
21760
  if (issues.length === 0) {
@@ -22163,10 +22704,11 @@ function main() {
22163
22704
  console.log(' ✓ no findings — scope, tests, blast radius, and sensitive files all clear');
22164
22705
  process.exit(0);
22165
22706
  }
22166
- const label = { 'missing-tests': 'missing tests', 'security-file': 'security file', 'god-node': 'god node', 'scope-drift': 'scope drift' };
22707
+ const label = { 'missing-tests': 'missing tests', 'security-file': 'sensitive path (path heuristic)', 'secret-detected': 'secret detected', 'god-node': 'god node', 'scope-drift': 'scope drift' };
22167
22708
  for (const f of result.findings) {
22168
22709
  if (f.type === 'missing-tests') console.log(` ⚠ ${label[f.type]}: ${f.file} changed with no matching test`);
22169
22710
  else if (f.type === 'security-file') console.log(` ⚠ ${label[f.type]}: ${f.file}`);
22711
+ else if (f.type === 'secret-detected') console.log(` ✗ ${label[f.type]}: ${f.secret} in ${f.file}`);
22170
22712
  else if (f.type === 'god-node') console.log(` ⚠ ${label[f.type]}: ${f.file} → ${f.count} dependents`);
22171
22713
  else if (f.type === 'scope-drift') console.log(` ⚠ ${label[f.type]}: ${f.count} top-level dirs (${f.dirs.join(', ')})`);
22172
22714
  }
@@ -22228,7 +22770,7 @@ function main() {
22228
22770
  process.exit(result.summary.ok ? 0 : 1);
22229
22771
  }
22230
22772
 
22231
- console.log(`[sigmap] create${result.task ? ` "${result.task}"` : ''} — grounded-creation pipeline`);
22773
+ console.log(`[sigmap] create${result.task ? ` "${result.task}"` : ''} — running deterministic guards (scaffold · verify-plan · verify-ai-output · review-pr); the LLM does the authoring`);
22232
22774
  for (const st of result.steps) {
22233
22775
  const mark = st.skipped ? '–' : (st.ok ? '✓' : '✗');
22234
22776
  const status = st.skipped ? `skipped (${st.reason})` : (st.ok ? 'ok' : 'FAILED');
@@ -22874,8 +23416,12 @@ function main() {
22874
23416
  const rawTok = getRawTokenCount(cwd, config);
22875
23417
  runGenerate(cwd, config, false);
22876
23418
 
22877
- const model = args[args.indexOf('--model') + 1] || 'gpt-4o';
22878
- const rateK = MODEL_COSTS[model] || MODEL_COSTS['gpt-4o'];
23419
+ const __mIdxCost = args.indexOf('--model');
23420
+ const model = (__mIdxCost !== -1 && args[__mIdxCost + 1] && !args[__mIdxCost + 1].startsWith('--')) ? args[__mIdxCost + 1] : 'gpt-4o';
23421
+ // Single source of truth for pricing — shared with `gain` (pricing.js).
23422
+ const { resolvePrice: __resolvePriceCost } = requireSourceOrBundled('./src/tracking/pricing');
23423
+ const __priceCost = __resolvePriceCost(model);
23424
+ const rateK = __priceCost.perMtok / 1000; // USD per 1K tokens
22879
23425
 
22880
23426
  const ctxPath = config.customOutput
22881
23427
  ? path.resolve(cwd, config.customOutput)
@@ -22888,19 +23434,20 @@ function main() {
22888
23434
  const costCtx = (outTok / 1000) * rateK;
22889
23435
 
22890
23436
  const out = {
22891
- model,
23437
+ model: __priceCost.model,
22892
23438
  rawTokens: rawTok,
22893
23439
  contextTokens: outTok,
22894
23440
  costRaw: costRaw.toFixed(4),
22895
23441
  costContext: costCtx.toFixed(4),
22896
23442
  savingsPct: savings,
23443
+ costBasis: 'estimate — counterfactual = whole-repo tokens; input tokens only',
22897
23444
  };
22898
23445
 
22899
23446
  if (args.includes('--json')) {
22900
23447
  process.stdout.write(JSON.stringify(out) + '\n');
22901
23448
  } else {
22902
- console.log(`\n Cost estimate (${model}):`);
22903
- console.log(` Without SigMap : ${rawTok.toLocaleString()} tok $${out.costRaw}/query`);
23449
+ console.log(`\n Cost estimate (${__priceCost.model} @ $${__priceCost.perMtok}/Mtok input, est.):`);
23450
+ console.log(` Without SigMap : ${rawTok.toLocaleString()} tok $${out.costRaw}/query (counterfactual: whole repo)`);
22904
23451
  console.log(` With SigMap : ${outTok.toLocaleString()} tok $${out.costContext}/query`);
22905
23452
  console.log(` Savings : ${savings}% ($${(costRaw - costCtx).toFixed(4)} saved per query)\n`);
22906
23453
  }