sigmap 8.9.1 → 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/CHANGELOG.md +26 -0
- package/README.md +8 -3
- package/gen-context.js +644 -211
- package/llms-full.txt +2 -2
- package/llms.txt +2 -2
- package/package.json +9 -3
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/conventions/extract.js +13 -3
- package/src/conventions/fix.js +4 -1
- package/src/extractors/javascript.js +4 -4
- package/src/extractors/typescript.js +5 -5
- package/src/graph/builder.js +124 -16
- package/src/graph/impact.js +2 -2
- package/src/health/scorer.js +172 -97
- package/src/judge/judge-engine.js +62 -2
- package/src/mcp/server.js +1 -1
- package/src/plan/planner.js +44 -15
- package/src/review/pr-evidence.js +2 -1
- package/src/review/review-pr.js +24 -2
- package/src/util/truncate.js +42 -0
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6154
|
-
|
|
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
|
|
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
|
-
|
|
8218
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
10236
|
-
*
|
|
10237
|
-
* @param {string}
|
|
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
|
|
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 =
|
|
10427
|
+
const r = resolveSpec(m[1]);
|
|
10311
10428
|
if (r) found.push(r);
|
|
10312
10429
|
}
|
|
10313
|
-
// CommonJS
|
|
10314
|
-
const
|
|
10315
|
-
while ((m =
|
|
10316
|
-
const r =
|
|
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,
|
|
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
|
|
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
|
|
11247
|
-
*
|
|
11248
|
-
*
|
|
11249
|
-
*
|
|
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
|
-
*
|
|
11252
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
11259
|
-
*
|
|
11260
|
-
*
|
|
11261
|
-
*
|
|
11262
|
-
*
|
|
11263
|
-
*
|
|
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
|
-
|
|
11275
|
-
|
|
11276
|
-
|
|
11277
|
-
|
|
11278
|
-
|
|
11279
|
-
|
|
11280
|
-
|
|
11281
|
-
|
|
11282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11290
|
-
strategy = cfg.strategy || 'full';
|
|
11489
|
+
strategy = JSON.parse(fs.readFileSync(cfgPath, 'utf8')).strategy || 'full';
|
|
11291
11490
|
}
|
|
11292
11491
|
} catch (_) {}
|
|
11293
11492
|
|
|
11294
|
-
// ──
|
|
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:
|
|
11502
|
+
const { percentile, overBudgetStreak: calcStreak } = __require('./src/format/dashboard');
|
|
11298
11503
|
const entries = readLog(cwd);
|
|
11299
|
-
const
|
|
11300
|
-
|
|
11301
|
-
|
|
11302
|
-
|
|
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 =
|
|
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
|
-
|
|
11316
|
-
|
|
11317
|
-
|
|
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
|
-
// ──
|
|
11525
|
+
// ── Freshness across ALL adapter outputs (freshest wins) ───────────────────
|
|
11526
|
+
let daysSinceRegen = null;
|
|
11321
11527
|
try {
|
|
11322
|
-
|
|
11323
|
-
|
|
11324
|
-
const
|
|
11325
|
-
|
|
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
|
-
// ──
|
|
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
|
|
11335
|
-
strategyFreshnessDays = parseFloat(((Date.now() -
|
|
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
|
-
|
|
11341
|
-
|
|
11342
|
-
|
|
11343
|
-
|
|
11344
|
-
|
|
11345
|
-
|
|
11346
|
-
|
|
11347
|
-
|
|
11348
|
-
|
|
11349
|
-
|
|
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
|
-
|
|
11388
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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:
|
|
14322
|
-
|
|
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
|
-
|
|
14326
|
-
const
|
|
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
|
-
|
|
14344
|
-
|
|
14345
|
-
|
|
14346
|
-
testsAffected:
|
|
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(`- ⚠️ **
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
20296
|
-
|
|
20297
|
-
|
|
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
|
|
20675
|
-
const
|
|
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` —
|
|
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 = '',
|
|
21191
|
+
let msg = '', diffRaw = '';
|
|
20832
21192
|
try {
|
|
20833
|
-
msg
|
|
20834
|
-
|
|
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
|
|
20839
|
-
if
|
|
20840
|
-
|
|
20841
|
-
|
|
20842
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
20968
|
-
|
|
20969
|
-
|
|
21348
|
+
goal: plan.goal,
|
|
21349
|
+
intent: plan.intent,
|
|
21350
|
+
inspectFirst: plan.inspectFirst,
|
|
21351
|
+
likelyToChange: plan.likelyToChange,
|
|
20970
21352
|
impactRadius: impact,
|
|
20971
|
-
|
|
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 (
|
|
21364
|
+
if (plan.inspectFirst.length === 0) {
|
|
20982
21365
|
console.log(' (no files found)');
|
|
20983
21366
|
} else {
|
|
20984
|
-
|
|
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 (
|
|
21371
|
+
if (plan.likelyToChange.length === 0) {
|
|
20989
21372
|
console.log(' (no files found)');
|
|
20990
21373
|
} else {
|
|
20991
|
-
|
|
21374
|
+
plan.likelyToChange.forEach((f, i) => console.log(` ${i + 1}. ${relOf(f)}`));
|
|
20992
21375
|
}
|
|
20993
|
-
if (
|
|
21376
|
+
if (impact && (impact.direct.length || impact.transitive.length)) {
|
|
20994
21377
|
console.log('');
|
|
20995
|
-
console.log('
|
|
20996
|
-
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
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': '
|
|
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}"` : ''} —
|
|
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
|
|
22878
|
-
const
|
|
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
|
}
|