diff --git a/AGENTS.md b/AGENTS.md index c943f0d..b4c74d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,6 +176,11 @@ code-fence --- ## src +### src/config/defaults.js +``` +module.exports = { DEFAULTS } :165-165 +``` + ### src/extractors/csharp.js ``` module.exports = { extract } :71-71 @@ -272,6 +277,12 @@ function dispatch(msg, cwd) :41-108 function start(cwd) :113-139 ``` +### src/retrieval/enrich-from-maps.js +``` +module.exports = { enrichWithSurfaces } :55-55 +function enrichWithSurfaces(index, cwd) → number :25-53 +``` + ### src/analysis/coverage-score.js ``` module.exports = { coverageScore, CODE_EXTS } :105-105 @@ -309,11 +320,6 @@ function getChangedFiles(files, cache) → { changed: string[], unch :71-88 function updateCacheEntries(cache, extracted) :96-103 ``` -### src/config/defaults.js -``` -module.exports = { DEFAULTS } :163-163 -``` - ### src/config/loader.js ``` module.exports = { loadConfig, loadBaseConfig } :313-313 @@ -1090,41 +1096,6 @@ function walk(dir, cwd, depth, out) :31-62 function analyze(files, cwd) :64-82 ``` -### src/map/route-table.js -``` -module.exports = { analyze } :127-127 -function shouldSkipFile(rel) :18-21 -function analyze(files, cwd) :23-125 -``` - -### src/mcp/handlers.js -``` -module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput } :975-975 -function _readContextFiles(cwd) :10-17 -function readContext(args, cwd) :36-66 -function searchSignatures(args, cwd) :74-100 -function getMap(args, cwd) :108-131 -function createCheckpoint(args, cwd) :143-215 -function getRouting(args, cwd) :224-261 -function explainFile(args, cwd) :269-356 -function listModules(args, cwd) :364-403 -function queryContext(args, cwd) :411-438 -function getMethodImpact(args, cwd) :446-460 -function getImpact(args, cwd) :468-480 -function getLines(args, cwd) :489-537 -function readMemory(args, cwd) :545-580 -function getCalleeSignatures(args, cwd) :589-634 -function _pkgVersion(cwd) :641-644 -function notifyFileCreated(args, cwd) :648-670 -function notifySymbolAdded(args, cwd) :673-693 -function notifyFileDeleted(args, cwd) :696-710 -function _changedFiles(cwd, args) :716-728 -function getDiffContext(args, cwd) :737-808 -function getArchitectureOverview(args, cwd) :817-885 -function verifySuggestion(args, cwd) :895-930 -function squeezeOutput(args, cwd) :940-973 -``` - ### src/mcp/install.js ``` module.exports = { CLIENTS, listClients, installClient, resolveTarget } :142-142 diff --git a/benchmarks/reports/surface-enrichment.json b/benchmarks/reports/surface-enrichment.json new file mode 100644 index 0000000..145ad21 --- /dev/null +++ b/benchmarks/reports/surface-enrichment.json @@ -0,0 +1,164 @@ +{ + "benchmark": "surface-enrichment-ab", + "arms": { + "A": "rank + plain index", + "B": "rank + route-enriched index" + }, + "tasks": 90, + "hitAt5A": 80, + "hitAt5B": 80, + "deltaTasks": 0, + "routeSigsTotal": 280, + "perRepo": [ + { + "repo": "abseil-cpp", + "tasks": 5, + "routeSigs": 0, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "akka", + "tasks": 5, + "routeSigs": 0, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "axios", + "tasks": 5, + "routeSigs": 1, + "hitA": 4, + "hitB": 4, + "delta": 0 + }, + { + "repo": "express", + "tasks": 5, + "routeSigs": 66, + "hitA": 4, + "hitB": 4, + "delta": 0 + }, + { + "repo": "fastapi", + "tasks": 5, + "routeSigs": 18, + "hitA": 4, + "hitB": 4, + "delta": 0 + }, + { + "repo": "fastify", + "tasks": 5, + "routeSigs": 0, + "hitA": 4, + "hitB": 4, + "delta": 0 + }, + { + "repo": "flask", + "tasks": 5, + "routeSigs": 162, + "hitA": 0, + "hitB": 0, + "delta": 0 + }, + { + "repo": "gin", + "tasks": 5, + "routeSigs": 28, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "laravel", + "tasks": 5, + "routeSigs": 0, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "okhttp", + "tasks": 5, + "routeSigs": 0, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "rails", + "tasks": 5, + "routeSigs": 0, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "riverpod", + "tasks": 5, + "routeSigs": 0, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "rust-analyzer", + "tasks": 5, + "routeSigs": 0, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "serilog", + "tasks": 5, + "routeSigs": 0, + "hitA": 0, + "hitB": 0, + "delta": 0 + }, + { + "repo": "spring-petclinic", + "tasks": 5, + "routeSigs": 5, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "svelte", + "tasks": 5, + "routeSigs": 0, + "hitA": 5, + "hitB": 5, + "delta": 0 + }, + { + "repo": "vapor", + "tasks": 5, + "routeSigs": 0, + "hitA": 1, + "hitB": 1, + "delta": 0 + }, + { + "repo": "vue-core", + "tasks": 5, + "routeSigs": 0, + "hitA": 5, + "hitB": 5, + "delta": 0 + } + ], + "skipped": [ + { + "repo": "retrieval", + "reason": "repo not cloned" + } + ] +} diff --git a/docs-vp/public/llms-full.txt b/docs-vp/public/llms-full.txt index e8131e0..c941a20 100644 --- a/docs-vp/public/llms-full.txt +++ b/docs-vp/public/llms-full.txt @@ -343,7 +343,7 @@ testCoverage = false testDirs = ["tests","test","__tests__","spec"] sigCache = false impactRadius = false -retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false} +retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false,"surfaceEnrichment":false} impact = {"depth":3,"includeSigs":true} ``` diff --git a/gen-context.js b/gen-context.js index f94bf4d..9ba7f10 100755 --- a/gen-context.js +++ b/gen-context.js @@ -1533,6 +1533,8 @@ __factories["./src/config/defaults"] = function(module, exports) { recencyBoost: 1.5, // Boost files call-graph-connected to query matches (opt-in, measure-gated) callGraphBoost: false, + // Append route pseudo-signatures to the rankable index (opt-in, measure-gated) + surfaceEnrichment: false, }, // Impact layer settings (v2.5) @@ -13336,7 +13338,14 @@ __factories["./src/map/route-table"] = function(module, exports) { return /(^|\/)(gen-context|gen-project-map)\.js$/.test(normalized); } - function analyze(files, cwd) { + /** + * Structured route rows across the supported frameworks — the data behind + * `analyze`, exposed for retrieval surface-enrichment (#488). + * @param {string[]} files absolute paths + * @param {string} cwd + * @returns {{ method:string, path:string, file:string }[]} file is cwd-relative + */ + function collectRoutes(files, cwd) { const routes = []; for (const filePath of files) { @@ -13428,6 +13437,11 @@ __factories["./src/map/route-table"] = function(module, exports) { } } + return routes; + } + + function analyze(files, cwd) { + const routes = collectRoutes(files, cwd); if (routes.length === 0) return ''; const lines = [ @@ -13440,7 +13454,7 @@ __factories["./src/map/route-table"] = function(module, exports) { return lines.join('\n'); } - module.exports = { analyze }; + module.exports = { analyze, collectRoutes }; }; @@ -13868,7 +13882,7 @@ __factories["./src/mcp/handlers"] = function(module, exports) { // Build dependency graph for neighbor boost — non-fatal if it fails let graph = null; try { graph = buildFromCwd(cwd); } catch (_) {} - // Opt-in call-graph neighbor boost (retrieval.callGraphBoost) — non-fatal + // Opt-in call-graph neighbor boost + surface enrichment — non-fatal let callGraph = null; try { const { loadConfig } = __require('./src/config/loader'); @@ -13876,6 +13890,9 @@ __factories["./src/mcp/handlers"] = function(module, exports) { if (retrieval && retrieval.callGraphBoost) { callGraph = __require('./src/graph/call-graph').buildCallFileGraph(cwd); } + if (retrieval && retrieval.surfaceEnrichment) { + __require('./src/retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd); + } } catch (_) {} const results = rank(args.query, index, { topK, cwd, graph, callGraph }); return formatRankTable(results, args.query); @@ -15615,6 +15632,65 @@ __factories["./src/retrieval/bm25"] = function(module, exports) { }; +// ── ./src/retrieval/enrich-from-maps ── +__factories["./src/retrieval/enrich-from-maps"] = function(module, exports) { + + /** + * Retrieval surface-enrichment (#488, §7.4 Retrieval ceiling — opt-in via + * `retrieval.surfaceEnrichment`, measure-gated). + * + * The map analyzers extract surfaces (routes) that never reach the rankable + * signature index — a query like "payment webhook route" cannot match a + * controller whose signatures never mention the route path. This module + * appends deterministic pseudo-signatures (`route GET /api/users`) to the + * defining file's signature list so the ranker's tokenizer can see them. + * + * Deterministic: rows are deduped and sorted; entries are copy-on-write so + * arrays shared with the signature cache are never mutated. + */ + + const path = require('path'); + + /** + * Enrich a signature index with route pseudo-signatures. + * @param {Map} index cwd-relative file → sigs (mutated: enriched entries are replaced with fresh arrays) + * @param {string} cwd + * @returns {number} pseudo-signatures added + */ + function enrichWithSurfaces(index, cwd) { + if (!(index instanceof Map) || index.size === 0) return 0; + + let collectRoutes; + try { ({ collectRoutes } = __require('./src/map/route-table')); } catch (_) { return 0; } + + const rels = [...index.keys()]; + const files = rels.map((rel) => path.join(cwd, rel)); + let routes = []; + try { routes = collectRoutes(files, cwd) || []; } catch (_) { return 0; } + + const byFile = new Map(); + for (const r of routes) { + const rel = String(r.file).replace(/\\/g, '/'); + if (!byFile.has(rel)) byFile.set(rel, new Set()); + byFile.get(rel).add(`route ${r.method} ${r.path}`); + } + + let added = 0; + for (const [rel, extras] of [...byFile.entries()].sort()) { + const sigs = index.get(rel); + if (!sigs) continue; + const fresh = [...extras].sort().filter((x) => !sigs.includes(x)); + if (!fresh.length) continue; + index.set(rel, [...sigs, ...fresh]); // copy-on-write: never mutate cached arrays + added += fresh.length; + } + return added; + } + + module.exports = { enrichWithSurfaces }; + +}; + // ── ./src/retrieval/ranker ── __factories["./src/retrieval/ranker"] = function(module, exports) { @@ -21959,6 +22035,10 @@ function main() { if (config && config.retrieval && config.retrieval.callGraphBoost) { try { askCallGraph = requireSourceOrBundled('./src/graph/call-graph').buildCallFileGraph(cwd); } catch (_) {} } + // Opt-in route surface-enrichment (retrieval.surfaceEnrichment) — non-fatal + if (config && config.retrieval && config.retrieval.surfaceEnrichment) { + try { requireSourceOrBundled('./src/retrieval/enrich-from-maps').enrichWithSurfaces(sigIndex, cwd); } catch (_) {} + } let ranked = rank(query, sigIndex, { topK: 5, weights: intentWeights, cwd, callGraph: askCallGraph }); @@ -24137,6 +24217,10 @@ function main() { if (config && config.retrieval && config.retrieval.callGraphBoost) { try { queryCallGraph = requireSourceOrBundled('./src/graph/call-graph').buildCallFileGraph(cwd); } catch (_) {} } + // Opt-in route surface-enrichment (retrieval.surfaceEnrichment) — non-fatal + if (config && config.retrieval && config.retrieval.surfaceEnrichment) { + try { requireSourceOrBundled('./src/retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd); } catch (_) {} + } const results = rank(query, index, { topK, recencyBoost, cwd, callGraph: queryCallGraph }); if (args.includes('--context')) { const miniCtx = buildMiniContext(results, cwd); diff --git a/llms-full.txt b/llms-full.txt index e8131e0..c941a20 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -343,7 +343,7 @@ testCoverage = false testDirs = ["tests","test","__tests__","spec"] sigCache = false impactRadius = false -retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false} +retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false,"surfaceEnrichment":false} impact = {"depth":3,"includeSigs":true} ``` diff --git a/package.json b/package.json index fd5a664..5cbc72c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "benchmark:test-discovery": "node scripts/run-test-discovery-benchmark.mjs --save", "benchmark:terse": "node scripts/run-terse-benchmark.mjs --save", "benchmark:callgraph-boost": "node scripts/run-callgraph-boost-benchmark.mjs --save", + "benchmark:surface-enrichment": "node scripts/run-surface-enrichment-benchmark.mjs --save", "validate:squeeze": "node scripts/run-squeeze-benchmark.mjs --gate", "health": "node gen-context.js --health", "map": "node gen-project-map.js", diff --git a/scripts/run-surface-enrichment-benchmark.mjs b/scripts/run-surface-enrichment-benchmark.mjs new file mode 100644 index 0000000..d990ba3 --- /dev/null +++ b/scripts/run-surface-enrichment-benchmark.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Route surface-enrichment A/B — the measure gate for retrieval.surfaceEnrichment. + * + * node scripts/run-surface-enrichment-benchmark.mjs # run, print table + * node scripts/run-surface-enrichment-benchmark.mjs --json # machine-readable + * node scripts/run-surface-enrichment-benchmark.mjs --save # write benchmarks/reports/surface-enrichment.json + * + * Two arms per repo through the FULL ranker: + * A — plain index rank(q, index, { cwd, graph }) + * B — route-enriched index rank(q, enriched, { cwd, graph }) + * The headline BM25 harness is untouched; this is the only legitimate source + * for the enrichment's number. Flip the default only if arm B ≥ arm A here. + * Note: the 90-task corpus is file-discovery-flavored — route-worded tasks + * would stress this harder; a +0 here is honest, not a failure. + * + * Reads contexts AS-IS — run the retrieval benchmark first for canonical state (#480). + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const REPOS_DIR = path.join(ROOT, 'benchmarks', 'repos'); +const TASKS_DIR = path.join(ROOT, 'benchmarks', 'tasks'); +const OUT = path.join(ROOT, 'benchmarks', 'reports', 'surface-enrichment.json'); + +const JSON_OUT = process.argv.includes('--json'); +const SAVE = process.argv.includes('--save'); + +const { rank, buildSigIndex } = require(path.join(ROOT, 'src/retrieval/ranker.js')); +const { buildFromCwd } = require(path.join(ROOT, 'src/graph/builder.js')); +const { enrichWithSurfaces } = require(path.join(ROOT, 'src/retrieval/enrich-from-maps.js')); + +function loadTasks(repo) { + const p = path.join(TASKS_DIR, `${repo}.jsonl`); + if (!fs.existsSync(p)) return []; + return fs.readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); +} + +function hitAt5(ranked, expected) { + const norm = (x) => String(x).replace(/\\/g, '/'); + const top5 = ranked.slice(0, 5).map((r) => norm(r.file)); + return expected.some((e) => top5.some((f) => f === norm(e) || f.endsWith('/' + norm(e)))) ? 1 : 0; +} + +const repos = fs.readdirSync(TASKS_DIR).filter((f) => f.endsWith('.jsonl')).map((f) => f.replace(/\.jsonl$/, '')).sort(); +const perRepo = []; +const skipped = []; +let tasksTotal = 0, hitsA = 0, hitsB = 0; + +for (const repo of repos) { + const dir = path.join(REPOS_DIR, repo); + if (!fs.existsSync(dir)) { skipped.push({ repo, reason: 'repo not cloned' }); continue; } + const tasks = loadTasks(repo); + if (!tasks.length) { skipped.push({ repo, reason: 'no tasks' }); continue; } + let index; + try { index = buildSigIndex(dir); } catch { index = new Map(); } + if (!index || index.size === 0) { skipped.push({ repo, reason: 'no context (run retrieval benchmark first)' }); continue; } + + let graph = null; + try { graph = buildFromCwd(dir); } catch { /* optional */ } + + const enriched = new Map([...index.entries()].map(([k, v]) => [k, [...v]])); + let routeSigs = 0; + try { routeSigs = enrichWithSurfaces(enriched, dir); } catch { /* optional */ } + + let a = 0, b = 0; + for (const t of tasks) { + const expected = t.expected_files || t.expected || []; + a += hitAt5(rank(t.query, index, { topK: 10, cwd: dir, graph }), expected); + b += hitAt5(rank(t.query, enriched, { topK: 10, cwd: dir, graph }), expected); + } + tasksTotal += tasks.length; hitsA += a; hitsB += b; + perRepo.push({ repo, tasks: tasks.length, routeSigs, hitA: a, hitB: b, delta: b - a }); +} + +const pct = (h) => (tasksTotal ? Math.round((h / tasksTotal) * 1000) / 10 : 0); +const result = { + benchmark: 'surface-enrichment-ab', + arms: { A: 'rank + plain index', B: 'rank + route-enriched index' }, + tasks: tasksTotal, + hitAt5A: pct(hitsA), + hitAt5B: pct(hitsB), + deltaTasks: hitsB - hitsA, + routeSigsTotal: perRepo.reduce((n, r) => n + r.routeSigs, 0), + perRepo, + skipped, +}; + +if (JSON_OUT) { + console.log(JSON.stringify(result, null, 2)); +} else { + console.log('Route surface-enrichment A/B (full rank(); headline BM25 harness untouched)'); + console.log(' note : reads contexts AS-IS — run the retrieval benchmark first (#480)'); + console.log(` tasks : ${tasksTotal} across ${perRepo.length} repos (${skipped.length} skipped)`); + console.log(` route sigs : ${result.routeSigsTotal} pseudo-signatures added in arm B`); + console.log(` arm A hit@5 : ${result.hitAt5A}% (plain index)`); + console.log(` arm B hit@5 : ${result.hitAt5B}% (+ route enrichment)`); + console.log(` delta : ${hitsB - hitsA >= 0 ? '+' : ''}${hitsB - hitsA} task(s)`); + const moved = perRepo.filter((r) => r.delta !== 0); + console.log(moved.length + ? ' moved repos : ' + moved.map((r) => `${r.repo} (${r.delta > 0 ? '+' : ''}${r.delta})`).join(', ') + : ' moved repos : none — arms identical on every repo'); + if (skipped.length) console.log(' skipped : ' + skipped.map((s) => `${s.repo} (${s.reason})`).join(', ')); +} + +if (SAVE) { + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, JSON.stringify(result, null, 2) + '\n'); + if (!JSON_OUT) console.log(` saved : ${path.relative(ROOT, OUT)}`); +} diff --git a/src/config/defaults.js b/src/config/defaults.js index 2937f2e..2a108a6 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -149,6 +149,8 @@ const DEFAULTS = { recencyBoost: 1.5, // Boost files call-graph-connected to query matches (opt-in, measure-gated) callGraphBoost: false, + // Append route pseudo-signatures to the rankable index (opt-in, measure-gated) + surfaceEnrichment: false, }, // Impact layer settings (v2.5) diff --git a/src/map/route-table.js b/src/map/route-table.js index 7221d04..0edd8ef 100644 --- a/src/map/route-table.js +++ b/src/map/route-table.js @@ -20,7 +20,14 @@ function shouldSkipFile(rel) { return /(^|\/)(gen-context|gen-project-map)\.js$/.test(normalized); } -function analyze(files, cwd) { +/** + * Structured route rows across the supported frameworks — the data behind + * `analyze`, exposed for retrieval surface-enrichment (#488). + * @param {string[]} files absolute paths + * @param {string} cwd + * @returns {{ method:string, path:string, file:string }[]} file is cwd-relative + */ +function collectRoutes(files, cwd) { const routes = []; for (const filePath of files) { @@ -112,6 +119,11 @@ function analyze(files, cwd) { } } + return routes; +} + +function analyze(files, cwd) { + const routes = collectRoutes(files, cwd); if (routes.length === 0) return ''; const lines = [ @@ -124,4 +136,4 @@ function analyze(files, cwd) { return lines.join('\n'); } -module.exports = { analyze }; +module.exports = { analyze, collectRoutes }; diff --git a/src/mcp/handlers.js b/src/mcp/handlers.js index f5023aa..5c2c575 100644 --- a/src/mcp/handlers.js +++ b/src/mcp/handlers.js @@ -421,7 +421,7 @@ function queryContext(args, cwd) { // Build dependency graph for neighbor boost — non-fatal if it fails let graph = null; try { graph = buildFromCwd(cwd); } catch (_) {} - // Opt-in call-graph neighbor boost (retrieval.callGraphBoost) — non-fatal + // Opt-in call-graph neighbor boost + surface enrichment — non-fatal let callGraph = null; try { const { loadConfig } = require('../config/loader'); @@ -429,6 +429,9 @@ function queryContext(args, cwd) { if (retrieval && retrieval.callGraphBoost) { callGraph = require('../graph/call-graph').buildCallFileGraph(cwd); } + if (retrieval && retrieval.surfaceEnrichment) { + require('../retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd); + } } catch (_) {} const results = rank(args.query, index, { topK, cwd, graph, callGraph }); return formatRankTable(results, args.query); diff --git a/src/retrieval/enrich-from-maps.js b/src/retrieval/enrich-from-maps.js new file mode 100644 index 0000000..93297de --- /dev/null +++ b/src/retrieval/enrich-from-maps.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * Retrieval surface-enrichment (#488, §7.4 Retrieval ceiling — opt-in via + * `retrieval.surfaceEnrichment`, measure-gated). + * + * The map analyzers extract surfaces (routes) that never reach the rankable + * signature index — a query like "payment webhook route" cannot match a + * controller whose signatures never mention the route path. This module + * appends deterministic pseudo-signatures (`route GET /api/users`) to the + * defining file's signature list so the ranker's tokenizer can see them. + * + * Deterministic: rows are deduped and sorted; entries are copy-on-write so + * arrays shared with the signature cache are never mutated. + */ + +const path = require('path'); + +/** + * Enrich a signature index with route pseudo-signatures. + * @param {Map} index cwd-relative file → sigs (mutated: enriched entries are replaced with fresh arrays) + * @param {string} cwd + * @returns {number} pseudo-signatures added + */ +function enrichWithSurfaces(index, cwd) { + if (!(index instanceof Map) || index.size === 0) return 0; + + let collectRoutes; + try { ({ collectRoutes } = require('../map/route-table')); } catch (_) { return 0; } + + const rels = [...index.keys()]; + const files = rels.map((rel) => path.join(cwd, rel)); + let routes = []; + try { routes = collectRoutes(files, cwd) || []; } catch (_) { return 0; } + + const byFile = new Map(); + for (const r of routes) { + const rel = String(r.file).replace(/\\/g, '/'); + if (!byFile.has(rel)) byFile.set(rel, new Set()); + byFile.get(rel).add(`route ${r.method} ${r.path}`); + } + + let added = 0; + for (const [rel, extras] of [...byFile.entries()].sort()) { + const sigs = index.get(rel); + if (!sigs) continue; + const fresh = [...extras].sort().filter((x) => !sigs.includes(x)); + if (!fresh.length) continue; + index.set(rel, [...sigs, ...fresh]); // copy-on-write: never mutate cached arrays + added += fresh.length; + } + return added; +} + +module.exports = { enrichWithSurfaces }; diff --git a/test/integration/surface-enrichment.test.js b/test/integration/surface-enrichment.test.js new file mode 100644 index 0000000..edc63de --- /dev/null +++ b/test/integration/surface-enrichment.test.js @@ -0,0 +1,124 @@ +'use strict'; + +/** + * Integration tests for route surface-enrichment (#488, opt-in). + * + * Covers: collectRoutes rows + unchanged analyze markdown, pseudo-signature + * enrichment (deterministic, copy-on-write), retrieval effect on a + * route-worded query, and the config-gated --query wiring (off by default). + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +const GEN_CONTEXT = path.resolve(__dirname, '../../gen-context.js'); +const { collectRoutes, analyze } = require('../../src/map/route-table'); +const { enrichWithSurfaces } = require('../../src/retrieval/enrich-from-maps'); +const { rank, buildSigIndex } = require('../../src/retrieval/ranker'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + passed++; + } catch (err) { + console.log(` FAIL ${name}: ${err.message}`); + failed++; + } +} + +/** Express-style fixture: webhook route lives in a file whose sigs never say "webhook". */ +function withProject(fn, config) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sigmap-surface-')); + try { + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync(path.join(dir, 'src', 'controller.js'), [ + "const app = require('./app');", + 'function handlePayment(req, res) {', + ' res.send(1);', + '}', + "app.post('/api/payments/webhook', handlePayment);", + 'module.exports = { handlePayment };', + '', + ].join('\n')); + fs.writeFileSync(path.join(dir, 'src', 'other.js'), [ + 'function webhookHelper(x) {', + ' return x;', + '}', + 'module.exports = { webhookHelper };', + '', + ].join('\n')); + if (config) fs.writeFileSync(path.join(dir, 'gen-context.config.json'), JSON.stringify(config, null, 2)); + const gen = spawnSync(process.execPath, [GEN_CONTEXT], { cwd: dir, encoding: 'utf8' }); + assert.strictEqual(gen.status, 0, gen.stderr); + fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('collectRoutes returns rows and analyze markdown is unchanged', () => { + withProject((dir) => { + const files = [path.join(dir, 'src', 'controller.js'), path.join(dir, 'src', 'other.js')]; + const rows = collectRoutes(files, dir); + assert.deepStrictEqual(rows, [{ method: 'POST', path: '/api/payments/webhook', file: 'src/controller.js' }]); + const md = analyze(files, dir); + assert.ok(md.startsWith('| Method | Path | File |'), md.slice(0, 40)); + assert.ok(md.includes('| POST | /api/payments/webhook | src/controller.js |'), md); + }); +}); + +test('enrichWithSurfaces adds the route pseudo-sig copy-on-write and deterministically', () => { + withProject((dir) => { + const index = buildSigIndex(dir); + const original = index.get('src/controller.js'); + const before = [...original]; + const added = enrichWithSurfaces(index, dir); + assert.strictEqual(added, 1); + assert.ok(index.get('src/controller.js').includes('route POST /api/payments/webhook')); + assert.deepStrictEqual(original, before, 'cached array was mutated'); + // idempotent + deterministic across a second pass on a fresh index + const index2 = buildSigIndex(dir); + enrichWithSurfaces(index2, dir); + assert.deepStrictEqual(index.get('src/controller.js'), index2.get('src/controller.js')); + assert.strictEqual(enrichWithSurfaces(index, dir), 0, 'second enrichment should add nothing'); + }); +}); + +test('a route-worded query retrieves the controller only with enrichment', () => { + withProject((dir) => { + const query = 'payments webhook route endpoint'; + const plain = rank(query, buildSigIndex(dir), { topK: 2, cwd: dir }); + const enriched = buildSigIndex(dir); + enrichWithSurfaces(enriched, dir); + const boosted = rank(query, enriched, { topK: 2, cwd: dir }); + const plainTop = plain[0] && plain[0].file; + const boostedTop = boosted[0] && boosted[0].file; + assert.strictEqual(boostedTop, 'src/controller.js', `enriched top: ${boostedTop}`); + const plainScore = (plain.find((r) => r.file === 'src/controller.js') || { score: 0 }).score; + const boostedScore = boosted.find((r) => r.file === 'src/controller.js').score; + assert.ok(boostedScore > plainScore, `score did not improve (${plainScore} → ${boostedScore}); plain top: ${plainTop}`); + }); +}); + +test('--query: route pseudo-sig appears only with retrieval.surfaceEnrichment on', () => { + withProject((dir) => { + const off = spawnSync(process.execPath, [GEN_CONTEXT, '--query', 'payments webhook route', '--json'], { cwd: dir, encoding: 'utf8' }); + assert.strictEqual(off.status, 0, off.stderr); + assert.ok(!off.stdout.includes('route POST'), 'enrichment active without config'); + }); + withProject((dir) => { + const on = spawnSync(process.execPath, [GEN_CONTEXT, '--query', 'payments webhook route', '--json'], { cwd: dir, encoding: 'utf8' }); + assert.strictEqual(on.status, 0, on.stderr); + assert.ok(on.stdout.includes('route POST /api/payments/webhook'), 'pseudo-sig missing with config on'); + }, { retrieval: { surfaceEnrichment: true } }); +}); + +console.log(`\nsurface-enrichment: ${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/version.json b/version.json index 5d3b475..69e5493 100644 --- a/version.json +++ b/version.json @@ -5,7 +5,7 @@ "languages": 33, "extractors": 42, "mcp_tools": 20, - "tests": 123, + "tests": 124, "metrics": { "hit_at_5": 0.878, "baseline_hit_at_5": 0.136,