sigmap 8.9.0 → 8.10.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
@@ -2056,8 +2056,16 @@ __factories["./src/conventions/extract"] = function(module, exports) {
2056
2056
 
2057
2057
  /**
2058
2058
  * Classify a file's base name (without extension) into a naming style.
2059
+ *
2060
+ * A single lowercase word (`user`, `index`, `loader`) is classified
2061
+ * `single-word`, NOT `camelCase`: it has no case boundary or separator, so it is
2062
+ * compatible with camelCase, kebab-case AND snake_case at once and expresses no
2063
+ * distinguishable convention. `scoreConvention` treats it as style-neutral
2064
+ * (excluded), so a repo of single-word files reports "unknown" rather than a
2065
+ * spurious "100% camelCase".
2066
+ *
2059
2067
  * @param {string} basename a file basename, e.g. "user-service.ts"
2060
- * @returns {'PascalCase'|'camelCase'|'kebab-case'|'snake_case'|'other'}
2068
+ * @returns {'PascalCase'|'camelCase'|'kebab-case'|'snake_case'|'single-word'|'other'}
2061
2069
  */
2062
2070
  function classifyNaming(basename) {
2063
2071
  let stem = String(basename || '');
@@ -2068,7 +2076,7 @@ __factories["./src/conventions/extract"] = function(module, exports) {
2068
2076
  if (/[_]/.test(stem) && /^[a-z0-9]+(?:_[a-z0-9]+)+$/.test(stem)) return 'snake_case';
2069
2077
  if (/^[A-Z][A-Za-z0-9]*$/.test(stem) && /[a-z]/.test(stem)) return 'PascalCase';
2070
2078
  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
2079
+ if (/^[a-z][a-z0-9]*$/.test(stem)) return 'single-word'; // style-neutral (no case boundary)
2072
2080
  return 'other';
2073
2081
  }
2074
2082
 
@@ -2091,7 +2099,9 @@ __factories["./src/conventions/extract"] = function(module, exports) {
2091
2099
  let total = 0;
2092
2100
  for (let i = 0; i < all.length; i++) {
2093
2101
  const l = all[i];
2094
- if (l == null || l === 'other') continue;
2102
+ // 'other' = unclassifiable; 'single-word' = style-neutral. Both express no
2103
+ // distinguishable convention and are excluded from the score.
2104
+ if (l == null || l === 'other' || l === 'single-word') continue;
2095
2105
  total++;
2096
2106
  counts.set(l, (counts.get(l) || 0) + 1);
2097
2107
  if (refs && refs[i] != null) {
@@ -2259,7 +2269,10 @@ __factories["./src/conventions/fix"] = function(module, exports) {
2259
2269
  if (TEST_RE.test(f)) continue;
2260
2270
  const base = path.basename(f);
2261
2271
  const style = classifyNaming(base);
2262
- if (style === 'other' || style === dominant) continue;
2272
+ // Skip unclassifiable ('other') and style-neutral single-word names — a
2273
+ // single lowercase word already satisfies any convention, so renaming it
2274
+ // (e.g. user.js → user.js) is a no-op at best and noise at worst.
2275
+ if (style === 'other' || style === 'single-word' || style === dominant) continue;
2263
2276
  const rel = path.relative(cwd, f).replace(/\\/g, '/');
2264
2277
  renames.push({ from: rel, to: _renamePath(rel, dominant), fromStyle: style });
2265
2278
  }
@@ -6071,6 +6084,7 @@ __factories["./src/extractors/java"] = function(module, exports) {
6071
6084
  __factories["./src/extractors/javascript"] = function(module, exports) {
6072
6085
 
6073
6086
  const { lineAt, withAnchor } = __require('./src/extractors/line-anchor');
6087
+ const { capWithNotice, capMembersWithNotice } = __require('./src/util/truncate');
6074
6088
 
6075
6089
  /**
6076
6090
  * Extract signatures from JavaScript source code.
@@ -6150,9 +6164,8 @@ __factories["./src/extractors/javascript"] = function(module, exports) {
6150
6164
  anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
6151
6165
  }
6152
6166
 
6153
- return sigs
6154
- .map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s))
6155
- .slice(0, 25);
6167
+ const withAnchors = sigs.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s));
6168
+ return capWithNotice(withAnchors, 25, 'signatures');
6156
6169
  }
6157
6170
 
6158
6171
  function extractBlock(src, startIndex) {
@@ -6183,7 +6196,7 @@ __factories["./src/extractors/javascript"] = function(module, exports) {
6183
6196
  const retStr = formatReturnHint(returnHints.get(m[1]));
6184
6197
  members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
6185
6198
  }
6186
- return members.slice(0, 8);
6199
+ return capMembersWithNotice(members, 8, 'methods');
6187
6200
  }
6188
6201
 
6189
6202
  function buildReturnHints(src) {
@@ -8063,6 +8076,7 @@ __factories["./src/extractors/toml"] = function(module, exports) {
8063
8076
  __factories["./src/extractors/typescript"] = function(module, exports) {
8064
8077
 
8065
8078
  const { lineAt, withAnchor } = __require('./src/extractors/line-anchor');
8079
+ const { capWithNotice, capMembersWithNotice } = __require('./src/util/truncate');
8066
8080
 
8067
8081
  /**
8068
8082
  * Extract signatures from TypeScript source code.
@@ -8214,9 +8228,8 @@ __factories["./src/extractors/typescript"] = function(module, exports) {
8214
8228
  }
8215
8229
  }
8216
8230
 
8217
- return sigs
8218
- .map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s))
8219
- .slice(0, 35);
8231
+ const withAnchors = sigs.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s));
8232
+ return capWithNotice(withAnchors, 35, 'signatures');
8220
8233
  }
8221
8234
 
8222
8235
  function extractBlock(src, startIndex) {
@@ -8246,7 +8259,7 @@ __factories["./src/extractors/typescript"] = function(module, exports) {
8246
8259
  const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
8247
8260
  members.push({ text: `${m[1]}(${normalizeParams(m[2])})`, start, end: m.index + m[0].length });
8248
8261
  }
8249
- return members.slice(0, 8);
8262
+ return capMembersWithNotice(members, 8, 'members');
8250
8263
  }
8251
8264
 
8252
8265
  const _CTRL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'do', 'try', 'catch', 'finally', 'else', 'return']);
@@ -8272,7 +8285,7 @@ __factories["./src/extractors/typescript"] = function(module, exports) {
8272
8285
  const retStr = retType ? ` → ${retType}` : '';
8273
8286
  members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
8274
8287
  }
8275
- return members.slice(0, 8);
8288
+ return capMembersWithNotice(members, 8, 'methods');
8276
8289
  }
8277
8290
 
8278
8291
  function normalizeParams(params) {
@@ -10232,20 +10245,19 @@ __factories["./src/graph/builder"] = function(module, exports) {
10232
10245
  const R_EXTS = new Set(['.r', '.R']);
10233
10246
 
10234
10247
  /**
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')
10248
+ * Probe an absolute base path for a JS/TS module file in fileSet, trying the
10249
+ * usual extension and index-file candidates.
10250
+ * @param {string} base - absolute path (no extension) to probe
10238
10251
  * @param {Set<string>} fileSet
10239
10252
  * @returns {string|null}
10240
10253
  */
10241
- function resolveJsPath(dir, importStr, fileSet) {
10242
- const base = path.resolve(dir, importStr);
10254
+ function probeJs(base, fileSet) {
10243
10255
  const candidates = [
10244
10256
  base,
10245
10257
  base + '.ts', base + '.tsx',
10246
10258
  base + '.js', base + '.jsx', base + '.mjs', base + '.cjs',
10247
- path.join(base, 'index.ts'),
10248
- path.join(base, 'index.js'),
10259
+ path.join(base, 'index.ts'), path.join(base, 'index.tsx'),
10260
+ path.join(base, 'index.js'), path.join(base, 'index.jsx'),
10249
10261
  ];
10250
10262
  for (const c of candidates) {
10251
10263
  const normC = normalizePath(c);
@@ -10254,6 +10266,103 @@ __factories["./src/graph/builder"] = function(module, exports) {
10254
10266
  return null;
10255
10267
  }
10256
10268
 
10269
+ /**
10270
+ * Resolve a JS/TS relative import string to an absolute path in fileSet.
10271
+ * @param {string} dir - directory of the importing file
10272
+ * @param {string} importStr - raw import string (e.g. './utils', '../store')
10273
+ * @param {Set<string>} fileSet
10274
+ * @returns {string|null}
10275
+ */
10276
+ function resolveJsPath(dir, importStr, fileSet) {
10277
+ return probeJs(path.resolve(dir, importStr), fileSet);
10278
+ }
10279
+
10280
+ /**
10281
+ * Strip comments and trailing commas so a tsconfig/jsconfig (JSONC) parses.
10282
+ * Deliberately conservative — leaves string contents alone.
10283
+ */
10284
+ function stripJsonc(src) {
10285
+ let out = '';
10286
+ let inStr = false, quote = '', inLine = false, inBlock = false;
10287
+ for (let i = 0; i < src.length; i++) {
10288
+ const c = src[i], n = src[i + 1];
10289
+ if (inLine) { if (c === '\n') { inLine = false; out += c; } continue; }
10290
+ if (inBlock) { if (c === '*' && n === '/') { inBlock = false; i++; } continue; }
10291
+ if (inStr) { out += c; if (c === '\\') { out += (n || ''); i++; } else if (c === quote) inStr = false; continue; }
10292
+ if (c === '"' || c === "'") { inStr = true; quote = c; out += c; continue; }
10293
+ if (c === '/' && n === '/') { inLine = true; i++; continue; }
10294
+ if (c === '/' && n === '*') { inBlock = true; i++; continue; }
10295
+ out += c;
10296
+ }
10297
+ // remove trailing commas before } or ]
10298
+ return out.replace(/,(\s*[}\]])/g, '$1');
10299
+ }
10300
+
10301
+ /**
10302
+ * Load the JS/TS path-alias map from tsconfig.json / jsconfig.json.
10303
+ * Resolves `compilerOptions.paths` and `baseUrl` into absolute target bases so
10304
+ * bare/aliased imports (e.g. `@/utils`, `components/Button`) can be resolved to
10305
+ * on-disk files. Returns null when no config or no aliasing is configured.
10306
+ *
10307
+ * @param {string} cwd
10308
+ * @returns {{ baseUrl: string|null, entries: Array<{prefix:string,wildcard:boolean,targets:string[]}> }|null}
10309
+ */
10310
+ function loadAliasMap(cwd) {
10311
+ if (!cwd) return null;
10312
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
10313
+ let json;
10314
+ try { json = JSON.parse(stripJsonc(fs.readFileSync(path.join(cwd, name), 'utf8'))); }
10315
+ catch (_) { continue; }
10316
+ const co = (json && json.compilerOptions) || {};
10317
+ const baseUrl = co.baseUrl ? path.resolve(cwd, co.baseUrl) : null;
10318
+ const base = baseUrl || cwd;
10319
+ const entries = [];
10320
+ for (const [pattern, targets] of Object.entries(co.paths || {})) {
10321
+ const wildcard = pattern.includes('*');
10322
+ const prefix = pattern.replace(/\*.*$/, '');
10323
+ const tgs = (Array.isArray(targets) ? targets : [])
10324
+ .map((t) => path.resolve(base, String(t).replace(/\*.*$/, '')));
10325
+ if (tgs.length) entries.push({ prefix, wildcard, targets: tgs });
10326
+ }
10327
+ if (baseUrl || entries.length) return { baseUrl, entries };
10328
+ return null;
10329
+ }
10330
+ return null;
10331
+ }
10332
+
10333
+ /**
10334
+ * Resolve a non-relative JS/TS import specifier through the alias map.
10335
+ * @param {string} spec - e.g. '@/utils', '@app/Button', 'components/Nav'
10336
+ * @param {object|null} aliasMap - from loadAliasMap
10337
+ * @param {Set<string>} fileSet
10338
+ * @returns {string|null}
10339
+ */
10340
+ function resolveAlias(spec, aliasMap, fileSet) {
10341
+ if (!aliasMap) return null;
10342
+ for (const e of aliasMap.entries) {
10343
+ if (e.wildcard) {
10344
+ if (spec.startsWith(e.prefix)) {
10345
+ const rest = spec.slice(e.prefix.length);
10346
+ for (const t of e.targets) {
10347
+ const r = probeJs(rest ? path.join(t, rest) : t, fileSet);
10348
+ if (r) return r;
10349
+ }
10350
+ }
10351
+ } else if (spec === e.prefix) {
10352
+ for (const t of e.targets) {
10353
+ const r = probeJs(t, fileSet);
10354
+ if (r) return r;
10355
+ }
10356
+ }
10357
+ }
10358
+ // Bare import resolved from baseUrl (tsconfig baseUrl without an explicit alias).
10359
+ if (aliasMap.baseUrl) {
10360
+ const r = probeJs(path.join(aliasMap.baseUrl, spec), fileSet);
10361
+ if (r) return r;
10362
+ }
10363
+ return null;
10364
+ }
10365
+
10257
10366
  /**
10258
10367
  * Resolve an R `source(...)` argument to an absolute path in fileSet.
10259
10368
  * Tries the dir-relative path first, then a cwd-relative path so that
@@ -10299,21 +10408,29 @@ __factories["./src/graph/builder"] = function(module, exports) {
10299
10408
 
10300
10409
  // ── JS / TS ───────────────────────────────────────────────────────────────
10301
10410
  if (JS_EXTS.has(ext)) {
10411
+ const aliasMap = ctx && ctx.aliasMap;
10412
+ // Resolve any specifier: relative → dir-relative; otherwise via tsconfig/
10413
+ // jsconfig path aliases + baseUrl. Bare npm packages (react, lodash) fall
10414
+ // through to null because they are not in fileSet, so no false edges.
10415
+ const resolveSpec = (spec) => spec.startsWith('.')
10416
+ ? resolveJsPath(dir, spec, fileSet)
10417
+ : resolveAlias(spec, aliasMap, fileSet);
10418
+
10302
10419
  const stripped = content
10303
10420
  .replace(/\/\/.*$/gm, '')
10304
10421
  .replace(/\/\*[\s\S]*?\*\//g, '');
10305
10422
 
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
10423
  let m;
10424
+ // ES imports: import ... from 'x' | import 'x' | export ... from 'x'
10425
+ const reEs = /(?:^|[\r\n])\s*(?:import|export)\s+(?:[^'";\r\n]*?\s+from\s+)?['"]([^'"]+)['"]/g;
10309
10426
  while ((m = reEs.exec(stripped)) !== null) {
10310
- const r = resolveJsPath(dir, m[1], fileSet);
10427
+ const r = resolveSpec(m[1]);
10311
10428
  if (r) found.push(r);
10312
10429
  }
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);
10430
+ // CommonJS require('x') and dynamic import('x').
10431
+ const reCall = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
10432
+ while ((m = reCall.exec(stripped)) !== null) {
10433
+ const r = resolveSpec(m[1]);
10317
10434
  if (r) found.push(r);
10318
10435
  }
10319
10436
  }
@@ -10485,6 +10602,10 @@ __factories["./src/graph/builder"] = function(module, exports) {
10485
10602
  const fileSet = new Set(files.map((f) => path.resolve(f)));
10486
10603
  // Create a normalized version for cross-platform case-insensitive lookups
10487
10604
  const fileSetNormalized = new Set([...fileSet].map(normalizePath));
10605
+ // Resolve the JS/TS path-alias map once (tsconfig/jsconfig paths + baseUrl),
10606
+ // unless a caller supplied one explicitly via ctx.
10607
+ const aliasMap = (ctx && 'aliasMap' in ctx) ? ctx.aliasMap : loadAliasMap(cwd);
10608
+ const effectiveCtx = Object.assign({}, ctx, { aliasMap });
10488
10609
  const forward = new Map();
10489
10610
  const reverse = new Map();
10490
10611
 
@@ -10505,7 +10626,7 @@ __factories["./src/graph/builder"] = function(module, exports) {
10505
10626
  }
10506
10627
 
10507
10628
  const normFilePath = normalizePath(filePath);
10508
- const deps = extractFileDeps(filePath, content, fileSetNormalized, cwd, ctx);
10629
+ const deps = extractFileDeps(filePath, content, fileSetNormalized, cwd, effectiveCtx);
10509
10630
  if (deps.length > 0) {
10510
10631
  forward.set(normFilePath, deps);
10511
10632
  for (const dep of deps) {
@@ -10584,7 +10705,7 @@ __factories["./src/graph/builder"] = function(module, exports) {
10584
10705
  return build(files, cwd, ctx);
10585
10706
  }
10586
10707
 
10587
- module.exports = { build, buildFromCwd, extractFileDeps, normalizePath };
10708
+ module.exports = { build, buildFromCwd, extractFileDeps, normalizePath, loadAliasMap, resolveAlias };
10588
10709
 
10589
10710
  };
10590
10711
 
@@ -11182,11 +11303,11 @@ __factories["./src/graph/impact"] = function(module, exports) {
11182
11303
  lines.push('');
11183
11304
 
11184
11305
  if (result.direct.length === 0 && result.transitive.length === 0) {
11185
- lines.push('_No files import this filezero blast radius._');
11306
+ lines.push('_No importers found via relative + aliased imports (lower bound dynamic/computed imports are not tracked)._');
11186
11307
  return lines.join('\n');
11187
11308
  }
11188
11309
 
11189
- lines.push(`**Total impacted files:** ${result.totalImpact}`);
11310
+ lines.push(`**Total impacted files:** ${result.totalImpact} _(lower bound — resolves relative + tsconfig/jsconfig-aliased imports)_`);
11190
11311
  lines.push('');
11191
11312
 
11192
11313
  if (result.direct.length > 0) {
@@ -11241,155 +11362,230 @@ __factories["./src/graph/impact"] = function(module, exports) {
11241
11362
  __factories["./src/health/scorer"] = function(module, exports) {
11242
11363
 
11243
11364
  /**
11244
- * SigMap health scorer.
11365
+ * SigMap health scorer (v8.11 — auditable composite).
11245
11366
  *
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)
11367
+ * Computes a 0-100 health score for a project. Every deduction is recorded in a
11368
+ * `components[]` breakdown so the number is auditable (which signal cost what),
11369
+ * and purely-informational metrics live under `diagnostics` rather than being
11370
+ * dressed up as if they affect the grade.
11250
11371
  *
11251
- * Strategy-aware: thresholds adjust based on the active strategy so that
11252
- * hot-cold (90% reduction intentional) is not penalized as 'low reduction'.
11372
+ * Scored signals (each appears in `components` only when it fires):
11373
+ * 1. context never generated — no adapter output exists though source does (45 pts)
11374
+ * 2. staleness — freshest adapter output older than 7 days (≤30 pts)
11375
+ * 3. low token reduction — avg reduction under threshold (full strategy) (20 pts)
11376
+ * 4. cold-context staleness — hot-cold context-cold.md older than 1 day (≤10 pts)
11377
+ * 5. over-budget rate — >20% of runs exceeded the token budget (20 pts)
11378
+ * 6. sustained over-budget — ≥3 consecutive over-budget runs (5 pts)
11253
11379
  *
11254
- * Grade scale: A 90 | B 75 | C 60 | D < 60
11380
+ * Diagnostics (informational, NOT scored): p50/p95 token count, and
11381
+ * languageCoverage (share of SigMap's supported languages present in the repo —
11382
+ * this is language diversity, not extractor quality, and was previously
11383
+ * mislabeled "extractorCoverage").
11255
11384
  *
11256
- * Never throws returns graceful result with nulls for unavailable metrics.
11385
+ * Freshness looks at the freshest of ANY adapter output (not just Copilot), so a
11386
+ * Claude/Codex/Cursor user is not falsely flagged as "never generated".
11257
11387
  *
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
- * }}
11388
+ * Grade scale: A 90 | B 75 | C ≥ 60 | D < 60. Never throws.
11389
+ *
11390
+ * @param {string} cwd
11391
+ * @returns {object} { score, grade, components, strategy, tokenReductionPct,
11392
+ * daysSinceRegen, strategyFreshnessDays, totalRuns, overBudgetRuns,
11393
+ * overBudgetStreak, languageCoverage, extractorCoverage, diagnostics }
11269
11394
  */
11270
- function score(cwd) {
11271
- const fs = require('fs');
11272
- const path = require('path');
11273
11395
 
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;
11396
+ const fs = require('fs');
11397
+ const path = require('path');
11398
+
11399
+ // Every path a SigMap adapter may write context to (freshness looks at the
11400
+ // freshest existing one). Mirrors the ranker's adapter-output probe order.
11401
+ const CONTEXT_FILES = [
11402
+ ['.github', 'copilot-instructions.md'],
11403
+ ['CLAUDE.md'],
11404
+ ['AGENTS.md'],
11405
+ ['.cursorrules'],
11406
+ ['.windsurfrules'],
11407
+ ['.github', 'openai-context.md'],
11408
+ ['.github', 'gemini-context.md'],
11409
+ ['llm-full.txt'],
11410
+ ['llm.txt'],
11411
+ ];
11412
+
11413
+ function gradeFor(points) {
11414
+ if (points >= 90) return 'A';
11415
+ if (points >= 75) return 'B';
11416
+ if (points >= 60) return 'C';
11417
+ return 'D';
11418
+ }
11419
+
11420
+ /**
11421
+ * Pure scoring core. Given gathered signals, return the score, grade, and the
11422
+ * labeled list of deductions. No IO — unit-testable in isolation.
11423
+ *
11424
+ * @param {object} s gathered signals
11425
+ * @returns {{ score:number, grade:'A'|'B'|'C'|'D', components:Array }}
11426
+ */
11427
+ function composeHealth(s) {
11428
+ const components = [];
11429
+ const add = (id, label, penalty, detail) => {
11430
+ const p = Math.round(penalty);
11431
+ if (p > 0) components.push({ id, label, penalty: p, detail });
11432
+ };
11433
+
11434
+ // 1. Context never generated — a project with source files but no context of
11435
+ // any kind is not "healthy"; it hasn't been set up. Gated on hasSource so
11436
+ // an empty/new directory is not penalised for having nothing to index.
11437
+ if (s.daysSinceRegen === null && s.hasSource) {
11438
+ add('not-generated', 'context never generated', 45,
11439
+ 'no adapter output found — run `sigmap` to generate context');
11440
+ }
11441
+
11442
+ // 2. Staleness — freshest adapter output older than the 7-day window.
11443
+ if (s.daysSinceRegen !== null && s.daysSinceRegen > 7) {
11444
+ add('staleness', 'context stale', Math.min(30, Math.floor((s.daysSinceRegen - 7) * 4)),
11445
+ `${s.daysSinceRegen}d since last regen (>7d)`);
11446
+ }
11447
+
11448
+ // 3. Low token reduction — only meaningful for the 'full' strategy; hot-cold
11449
+ // and per-module intentionally produce small/partial outputs.
11450
+ const reductionThreshold = s.strategy === 'full' ? 60 : 0;
11451
+ if (s.tokenReductionPct !== null && s.tokenReductionPct < reductionThreshold) {
11452
+ add('low-reduction', 'low token reduction', 20,
11453
+ `${s.tokenReductionPct}% avg reduction (<${reductionThreshold}%)`);
11454
+ }
11455
+
11456
+ // 4. Cold-context staleness (hot-cold only).
11457
+ if (s.strategy === 'hot-cold' && s.strategyFreshnessDays !== null && s.strategyFreshnessDays > 1) {
11458
+ add('cold-freshness', 'cold context stale', Math.min(10, Math.floor(s.strategyFreshnessDays - 1) * 3),
11459
+ `context-cold.md ${s.strategyFreshnessDays}d old`);
11460
+ }
11461
+
11462
+ // 5. Over-budget rate.
11463
+ if (s.overBudgetRuns > 0 && s.totalRuns > 0) {
11464
+ const rate = (s.overBudgetRuns / s.totalRuns) * 100;
11465
+ if (rate > 20) add('over-budget', 'runs over budget', 20,
11466
+ `${Math.round(rate)}% of runs exceeded budget (>20%)`);
11467
+ }
11468
+
11469
+ // 6. Sustained over-budget streak — previously computed but never scored.
11470
+ if (s.overBudgetStreak >= 3) {
11471
+ add('over-budget-streak', 'sustained over-budget', 5,
11472
+ `${s.overBudgetStreak} consecutive over-budget runs`);
11473
+ }
11283
11474
 
11284
- // ── Detect active strategy ────────────────────────────────────────────────
11475
+ const penalty = components.reduce((sum, c) => sum + c.penalty, 0);
11476
+ const score = Math.max(0, Math.min(100, 100 - penalty));
11477
+ return { score, grade: gradeFor(score), components };
11478
+ }
11479
+
11480
+ /**
11481
+ * Gather health signals from disk and score them. Never throws.
11482
+ * @param {string} cwd
11483
+ */
11484
+ function score(cwd) {
11285
11485
  let strategy = 'full';
11286
11486
  try {
11287
11487
  const cfgPath = path.join(cwd, 'gen-context.config.json');
11288
11488
  if (fs.existsSync(cfgPath)) {
11289
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
11290
- strategy = cfg.strategy || 'full';
11489
+ strategy = JSON.parse(fs.readFileSync(cfgPath, 'utf8')).strategy || 'full';
11291
11490
  }
11292
11491
  } catch (_) {}
11293
11492
 
11294
- // ── Read usage log via tracking logger ──────────────────────────────────
11493
+ // ── Usage-log signals (only present when tracking has recorded runs) ────────
11494
+ let tokenReductionPct = null;
11495
+ let overBudgetRuns = 0;
11496
+ let totalRuns = 0;
11497
+ let p50TokenCount = 0;
11498
+ let p95TokenCount = 0;
11499
+ let overBudgetStreak = 0;
11295
11500
  try {
11296
11501
  const { readLog, summarize } = __require('./src/tracking/logger');
11297
- const { percentile, overBudgetStreak: calcOverBudgetStreak } = __require('./src/format/dashboard');
11502
+ const { percentile, overBudgetStreak: calcStreak } = __require('./src/format/dashboard');
11298
11503
  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;
11504
+ const sum = summarize(entries);
11505
+ if (sum.totalRuns > 0) tokenReductionPct = sum.avgReductionPct;
11506
+ overBudgetRuns = sum.overBudgetRuns;
11507
+ totalRuns = sum.totalRuns;
11305
11508
  const finals = entries.map((e) => Number(e.finalTokens)).filter(Number.isFinite);
11306
11509
  p50TokenCount = Math.round(percentile(finals, 50));
11307
11510
  p95TokenCount = Math.round(percentile(finals, 95));
11308
- overBudgetStreak = calcOverBudgetStreak(entries);
11309
- } catch (_) {
11310
- // No usage log yet — proceed with nulls
11311
- }
11511
+ overBudgetStreak = calcStreak(entries);
11512
+ } catch (_) {}
11312
11513
 
11514
+ // ── Language coverage (DIAGNOSTIC — share of supported languages present,
11515
+ // i.e. diversity, not extractor quality). Also yields hasSource. ──────────
11516
+ let languageCoverage = null;
11517
+ let hasSource = false;
11313
11518
  try {
11314
11519
  const { computeExtractorCoverage } = __require('./src/format/dashboard');
11315
- extractorCoverage = computeExtractorCoverage(cwd).pct;
11316
- } catch (_) {
11317
- extractorCoverage = 0;
11318
- }
11520
+ const cov = computeExtractorCoverage(cwd);
11521
+ languageCoverage = { covered: cov.covered, supported: cov.supported, pct: cov.pct };
11522
+ hasSource = Object.values(cov.perLanguage || {}).some((n) => n > 0);
11523
+ } catch (_) {}
11319
11524
 
11320
- // ── Days since primary context file was last regenerated ─────────────────
11525
+ // ── Freshness across ALL adapter outputs (freshest wins) ───────────────────
11526
+ let daysSinceRegen = null;
11321
11527
  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));
11528
+ let newest = null;
11529
+ for (const parts of CONTEXT_FILES) {
11530
+ const p = path.join(cwd, ...parts);
11531
+ try {
11532
+ if (fs.existsSync(p)) {
11533
+ const m = fs.statSync(p).mtimeMs;
11534
+ if (newest === null || m > newest) newest = m;
11535
+ }
11536
+ } catch (_) {}
11537
+ }
11538
+ if (newest !== null) {
11539
+ daysSinceRegen = parseFloat(((Date.now() - newest) / (1000 * 60 * 60 * 24)).toFixed(1));
11326
11540
  }
11327
11541
  } catch (_) {}
11328
11542
 
11329
- // ── Strategy freshness: context-cold.md age (hot-cold only) ─────────────
11543
+ // ── Cold-context freshness (hot-cold strategy only) ────────────────────────
11544
+ let strategyFreshnessDays = null;
11330
11545
  if (strategy === 'hot-cold') {
11331
11546
  try {
11332
11547
  const coldFile = path.join(cwd, '.github', 'context-cold.md');
11333
11548
  if (fs.existsSync(coldFile)) {
11334
- const mtime = fs.statSync(coldFile).mtimeMs;
11335
- strategyFreshnessDays = parseFloat(((Date.now() - mtime) / (1000 * 60 * 60 * 24)).toFixed(1));
11549
+ const m = fs.statSync(coldFile).mtimeMs;
11550
+ strategyFreshnessDays = parseFloat(((Date.now() - m) / (1000 * 60 * 60 * 24)).toFixed(1));
11336
11551
  }
11337
11552
  } catch (_) {}
11338
11553
  }
11339
11554
 
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';
11555
+ const { score: points, grade, components } = composeHealth({
11556
+ strategy,
11557
+ daysSinceRegen,
11558
+ strategyFreshnessDays,
11559
+ tokenReductionPct,
11560
+ overBudgetRuns,
11561
+ totalRuns,
11562
+ overBudgetStreak,
11563
+ hasSource,
11564
+ });
11375
11565
 
11376
11566
  return {
11377
11567
  score: points,
11378
11568
  grade,
11569
+ components,
11379
11570
  strategy,
11380
11571
  tokenReductionPct,
11381
11572
  daysSinceRegen,
11382
11573
  strategyFreshnessDays,
11383
11574
  totalRuns,
11384
11575
  overBudgetRuns,
11576
+ overBudgetStreak,
11577
+ languageCoverage,
11578
+ // Back-compat top-level fields (also surfaced, honestly grouped, under
11579
+ // `diagnostics`). `extractorCoverage` keeps its old name but its value is
11580
+ // language-diversity pct (never extractor quality) — prefer `languageCoverage`.
11385
11581
  p50TokenCount,
11386
11582
  p95TokenCount,
11387
- overBudgetStreak,
11388
- extractorCoverage,
11583
+ extractorCoverage: languageCoverage ? languageCoverage.pct : 0,
11584
+ diagnostics: { p50TokenCount, p95TokenCount, languageCoverage },
11389
11585
  };
11390
11586
  }
11391
11587
 
11392
- module.exports = { score };
11588
+ module.exports = { score, composeHealth };
11393
11589
 
11394
11590
  };
11395
11591
 
@@ -11462,6 +11658,7 @@ __factories["./src/judge/judge-engine"] = function(module, exports) {
11462
11658
  const fs = require('fs');
11463
11659
  const path = require('path');
11464
11660
  const { boostFiles, normalizeFile, penalizeFiles } = __require('./src/learning/weights');
11661
+ const parsers = __require('./src/verify/parsers');
11465
11662
 
11466
11663
  const STOP = new Set([
11467
11664
  'the','a','an','in','on','at','to','of','for','and','or','but',
@@ -11484,6 +11681,57 @@ __factories["./src/judge/judge-engine"] = function(module, exports) {
11484
11681
  return parseFloat((matched.length / respTokens.length).toFixed(3));
11485
11682
  }
11486
11683
 
11684
+ /**
11685
+ * Claim-level grounding (v8.10) — the structural half of the judge.
11686
+ *
11687
+ * `groundedness` above measures lexical *word* overlap: "does the answer reuse
11688
+ * context vocabulary?" That is a weak proxy — an answer can echo context words
11689
+ * while asserting a symbol, file, or import the context never mentions (a
11690
+ * hallucination), and still score high. This function extracts the answer's
11691
+ * *concrete, checkable claims* — the same high-precision claims the hallucination
11692
+ * guard checks (backtick-wrapped `foo()` calls, `path/to/file.ext` references,
11693
+ * and `import … from 'mod'` statements) — and verifies each one appears in the
11694
+ * provided context. A claim the context never grounds is a hallucination signal
11695
+ * that pure word-overlap cannot see.
11696
+ *
11697
+ * Deterministic, offline, zero-dependency. Reuses `src/verify/parsers`.
11698
+ *
11699
+ * @param {string} response
11700
+ * @param {string} context
11701
+ * @returns {{ total: number, grounded: number, ungrounded: Array<{kind:string, value:string}> }}
11702
+ */
11703
+ function claimGrounding(response, context) {
11704
+ if (!response || !context) return { total: 0, grounded: 0, ungrounded: [] };
11705
+ const ctxLower = context.toLowerCase();
11706
+
11707
+ const raw = [];
11708
+ for (const s of parsers.extractSymbols(response)) raw.push({ kind: 'symbol', value: s.name });
11709
+ for (const f of parsers.extractFilePaths(response)) raw.push({ kind: 'file', value: f.path });
11710
+ for (const i of parsers.extractImports(response)) raw.push({ kind: 'import', value: i.module });
11711
+
11712
+ const seen = new Set();
11713
+ const claims = raw.filter((c) => {
11714
+ const key = `${c.kind}::${c.value}`;
11715
+ if (seen.has(key)) return false;
11716
+ seen.add(key);
11717
+ return true;
11718
+ });
11719
+
11720
+ const ungrounded = [];
11721
+ let grounded = 0;
11722
+ for (const c of claims) {
11723
+ // A file claim is grounded if its basename appears in context (the answer
11724
+ // may cite a different directory than the map records). Symbols and modules
11725
+ // are matched on the token itself.
11726
+ const needle = c.value.toLowerCase();
11727
+ const base = c.kind === 'file' ? (c.value.split('/').pop() || c.value).toLowerCase() : needle;
11728
+ if (ctxLower.includes(base) || ctxLower.includes(needle)) grounded++;
11729
+ else ungrounded.push({ kind: c.kind, value: c.value });
11730
+ }
11731
+
11732
+ return { total: claims.length, grounded, ungrounded };
11733
+ }
11734
+
11487
11735
  const GENERIC_MARKERS = [
11488
11736
  'however, based on my knowledge',
11489
11737
  'generally speaking',
@@ -11535,8 +11783,16 @@ __factories["./src/judge/judge-engine"] = function(module, exports) {
11535
11783
  }
11536
11784
  }
11537
11785
 
11786
+ // Structural claim grounding: any concrete symbol/file/import the answer
11787
+ // states that the context never mentions is a hallucination the lexical
11788
+ // score above cannot detect. Each ungrounded claim fails the verdict.
11789
+ const claims = claimGrounding(response, context);
11790
+ for (const c of claims.ungrounded) {
11791
+ reasons.push(`${c.kind} claim not grounded in context: ${c.value}${c.kind === 'symbol' ? '()' : ''}`);
11792
+ }
11793
+
11538
11794
  const verdict = score >= threshold && reasons.length === 0 ? 'pass' : 'fail';
11539
- const result = { score, verdict, reasons };
11795
+ const result = { score, verdict, reasons, claims };
11540
11796
 
11541
11797
  if (opts.learn) {
11542
11798
  const learning = {
@@ -11578,7 +11834,7 @@ __factories["./src/judge/judge-engine"] = function(module, exports) {
11578
11834
  return result;
11579
11835
  }
11580
11836
 
11581
- module.exports = { groundedness, judge };
11837
+ module.exports = { groundedness, claimGrounding, judge };
11582
11838
 
11583
11839
  };
11584
11840
 
@@ -13694,7 +13950,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
13694
13950
 
13695
13951
  const SERVER_INFO = {
13696
13952
  name: 'sigmap',
13697
- version: '8.9.0',
13953
+ version: '8.10.0',
13698
13954
  description: 'SigMap MCP server — code signatures on demand',
13699
13955
  };
13700
13956
 
@@ -14292,7 +14548,7 @@ __factories["./src/plan/planner"] = function(module, exports) {
14292
14548
 
14293
14549
  module.exports = { createPlan };
14294
14550
 
14295
- function createPlan(goal, cwd, config) {
14551
+ function createPlan(goal, cwd, config = {}) {
14296
14552
  // Step 1: Detect intent and rank files for the goal
14297
14553
  const intent = detectIntent(goal);
14298
14554
  const sigIndex = buildSigIndex(cwd);
@@ -14306,30 +14562,59 @@ __factories["./src/plan/planner"] = function(module, exports) {
14306
14562
  const highConf = ranked.filter(r => r.confidence === 'high').slice(0, 5);
14307
14563
  const medConf = ranked.filter(r => r.confidence === 'medium').slice(0, 5);
14308
14564
 
14309
- // Step 3: Compute impact radius for highest-confidence file
14565
+ // Step 3: Impact radius union the reverse-dependency blast radius of EVERY
14566
+ // high-confidence file (not just the top one), bounded to 3 hops. Note the
14567
+ // dependency graph resolves relative imports only, so this is a *lower bound*
14568
+ // on real coupling (aliased/bare/dynamic imports are invisible). Previously
14569
+ // this passed `{ maxDepth: 3 }`, which getImpact ignores — it reads `depth`,
14570
+ // so the traversal silently ran unbounded (depth 0). Fixed to `depth: 3`.
14310
14571
  let impact = null;
14311
14572
  if (highConf.length > 0) {
14312
- const entryFile = highConf[0].file;
14313
14573
  try {
14314
14574
  const graph = buildFromCwd(cwd);
14315
- impact = getImpact(entryFile, graph, { maxDepth: 3, cwd });
14575
+ // getImpact normalizes graph paths to lowercase, so on a case-varying
14576
+ // filesystem (e.g. macOS `/Users`) its returned paths climb out of cwd.
14577
+ // Re-anchor every impacted path to a clean, case-insensitive repo-relative
14578
+ // form so dedup against the entry set works and output is readable.
14579
+ const clean = (f) => {
14580
+ const abs = path.resolve(cwd, f);
14581
+ return abs.toLowerCase().startsWith(cwd.toLowerCase())
14582
+ ? abs.slice(cwd.length).replace(/^[/\\]/, '')
14583
+ : path.relative(cwd, abs);
14584
+ };
14585
+ const entrySet = new Set(highConf.map(r => r.file));
14586
+ const direct = new Set();
14587
+ const transitive = new Set();
14588
+ for (const r of highConf) {
14589
+ const imp = getImpact(r.file, graph, { depth: 3, cwd });
14590
+ for (const f of (imp.direct || [])) direct.add(clean(f));
14591
+ for (const f of (imp.transitive || [])) transitive.add(clean(f));
14592
+ }
14593
+ // The files we plan to change are not their own blast radius; and a file
14594
+ // reached directly from one entry outranks a transitive reach from another.
14595
+ for (const e of entrySet) { direct.delete(e); transitive.delete(e); }
14596
+ for (const f of direct) transitive.delete(f);
14597
+ impact = { direct: [...direct], transitive: [...transitive] };
14316
14598
  } catch (_) {
14317
14599
  // Graph build failed, continue without impact
14318
14600
  }
14319
14601
  }
14320
14602
 
14321
- // Step 4: Identify likely-affected tests
14322
- let testedFiles = [];
14603
+ // Step 4: Flag which files-to-inspect have detectable test coverage. The test
14604
+ // index maps test-*name tokens*, not test files, so `isTested` can only tell
14605
+ // us a source file is covered — it cannot name the test file. We therefore
14606
+ // report the covered SOURCE files honestly rather than pretending to list the
14607
+ // tests to run.
14608
+ let coveredFiles = [];
14323
14609
  try {
14324
14610
  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 => {
14611
+ coveredFiles = highConf.filter(r => {
14612
+ const fnNames = (r.sigs || []).map(s => {
14328
14613
  const m = s.match(/(?:function|def|fn)\s+(\w+)/);
14329
14614
  return m ? m[1] : null;
14330
14615
  }).filter(Boolean);
14331
14616
  return fnNames.some(fn => isTested(fn, testIndex));
14332
- });
14617
+ }).map(r => r.file);
14333
14618
  } catch (_) {
14334
14619
  // Coverage index failed, continue without test info
14335
14620
  }
@@ -14339,11 +14624,11 @@ __factories["./src/plan/planner"] = function(module, exports) {
14339
14624
  intent,
14340
14625
  inspectFirst: highConf.map(r => r.file),
14341
14626
  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),
14627
+ impactRadius: impact,
14628
+ coveredFiles,
14629
+ // `testsAffected` retained for backward compatibility; it is the set of
14630
+ // covered source files, NOT the test files (which the index cannot name).
14631
+ testsAffected: coveredFiles,
14347
14632
  };
14348
14633
  }
14349
14634
 
@@ -15392,7 +15677,8 @@ __factories["./src/review/pr-evidence"] = function(module, exports) {
15392
15677
  L.push('### Review findings');
15393
15678
  for (const f of evidence.review.findings) {
15394
15679
  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}\``);
15680
+ else if (f.type === 'security-file') L.push(`- ⚠️ **sensitive path touched** (path heuristic, not a content scan) — \`${f.file}\``);
15681
+ else if (f.type === 'secret-detected') L.push(`- 🔑 **secret detected** (${f.secret}) — \`${f.file}\``);
15396
15682
  else if (f.type === 'god-node') L.push(`- ⚠️ **god node** — \`${f.file}\` → ${f.count} dependents (high blast radius)`);
15397
15683
  else if (f.type === 'scope-drift') L.push(`- ⚠️ **scope drift** — ${f.count} top-level dirs touched (${f.dirs.join(', ')})`);
15398
15684
  }
@@ -15447,8 +15733,10 @@ __factories["./src/review/review-pr"] = function(module, exports) {
15447
15733
  * zero-dependency, bundle-safe; reuses the impact graph for blast radius.
15448
15734
  */
15449
15735
 
15736
+ const fs = require('fs');
15450
15737
  const path = require('path');
15451
15738
  const { analyzeImpact } = __require('./src/graph/impact');
15739
+ const { PATTERNS } = __require('./src/security/patterns');
15452
15740
 
15453
15741
  const SECURITY_PATTERNS = [
15454
15742
  /(^|\/)\.env(\.|$)/i,
@@ -15499,10 +15787,30 @@ __factories["./src/review/review-pr"] = function(module, exports) {
15499
15787
  if (!covered) findings.push({ type: 'missing-tests', file: s, severity: 'warn' });
15500
15788
  }
15501
15789
 
15502
- // 2. Security-sensitive files.
15790
+ // 2a. Sensitive-path heuristic — flags files whose PATH looks security-relevant
15791
+ // (.env, auth/, lockfiles, workflows, key material). This is a path heuristic,
15792
+ // NOT a content scan: it flags touching the path regardless of what changed,
15793
+ // and cannot see a secret hidden in an innocently-named file. `basis` records
15794
+ // that honestly so consumers don't mistake it for a content check.
15503
15795
  for (const f of live) {
15504
15796
  if (SECURITY_PATTERNS.some((re) => re.test(f.path))) {
15505
- findings.push({ type: 'security-file', file: f.path, severity: 'warn' });
15797
+ findings.push({ type: 'security-file', file: f.path, severity: 'warn', basis: 'path-heuristic' });
15798
+ }
15799
+ }
15800
+
15801
+ // 2b. Real secret scan — read each changed file's CONTENT and match known
15802
+ // secret patterns. This is the actual security check (content, not filename):
15803
+ // it catches a hardcoded key in a file the path heuristic would never flag.
15804
+ const readFile = opts.readFile || ((p) => fs.readFileSync(path.resolve(cwd, p), 'utf8'));
15805
+ for (const f of live) {
15806
+ let content;
15807
+ try { content = readFile(f.path); } catch (_) { continue; } // absent/unreadable → skip
15808
+ if (typeof content !== 'string' || content.length > 2_000_000) continue; // skip huge/binary
15809
+ for (const pat of PATTERNS) {
15810
+ if (pat.regex.test(content)) {
15811
+ findings.push({ type: 'secret-detected', file: f.path, secret: pat.name, severity: 'high', basis: 'content-scan' });
15812
+ break; // one hit is enough to flag the file
15813
+ }
15506
15814
  }
15507
15815
  }
15508
15816
 
@@ -17122,6 +17430,52 @@ __factories["./src/util/git"] = function(module, exports) {
17122
17430
 
17123
17431
  };
17124
17432
 
17433
+ // ── ./src/util/truncate ──
17434
+ __factories["./src/util/truncate"] = function(module, exports) {
17435
+
17436
+ /**
17437
+ * Visible truncation for extractor caps (v8.11).
17438
+ *
17439
+ * Extractors cap per-file signatures and per-class members to protect the token
17440
+ * budget. Historically that truncation was SILENT — the tail of a large file
17441
+ * simply vanished with no trace, so "5 of 40 methods extracted" looked identical
17442
+ * to "fully extracted". These helpers keep the cap but append a visible marker
17443
+ * so the loss is always disclosed.
17444
+ *
17445
+ * Zero-dependency, bundle-safe.
17446
+ */
17447
+
17448
+ /**
17449
+ * Cap a string array, appending a `… +N more <label>` marker when items drop.
17450
+ * @param {string[]} items
17451
+ * @param {number} limit
17452
+ * @param {string} label e.g. 'signatures'
17453
+ * @returns {string[]}
17454
+ */
17455
+ function capWithNotice(items, limit, label) {
17456
+ if (!Array.isArray(items) || items.length <= limit) return items;
17457
+ const dropped = items.length - limit;
17458
+ return items.slice(0, limit).concat(`… +${dropped} more ${label}`);
17459
+ }
17460
+
17461
+ /**
17462
+ * Cap an array of member objects ({ text, ... }), appending a marker member
17463
+ * when items drop so the class block discloses the omission.
17464
+ * @param {Array<{text:string}>} members
17465
+ * @param {number} limit
17466
+ * @param {string} [label='methods']
17467
+ * @returns {Array<{text:string}>}
17468
+ */
17469
+ function capMembersWithNotice(members, limit, label = 'methods') {
17470
+ if (!Array.isArray(members) || members.length <= limit) return members;
17471
+ const dropped = members.length - limit;
17472
+ return members.slice(0, limit).concat({ text: `… +${dropped} more ${label}`, start: 0, end: 0 });
17473
+ }
17474
+
17475
+ module.exports = { capWithNotice, capMembersWithNotice };
17476
+
17477
+ };
17478
+
17125
17479
  // ── ./src/verify/closest-match ──
17126
17480
  __factories["./src/verify/closest-match"] = function(module, exports) {
17127
17481
 
@@ -18286,7 +18640,7 @@ function __tryGit(args, opts = {}) {
18286
18640
  catch (_) { return ''; }
18287
18641
  }
18288
18642
 
18289
- const VERSION = '8.9.0';
18643
+ const VERSION = '8.10.0';
18290
18644
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
18291
18645
 
18292
18646
  function requireSourceOrBundled(key) {
@@ -20292,15 +20646,9 @@ function registerMcp(cwd, scriptPath) {
20292
20646
  // ---------------------------------------------------------------------------
20293
20647
  // v4.2 helpers
20294
20648
  // ---------------------------------------------------------------------------
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
- };
20649
+ // Pricing lives in src/tracking/pricing.js (the verified, single-source table
20650
+ // shared with the `gain` dashboard). The old inline MODEL_COSTS table was
20651
+ // removed — it disagreed with pricing.js (e.g. gpt-4o at $5 vs $2.50 /Mtok).
20304
20652
 
20305
20653
  function buildMiniContext(ranked, cwd) {
20306
20654
  const lines = ['# SigMap Query Context', `Generated: ${new Date().toISOString()}`, ''];
@@ -20671,8 +21019,14 @@ function main() {
20671
21019
  }
20672
21020
  if (rawTok === 0) rawTok = getRawTokenCount(cwd, config);
20673
21021
  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'];
21022
+ const __mIdx = args.indexOf('--model');
21023
+ const model = (__mIdx !== -1 && args[__mIdx + 1] && !args[__mIdx + 1].startsWith('--')) ? args[__mIdx + 1] : 'gpt-4o';
21024
+ // Single source of truth for pricing — the verified pricing.js table, shared
21025
+ // with the `gain` dashboard (previously an inline MODEL_COSTS table disagreed
21026
+ // with it, e.g. gpt-4o priced at $5/Mtok here vs $2.50/Mtok there).
21027
+ const { resolvePrice: __resolvePrice } = requireSourceOrBundled('./src/tracking/pricing');
21028
+ const __price = __resolvePrice(model);
21029
+ const rateK = __price.perMtok / 1000; // USD per 1K tokens
20676
21030
  const costRaw = ((rawTok / 1000) * rateK).toFixed(4);
20677
21031
  const costCtx = ((ctxTok / 1000) * rateK).toFixed(4);
20678
21032
 
@@ -20682,6 +21036,8 @@ function main() {
20682
21036
  process.stdout.write(JSON.stringify({
20683
21037
  intent, coverage: coveragePct, contextTokens: ctxTok,
20684
21038
  costBefore: costRaw, costAfter: costCtx, savingsPct: savings,
21039
+ pricedModel: __price.model,
21040
+ costBasis: 'estimate — counterfactual = full content of ranked files; input tokens only',
20685
21041
  riskLevel, contextPath: path.relative(cwd, outPath),
20686
21042
  }) + '\n');
20687
21043
  } else {
@@ -20697,6 +21053,7 @@ function main() {
20697
21053
  ` Coverage : ${coveragePct}%`,
20698
21054
  ` Risk : ${riskLevel}`,
20699
21055
  ` Cost : $${costCtx}/query (was $${costRaw} · saved ${savings}%)`,
21056
+ ` ${' '.repeat(9)} est. @ ${__price.model} $${__price.perMtok}/Mtok input; "was" = full ranked files`,
20700
21057
  bar,
20701
21058
  ].join('\n'));
20702
21059
  }
@@ -20825,21 +21182,57 @@ function main() {
20825
21182
  process.exit(0);
20826
21183
  }
20827
21184
 
20828
- // v4.2: `sigmap suggest-profile` — auto-detect task type from git state
21185
+ // v4.2: `sigmap suggest-profile` — infer the task profile from the actual
21186
+ // staged CHANGES (test ratio, breadth, doc/config mix), not just one
21187
+ // commit-message keyword. The commit message is only a secondary disambiguator
21188
+ // for source-focused edits, and the sole (weak) signal when nothing is staged.
20829
21189
  if (args[0] === 'suggest-profile') {
20830
21190
  const short = args.includes('--short');
20831
- let msg = '', diff = '';
21191
+ let msg = '', diffRaw = '';
20832
21192
  try {
20833
- msg = __git(['log', '-1', '--format=%s'], { cwd, timeout: 3000 }).trim();
20834
- diff = __git(['diff', '--cached', '--name-only'], { cwd, timeout: 3000 });
21193
+ msg = __git(['log', '-1', '--format=%s'], { cwd, timeout: 3000 }).trim();
21194
+ diffRaw = __git(['diff', '--cached', '--name-only'], { cwd, timeout: 3000 });
20835
21195
  } catch (_) {}
20836
21196
 
21197
+ const files = diffRaw.split('\n').map((s) => s.trim()).filter(Boolean);
21198
+ const isTest = (f) => /\.(test|spec)\.[jt]sx?$|(^|\/)test_|_test\.(py|go)$|(^|\/)(tests?|__tests__|spec)\//i.test(f);
21199
+ const isSrc = (f) => /\.(js|jsx|ts|tsx|mjs|cjs|py|go|rs|java|rb|php)$/i.test(f) && !isTest(f);
21200
+ const isDocCfg = (f) => /\.(md|json|ya?ml|toml|cfg|ini)$/i.test(f) || /(^|\/)\.github\//i.test(f);
21201
+ const fixMsg = /fix|bug|error|crash|exception/i.test(msg);
21202
+ const archMsg = /refactor|architect|redesign|module/i.test(msg);
21203
+
21204
+ const testN = files.filter(isTest).length;
21205
+ const srcN = files.filter(isSrc).length;
21206
+ const docCfgN = files.filter(isDocCfg).length;
21207
+ const dirs = new Set(files.map((f) => (f.includes('/') ? f.split('/')[0] : '.')));
21208
+
20837
21209
  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'; }
21210
+ let reason;
21211
+ if (files.length === 0) {
21212
+ if (fixMsg) { profile = 'debug'; reason = `no staged files; commit hint: "${msg.slice(0, 50)}"`; }
21213
+ else if (archMsg) { profile = 'architecture'; reason = `no staged files; commit hint: "${msg.slice(0, 50)}"`; }
21214
+ else if (/review|pr|pull.request|check/i.test(msg)) { profile = 'review'; reason = `no staged files; commit hint: "${msg.slice(0, 50)}"`; }
21215
+ else { reason = 'no staged files and no strong commit-message signal'; }
21216
+ } else if (dirs.size >= 4) {
21217
+ profile = 'architecture';
21218
+ reason = `changes span ${dirs.size} top-level dirs (${[...dirs].slice(0, 4).join(', ')}…)`;
21219
+ } else if (testN > 0 && testN >= srcN) {
21220
+ profile = 'debug';
21221
+ reason = `${testN} test file(s) staged (≥ ${srcN} source) — test-driven change`;
21222
+ } else if (srcN === 0 && docCfgN > 0) {
21223
+ profile = 'review';
21224
+ reason = `only docs/config staged (${docCfgN} file(s)) — meta change`;
21225
+ } else if (srcN > 0 && archMsg) {
21226
+ profile = 'architecture';
21227
+ reason = `${srcN} source file(s) + refactor-style commit`;
21228
+ } else if (srcN > 0 && fixMsg) {
21229
+ profile = 'debug';
21230
+ reason = `${srcN} source file(s) + fix-style commit`;
21231
+ } else if (srcN > 0) {
21232
+ reason = `${srcN} source file(s) staged, no strong task signal`;
21233
+ } else {
21234
+ reason = 'staged changes do not match a known task profile';
21235
+ }
20843
21236
 
20844
21237
  if (short) {
20845
21238
  console.log(profile);
@@ -20939,61 +21332,57 @@ function main() {
20939
21332
  process.exit(1);
20940
21333
  }
20941
21334
 
20942
- const { detectIntent, buildSigIndex, rank } = requireSourceOrBundled('./src/retrieval/ranker');
20943
-
20944
- const intent = detectIntent(goal);
20945
- const intentWeights = getIntentWeights(intent);
21335
+ const { createPlan } = requireSourceOrBundled('./src/plan/planner');
21336
+ const plan = createPlan(goal, cwd, config);
20946
21337
 
20947
- const sigIndex = buildSigIndex(cwd);
20948
- if (sigIndex.size === 0) {
21338
+ if (plan.error) {
20949
21339
  console.error('[sigmap] no context file found. Run: sigmap (to generate first)');
20950
21340
  process.exit(1);
20951
21341
  }
20952
21342
 
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));
21343
+ const relOf = (f) => path.relative(cwd, path.isAbsolute(f) ? f : path.join(cwd, f));
21344
+ const impact = plan.impactRadius;
20964
21345
 
20965
21346
  if (args.includes('--json')) {
20966
21347
  process.stdout.write(JSON.stringify({
20967
- goal, intent,
20968
- inspectFirst: highConf.map(r => r.file),
20969
- likelyToChange: medConf.map(r => r.file),
21348
+ goal: plan.goal,
21349
+ intent: plan.intent,
21350
+ inspectFirst: plan.inspectFirst,
21351
+ likelyToChange: plan.likelyToChange,
20970
21352
  impactRadius: impact,
20971
- testsAffected: testedFiles.map(r => r.file),
21353
+ coveredFiles: plan.coveredFiles,
21354
+ testsAffected: plan.testsAffected,
20972
21355
  }, null, 2) + '\n');
20973
21356
  } else {
20974
21357
  const bar = '─'.repeat(50);
20975
21358
  console.log(bar);
20976
21359
  console.log(` sigmap plan "${goal}"`);
20977
- console.log(` Intent : ${intent}`);
21360
+ console.log(` Intent : ${plan.intent}`);
20978
21361
  console.log(bar);
20979
21362
  console.log('');
20980
21363
  console.log(' Inspect first (highest relevance):');
20981
- if (highConf.length === 0) {
21364
+ if (plan.inspectFirst.length === 0) {
20982
21365
  console.log(' (no files found)');
20983
21366
  } else {
20984
- highConf.forEach((r, i) => console.log(` ${i + 1}. ${path.relative(cwd, r.file)}`));
21367
+ plan.inspectFirst.forEach((f, i) => console.log(` ${i + 1}. ${relOf(f)}`));
20985
21368
  }
20986
21369
  console.log('');
20987
21370
  console.log(' Likely to change:');
20988
- if (medConf.length === 0) {
21371
+ if (plan.likelyToChange.length === 0) {
20989
21372
  console.log(' (no files found)');
20990
21373
  } else {
20991
- medConf.forEach((r, i) => console.log(` ${i + 1}. ${path.relative(cwd, r.file)}`));
21374
+ plan.likelyToChange.forEach((f, i) => console.log(` ${i + 1}. ${relOf(f)}`));
20992
21375
  }
20993
- if (testedFiles.length > 0) {
21376
+ if (impact && (impact.direct.length || impact.transitive.length)) {
20994
21377
  console.log('');
20995
- console.log(' Tests to run after change:');
20996
- testedFiles.forEach(r => console.log(` • ${path.relative(cwd, r.file)}`));
21378
+ console.log(' Impact radius (relative-import dependents, ≤3 hops — lower bound):');
21379
+ impact.direct.forEach(f => console.log(` • ${relOf(f)} (direct)`));
21380
+ impact.transitive.forEach(f => console.log(` • ${relOf(f)} (transitive)`));
21381
+ }
21382
+ if (plan.coveredFiles.length > 0) {
21383
+ console.log('');
21384
+ console.log(' Files with test coverage (re-run their suites after changing):');
21385
+ plan.coveredFiles.forEach(f => console.log(` • ${relOf(f)}`));
20997
21386
  }
20998
21387
  console.log('');
20999
21388
  console.log(bar);
@@ -21151,18 +21540,18 @@ function main() {
21151
21540
  }
21152
21541
 
21153
21542
  if (entries.length === 0) {
21154
- console.log('[sigmap] No learned weights yet. Run: sigmap learn --good <file>');
21543
+ console.log('[sigmap] No manual weights set. Run: sigmap learn --good <file> to boost a file.');
21155
21544
  process.exit(0);
21156
21545
  }
21157
21546
 
21158
- console.log('[sigmap] Learned file weights (xmultiplier vs baseline):');
21547
+ console.log('[sigmap] Manual file weights boost/penalty multipliers vs baseline:');
21159
21548
  for (const [file, mult] of entries) {
21160
21549
  const bar = mult >= 1
21161
21550
  ? `+${'█'.repeat(Math.max(1, Math.round((mult - 1) * 10)))}`
21162
21551
  : `-${'░'.repeat(Math.max(1, Math.round((1 - mult) * 10)))}`;
21163
21552
  console.log(` ${file.padEnd(50)} x${mult.toFixed(2)} ${bar}`);
21164
21553
  }
21165
- console.log(`\n Total files with learned weights: ${entries.length}`);
21554
+ console.log(`\n ${entries.length} file(s) manually boosted/penalized (set via \`sigmap learn\`; decays toward 1.0 over time — not automatic learning).`);
21166
21555
  console.log(' To reset: sigmap learn --reset');
21167
21556
  process.exit(0);
21168
21557
  }
@@ -21192,7 +21581,17 @@ function main() {
21192
21581
  if (coveragePct < 70)
21193
21582
  warnings.push(`coverage ${coveragePct}% is below recommended 70% — increase maxTokens or expand srcDirs`);
21194
21583
 
21195
- // Optional query symbol check
21584
+ // Optional query check. Two complementary signals:
21585
+ // (a) cased-symbol coverage — if the query literally names a camelCase /
21586
+ // PascalCase symbol (loginUser, AuthMiddleware), confirm it lands in
21587
+ // the top-5. This is a no-op for lowercase natural-language queries.
21588
+ // (b) retrieval-confidence report — works for ANY query, including plain
21589
+ // NL like "login rate limit". Built from the ranker's own score
21590
+ // distribution: a zero top score means the current context has no
21591
+ // lexical match at all; a near-tie between rank-1 and rank-2 means the
21592
+ // ranking is flat and coverage is ambiguous. This replaces the old
21593
+ // behaviour where an NL query silently produced no output whatsoever.
21594
+ let queryReport = null;
21196
21595
  const valQueryIdx = args.indexOf('--query');
21197
21596
  if (valQueryIdx !== -1) {
21198
21597
  const q = (args[valQueryIdx + 1] || '').trim();
@@ -21200,6 +21599,8 @@ function main() {
21200
21599
  try {
21201
21600
  const { rank, buildSigIndex } = requireSourceOrBundled('./src/retrieval/ranker');
21202
21601
  const ranked = rank(q, buildSigIndex(cwd), { topK: 5, cwd });
21602
+
21603
+ // (a) cased-symbol coverage
21203
21604
  const symbols = extractQuerySymbols(q);
21204
21605
  const missing = symbols.filter((sym) =>
21205
21606
  !ranked.some((r) => r.sigs && r.sigs.some((s) => s.toLowerCase().includes(sym.toLowerCase())))
@@ -21208,12 +21609,38 @@ function main() {
21208
21609
  warnings.push(`query "${q}" references symbols not in top-5 context: ${missing.join(', ')}`);
21209
21610
  else if (symbols.length > 0)
21210
21611
  console.log(`[sigmap] ✓ query coverage OK — all ${symbols.length} symbols found`);
21612
+
21613
+ // (b) retrieval-confidence report
21614
+ const top = ranked[0] || null;
21615
+ const topScore = top ? (top.score || 0) : 0;
21616
+ const secondScore = ranked[1] ? (ranked[1].score || 0) : 0;
21617
+ const gapRatio = topScore > 0 ? (topScore - secondScore) / topScore : 0;
21618
+ let queryConfidence;
21619
+ if (topScore <= 0) queryConfidence = 'none';
21620
+ else if (ranked.length > 1 && gapRatio < 0.1) queryConfidence = 'low';
21621
+ else queryConfidence = (top && top.confidence) || 'medium';
21622
+
21623
+ queryReport = {
21624
+ text: q,
21625
+ topFile: top ? top.file : null,
21626
+ topScore: parseFloat(topScore.toFixed(3)),
21627
+ confidence: queryConfidence,
21628
+ };
21629
+
21630
+ if (queryConfidence === 'none')
21631
+ warnings.push(`query "${q}" has no lexical match in the current context — expand srcDirs or raise maxTokens`);
21632
+ else if (queryConfidence === 'low')
21633
+ warnings.push(`query "${q}" ranks flat (top ${top.file} score ${queryReport.topScore}, no dominant match) — context may not cover it well`);
21634
+ else if (!args.includes('--json'))
21635
+ console.log(`[sigmap] ✓ query "${q}" → ${top.file} (score ${queryReport.topScore}, confidence ${queryConfidence})`);
21211
21636
  } catch (_) {}
21212
21637
  }
21213
21638
  }
21214
21639
 
21215
21640
  if (args.includes('--json')) {
21216
- process.stdout.write(JSON.stringify({ valid: issues.length === 0, issues, warnings, coverage: coveragePct }) + '\n');
21641
+ const payload = { valid: issues.length === 0, issues, warnings, coverage: coveragePct };
21642
+ if (queryReport) payload.query = queryReport;
21643
+ process.stdout.write(JSON.stringify(payload) + '\n');
21217
21644
  } else {
21218
21645
  for (const w of warnings) console.warn(`[sigmap] ⚠ ${w}`);
21219
21646
  if (issues.length === 0) {
@@ -22163,10 +22590,11 @@ function main() {
22163
22590
  console.log(' ✓ no findings — scope, tests, blast radius, and sensitive files all clear');
22164
22591
  process.exit(0);
22165
22592
  }
22166
- const label = { 'missing-tests': 'missing tests', 'security-file': 'security file', 'god-node': 'god node', 'scope-drift': 'scope drift' };
22593
+ 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
22594
  for (const f of result.findings) {
22168
22595
  if (f.type === 'missing-tests') console.log(` ⚠ ${label[f.type]}: ${f.file} changed with no matching test`);
22169
22596
  else if (f.type === 'security-file') console.log(` ⚠ ${label[f.type]}: ${f.file}`);
22597
+ else if (f.type === 'secret-detected') console.log(` ✗ ${label[f.type]}: ${f.secret} in ${f.file}`);
22170
22598
  else if (f.type === 'god-node') console.log(` ⚠ ${label[f.type]}: ${f.file} → ${f.count} dependents`);
22171
22599
  else if (f.type === 'scope-drift') console.log(` ⚠ ${label[f.type]}: ${f.count} top-level dirs (${f.dirs.join(', ')})`);
22172
22600
  }
@@ -22228,7 +22656,7 @@ function main() {
22228
22656
  process.exit(result.summary.ok ? 0 : 1);
22229
22657
  }
22230
22658
 
22231
- console.log(`[sigmap] create${result.task ? ` "${result.task}"` : ''} — grounded-creation pipeline`);
22659
+ 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
22660
  for (const st of result.steps) {
22233
22661
  const mark = st.skipped ? '–' : (st.ok ? '✓' : '✗');
22234
22662
  const status = st.skipped ? `skipped (${st.reason})` : (st.ok ? 'ok' : 'FAILED');
@@ -22874,8 +23302,12 @@ function main() {
22874
23302
  const rawTok = getRawTokenCount(cwd, config);
22875
23303
  runGenerate(cwd, config, false);
22876
23304
 
22877
- const model = args[args.indexOf('--model') + 1] || 'gpt-4o';
22878
- const rateK = MODEL_COSTS[model] || MODEL_COSTS['gpt-4o'];
23305
+ const __mIdxCost = args.indexOf('--model');
23306
+ const model = (__mIdxCost !== -1 && args[__mIdxCost + 1] && !args[__mIdxCost + 1].startsWith('--')) ? args[__mIdxCost + 1] : 'gpt-4o';
23307
+ // Single source of truth for pricing — shared with `gain` (pricing.js).
23308
+ const { resolvePrice: __resolvePriceCost } = requireSourceOrBundled('./src/tracking/pricing');
23309
+ const __priceCost = __resolvePriceCost(model);
23310
+ const rateK = __priceCost.perMtok / 1000; // USD per 1K tokens
22879
23311
 
22880
23312
  const ctxPath = config.customOutput
22881
23313
  ? path.resolve(cwd, config.customOutput)
@@ -22888,19 +23320,20 @@ function main() {
22888
23320
  const costCtx = (outTok / 1000) * rateK;
22889
23321
 
22890
23322
  const out = {
22891
- model,
23323
+ model: __priceCost.model,
22892
23324
  rawTokens: rawTok,
22893
23325
  contextTokens: outTok,
22894
23326
  costRaw: costRaw.toFixed(4),
22895
23327
  costContext: costCtx.toFixed(4),
22896
23328
  savingsPct: savings,
23329
+ costBasis: 'estimate — counterfactual = whole-repo tokens; input tokens only',
22897
23330
  };
22898
23331
 
22899
23332
  if (args.includes('--json')) {
22900
23333
  process.stdout.write(JSON.stringify(out) + '\n');
22901
23334
  } else {
22902
- console.log(`\n Cost estimate (${model}):`);
22903
- console.log(` Without SigMap : ${rawTok.toLocaleString()} tok $${out.costRaw}/query`);
23335
+ console.log(`\n Cost estimate (${__priceCost.model} @ $${__priceCost.perMtok}/Mtok input, est.):`);
23336
+ console.log(` Without SigMap : ${rawTok.toLocaleString()} tok $${out.costRaw}/query (counterfactual: whole repo)`);
22904
23337
  console.log(` With SigMap : ${outTok.toLocaleString()} tok $${out.costContext}/query`);
22905
23338
  console.log(` Savings : ${savings}% ($${(costRaw - costCtx).toFixed(4)} saved per query)\n`);
22906
23339
  }