graph-indexer 2.1.1 → 3.0.1
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 +312 -1
- package/README.md +236 -312
- package/SECURITY.md +53 -58
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +9 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +103 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +129 -0
- package/package.json +32 -109
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +366 -0
- package/src/cli/init.mjs +272 -0
- package/src/cli/resident.mjs +185 -0
- package/src/index/discover.mjs +128 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +332 -0
- package/src/index/resolver.mjs +961 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +974 -0
- package/src/parse/arity.mjs +153 -0
- package/src/parse/extract.mjs +1038 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +345 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +683 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +130 -0
- package/src/query/textsearch.mjs +178 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +63 -0
- package/src/util/regex.mjs +2 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- package/watch-daemon.mjs +0 -289
package/mcp/topology.mjs
DELETED
|
@@ -1,676 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file mcp/topology.mjs
|
|
3
|
-
* @description MCP topology helpers: caller classification, symbol references,
|
|
4
|
-
* route mapping, connected-subgraph traversal, and HyDE.
|
|
5
|
-
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
6
|
-
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
7
|
-
* @license MIT
|
|
8
|
-
*/
|
|
9
|
-
import { isNaturalLanguageQuery } from '../search-core.mjs';
|
|
10
|
-
import { extractSignatureLine } from './format.mjs';
|
|
11
|
-
|
|
12
|
-
// ─── Low-confidence handoff ───────────────────────────────────────────────────
|
|
13
|
-
// The dominant failure mode for behavioural queries is "didn't nail the exact
|
|
14
|
-
// symbol" — yet most of those misses still land the correct FILE in the top
|
|
15
|
-
// results. When a natural-language query yields no dominant match, surfacing the
|
|
16
|
-
// distinct candidate files lets the agent `get_file_skeleton` them instead of
|
|
17
|
-
// reading whole files blind. The gate is derived entirely from the returned
|
|
18
|
-
// ranking (no extra work, a few tokens) and is deliberately conservative so it
|
|
19
|
-
// NEVER fires on a confident symbolic hit:
|
|
20
|
-
// • a pinned `exact_tokens` → the caller already knows the symbol;
|
|
21
|
-
// • a keyword / symbol-lookup query (not natural language) → rank-1-dominant;
|
|
22
|
-
// • a top result that clearly separates from #2 (≥2× the fused score, the same
|
|
23
|
-
// factor as the exact-name boost) → a dominant match;
|
|
24
|
-
// • results confined to a single file → nothing cross-file to hand off.
|
|
25
|
-
// The 2× separation maps to fuseAndRank's exact-name boost multiplier — it is a
|
|
26
|
-
// structural constant, not a value fit to the benchmark queries.
|
|
27
|
-
export function assessConfidence(matches, fullQuery, exactPinned, limit = 5) {
|
|
28
|
-
const distinctFiles = [];
|
|
29
|
-
const seen = new Set();
|
|
30
|
-
for (const m of matches) {
|
|
31
|
-
const fp = m?.chunk?.file_path;
|
|
32
|
-
if (fp && !seen.has(fp)) { seen.add(fp); distinctFiles.push(fp); }
|
|
33
|
-
}
|
|
34
|
-
const dominates = matches.length >= 2 && matches[0].score >= 2 * matches[1].score;
|
|
35
|
-
const lowConfidence = !exactPinned
|
|
36
|
-
&& isNaturalLanguageQuery(fullQuery)
|
|
37
|
-
&& matches.length >= 2
|
|
38
|
-
&& !dominates
|
|
39
|
-
&& distinctFiles.length >= 2;
|
|
40
|
-
return { lowConfidence, candidateFiles: lowConfidence ? distinctFiles.slice(0, limit) : [] };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ─── Query-side HyDE (opt-in) ─────────────────────────────────────────────────
|
|
44
|
-
// Behavioural queries often share NO vocabulary with the code that answers them.
|
|
45
|
-
// HyDE (Hypothetical Document Embeddings) closes that gap on the QUERY side: a
|
|
46
|
-
// local LLM writes a short hypothetical implementation of the request, we embed
|
|
47
|
-
// THAT, and blend it with the raw query vector (never replace it — the raw query
|
|
48
|
-
// is the anchor). The chunk side already does this via chunk.hyde/summaries; this
|
|
49
|
-
// is its query-time complement. Gated off by default → when disabled, search is
|
|
50
|
-
// byte-identical and the eval/parity are untouched. Per-query result is cached for
|
|
51
|
-
// the process lifetime so repeated queries pay the generation cost once.
|
|
52
|
-
const HYDE_ALPHA = 0.5; // blend weight on the hypothetical vector (0 = pure query, 1 = pure HyDE)
|
|
53
|
-
const _hydeCache = new Map(); // normalized query → blended Float32Array
|
|
54
|
-
|
|
55
|
-
// ─── Repo-language detection (drives the language-aware HyDE prompt) ───────────
|
|
56
|
-
const _EXT_FAMILY = {
|
|
57
|
-
ts: 'typescript', tsx: 'typescript',
|
|
58
|
-
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
59
|
-
py: 'python', go: 'go', rs: 'rust', java: 'java',
|
|
60
|
-
kt: 'kotlin', kts: 'kotlin', cs: 'csharp', rb: 'ruby', php: 'php',
|
|
61
|
-
swift: 'swift', c: 'c', h: 'c',
|
|
62
|
-
sh: 'bash', bash: 'bash', zsh: 'bash',
|
|
63
|
-
scss: 'scss', css: 'scss', sass: 'scss',
|
|
64
|
-
};
|
|
65
|
-
const _LARAVEL_DIRS = new Set([
|
|
66
|
-
'models', 'http', 'controllers', 'console', 'providers',
|
|
67
|
-
'middleware', 'requests', 'builders', 'repositories', 'services',
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Pick the canonical language/framework key for a repo from its index stats.
|
|
72
|
-
* @param {Map<string, number>} extCounts extension → chunk count (db.stats().extCounts)
|
|
73
|
-
* @param {Iterable<{file_path?:string}>} chunksIterable bounded path scan (db.iterateChunks())
|
|
74
|
-
* @returns {string|null} one of typescript|javascript|python|go|rust|java-spring|java|
|
|
75
|
-
* kotlin|csharp|ruby|php-laravel|php|swift|c|bash|scss, or null when ambiguous/unknown
|
|
76
|
-
* (null → buildHydePrompt falls back to the generic, never-regressing prompt).
|
|
77
|
-
*/
|
|
78
|
-
export function detectRepoLanguage(extCounts, chunksIterable = []) {
|
|
79
|
-
const score = new Map();
|
|
80
|
-
if (extCounts && typeof extCounts.forEach === 'function') {
|
|
81
|
-
for (const [ext, n] of extCounts) {
|
|
82
|
-
const fam = _EXT_FAMILY[String(ext).toLowerCase()];
|
|
83
|
-
if (fam) score.set(fam, (score.get(fam) || 0) + n);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (score.size === 0) return null;
|
|
87
|
-
|
|
88
|
-
let springSignal = false;
|
|
89
|
-
const laravelDirs = new Set();
|
|
90
|
-
let laravelKw = false;
|
|
91
|
-
let scanned = 0;
|
|
92
|
-
for (const chunk of (chunksIterable || [])) {
|
|
93
|
-
if (scanned++ >= 400) break;
|
|
94
|
-
const fp = String(chunk?.file_path || '').toLowerCase();
|
|
95
|
-
if (!fp) continue;
|
|
96
|
-
if (fp.includes('springframework')) springSignal = true;
|
|
97
|
-
for (const seg of fp.split('/')) {
|
|
98
|
-
if (_LARAVEL_DIRS.has(seg)) laravelDirs.add(seg);
|
|
99
|
-
if (seg === 'laravel' || seg === 'eloquent' || seg === 'artisan' || seg === 'symfony') laravelKw = true;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const ts = score.get('typescript') || 0;
|
|
104
|
-
const js = score.get('javascript') || 0;
|
|
105
|
-
const web = ts + js;
|
|
106
|
-
const ranking = [];
|
|
107
|
-
for (const [fam, n] of score) {
|
|
108
|
-
if (fam === 'typescript' || fam === 'javascript') continue;
|
|
109
|
-
ranking.push([fam, n]);
|
|
110
|
-
}
|
|
111
|
-
if (web > 0) ranking.push(['web', web]);
|
|
112
|
-
ranking.sort((a, b) => b[1] - a[1]);
|
|
113
|
-
const [topFam, topScore] = ranking[0];
|
|
114
|
-
const secondScore = ranking[1]?.[1] ?? 0;
|
|
115
|
-
|
|
116
|
-
if ((score.get('java') || 0) > 0 && springSignal) return 'java-spring';
|
|
117
|
-
|
|
118
|
-
if (secondScore > 0 && secondScore >= 0.9 * topScore) return null;
|
|
119
|
-
|
|
120
|
-
if (topFam === 'web') return ts >= 0.6 * web ? 'typescript' : 'javascript';
|
|
121
|
-
if (topFam === 'php') return (laravelKw || laravelDirs.size >= 2) ? 'php-laravel' : 'php';
|
|
122
|
-
if (topFam === 'java') return springSignal ? 'java-spring' : 'java';
|
|
123
|
-
return topFam;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// HyDE must never degrade below no-HyDE when language is unknown/ambiguous.
|
|
127
|
-
function _genericHydePrompt(query) {
|
|
128
|
-
return (
|
|
129
|
-
`Write a short, realistic code snippet (5-15 lines, any language) that implements or `
|
|
130
|
-
+ `directly answers the request below. Output ONLY code — no prose, no markdown fences, `
|
|
131
|
-
+ `no comments explaining yourself.\n\nRequest: ${query}\n\nCode:`
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const _HYDE_LANG = {
|
|
136
|
-
typescript: {
|
|
137
|
-
name: 'TypeScript',
|
|
138
|
-
idioms: 'Match NestJS/React idioms: @Injectable/@Controller providers with constructor injection (private readonly svc: Service) and async handlers, or a React FC using useState/useEffect with a typed props interface.',
|
|
139
|
-
primer: 'export ',
|
|
140
|
-
},
|
|
141
|
-
javascript: {
|
|
142
|
-
name: 'JavaScript',
|
|
143
|
-
idioms: 'Match Axios/Express idioms: middleware (req, res, next), router.get/post, module.exports, request/response interceptors, and promise chains.',
|
|
144
|
-
primer: 'const ',
|
|
145
|
-
},
|
|
146
|
-
python: {
|
|
147
|
-
name: 'Python',
|
|
148
|
-
idioms: 'Match FastAPI/Django idioms: @router.get/@router.post async handlers with Depends() and Pydantic BaseModel, or Django class-based views and models with CharField/ForeignKey and QuerySet .filter()/.select_related().',
|
|
149
|
-
primer: 'async def ',
|
|
150
|
-
},
|
|
151
|
-
go: {
|
|
152
|
-
name: 'Go',
|
|
153
|
-
idioms: 'Match Gin idioms: handlers func(c *gin.Context) calling c.JSON(http.StatusOK, ...) and c.ShouldBindJSON(&req), middleware as gin.HandlerFunc, and router groups r.Group("/api").',
|
|
154
|
-
primer: 'func ',
|
|
155
|
-
},
|
|
156
|
-
rust: {
|
|
157
|
-
name: 'Rust',
|
|
158
|
-
idioms: 'Match idiomatic Rust: pub struct/trait definitions, impl blocks, #[derive(Debug, Clone)], Result/Option return types, match arms, and use crate:: imports.',
|
|
159
|
-
primer: 'pub ',
|
|
160
|
-
},
|
|
161
|
-
'java-spring': {
|
|
162
|
-
name: 'Java (Spring Boot)',
|
|
163
|
-
idioms: 'Match Spring Boot idioms: @Service/@Repository/@RestController annotations, constructor injection, @Transactional, ResponseEntity<T>, JpaRepository<Entity, ID>, and @GetMapping/@PostMapping.',
|
|
164
|
-
primer: 'import org.springframework.',
|
|
165
|
-
},
|
|
166
|
-
java: {
|
|
167
|
-
name: 'Java',
|
|
168
|
-
idioms: 'Match standard Java idioms: class/interface definitions, generics, Optional<T>, try/catch, and stream().filter().map().collect().',
|
|
169
|
-
primer: 'public class ',
|
|
170
|
-
},
|
|
171
|
-
kotlin: {
|
|
172
|
-
name: 'Kotlin (Android)',
|
|
173
|
-
idioms: 'Match Android/Kotlin idioms: ViewModel with LiveData/StateFlow, coroutines (suspend fun, viewModelScope.launch), Hilt injection (@HiltViewModel, @Inject constructor), and companion object.',
|
|
174
|
-
primer: 'class ',
|
|
175
|
-
},
|
|
176
|
-
csharp: {
|
|
177
|
-
name: 'C# (ASP.NET Core)',
|
|
178
|
-
idioms: 'Match ASP.NET Core idioms: [ApiController]/[Route] controllers, async Task<IActionResult>, [HttpGet]/[HttpPost], ILogger<T> injection, and Entity Framework (.Include, .Where, .FirstOrDefaultAsync).',
|
|
179
|
-
primer: 'public class ',
|
|
180
|
-
},
|
|
181
|
-
ruby: {
|
|
182
|
-
name: 'Ruby (Rails)',
|
|
183
|
-
idioms: 'Match Rails idioms: ActiveRecord models (belongs_to, has_many, scope, validate), controller actions (def index/show/create) with before_action, and render json:.',
|
|
184
|
-
primer: 'class ',
|
|
185
|
-
},
|
|
186
|
-
'php-laravel': {
|
|
187
|
-
name: 'PHP (Laravel/Symfony)',
|
|
188
|
-
idioms: 'Match Laravel idioms: Eloquent models (extends Model, $fillable, hasMany/belongsTo), Service classes with constructor injection, Controllers extending Controller, Artisan commands (extends Command, handle()), and Facades (Route::, Auth::). For Symfony use #[Route] attributes and autowired services.',
|
|
189
|
-
primer: '<?php\n\nnamespace App\\',
|
|
190
|
-
},
|
|
191
|
-
php: {
|
|
192
|
-
name: 'PHP',
|
|
193
|
-
idioms: 'Match idiomatic PHP: namespaced classes, typed properties and method signatures, constructor injection, and interface implementations.',
|
|
194
|
-
primer: '<?php\n\n',
|
|
195
|
-
},
|
|
196
|
-
swift: {
|
|
197
|
-
name: 'Swift',
|
|
198
|
-
idioms: 'Match Swift/Alamofire idioms: AF.request(...).responseDecodable, Session, RequestInterceptor/EventMonitor, Result<T, Error> completion handlers, @discardableResult, and protocol conformances.',
|
|
199
|
-
primer: 'public ',
|
|
200
|
-
},
|
|
201
|
-
c: {
|
|
202
|
-
name: 'C',
|
|
203
|
-
idioms: 'Match idiomatic C: function definitions with pointer parameters, struct definitions, malloc/free with NULL checks, and a typed C API (e.g. cJSON_Parse, cJSON_GetObjectItem, cJSON_CreateObject).',
|
|
204
|
-
primer: 'static ',
|
|
205
|
-
},
|
|
206
|
-
bash: {
|
|
207
|
-
name: 'Bash',
|
|
208
|
-
idioms: 'Match idiomatic Bash: function definitions name() { ... }, local variables, case statements, [[ ]] conditionals, $() command substitution, and return 0/1.',
|
|
209
|
-
primer: null,
|
|
210
|
-
},
|
|
211
|
-
scss: {
|
|
212
|
-
name: 'SCSS',
|
|
213
|
-
idioms: 'Match idiomatic SCSS: @mixin definitions used via @include, nested selectors, $variable declarations, @extend, and &:hover/&:focus patterns.',
|
|
214
|
-
primer: null,
|
|
215
|
-
},
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
export function buildHydePrompt(query, lang = null) {
|
|
219
|
-
const spec = lang ? _HYDE_LANG[lang] : null;
|
|
220
|
-
if (!spec) return _genericHydePrompt(query);
|
|
221
|
-
const primer = spec.primer ?? `${spec.name} code:\n`;
|
|
222
|
-
return (
|
|
223
|
-
`Write a short, realistic ${spec.name} code snippet (5-15 lines) that implements or `
|
|
224
|
-
+ `directly answers the request below. ${spec.idioms} Output ONLY code — no prose, `
|
|
225
|
-
+ `no markdown fences, no comments explaining yourself.\n\nRequest: ${query}\n\n${primer}`
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export function blendVectors(a, b, alpha = HYDE_ALPHA) {
|
|
230
|
-
const out = new Float32Array(a.length);
|
|
231
|
-
for (let i = 0; i < a.length; i++) out[i] = (1 - alpha) * a[i] + alpha * b[i];
|
|
232
|
-
return out;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Augment a query vector with a hypothetical-snippet embedding. Best-effort: any
|
|
237
|
-
* failure (generator down, dim mismatch, empty snippet) returns the raw vector
|
|
238
|
-
* unchanged, so HyDE can never degrade a query below the no-HyDE baseline.
|
|
239
|
-
*/
|
|
240
|
-
export async function hydeQueryVector(query, rawVec, { embedder, generate, lang = null }) {
|
|
241
|
-
if (!rawVec || !embedder || !generate) return rawVec;
|
|
242
|
-
const norm = query.trim().toLowerCase();
|
|
243
|
-
if (_hydeCache.has(norm)) return _hydeCache.get(norm);
|
|
244
|
-
let blended = rawVec;
|
|
245
|
-
try {
|
|
246
|
-
const snippet = await generate(buildHydePrompt(query, lang));
|
|
247
|
-
if (snippet && snippet.trim()) {
|
|
248
|
-
const hydeVec = await embedder.embedQuery(snippet.slice(0, 2000));
|
|
249
|
-
if (hydeVec && hydeVec.length === rawVec.length) blended = blendVectors(rawVec, hydeVec);
|
|
250
|
-
}
|
|
251
|
-
} catch { /* keep the raw vector */ }
|
|
252
|
-
_hydeCache.set(norm, blended);
|
|
253
|
-
return blended;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Lowercased capitalized type tokens from a type / return-type string —
|
|
257
|
-
* "Promise<OrderRepo>" → ["promise","orderrepo"]. Lets an inferred receiver type
|
|
258
|
-
* (parse/metadata.mjs attaches recv_type / recv_via_call to call sites) be matched
|
|
259
|
-
* against a target method's defining class without parsing the type grammar. */
|
|
260
|
-
function _typeMatchTokens(str) {
|
|
261
|
-
const out = [];
|
|
262
|
-
if (!str) return out;
|
|
263
|
-
for (const m of String(str).match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || []) {
|
|
264
|
-
if (/^[A-Z]/.test(m)) out.push(m.toLowerCase());
|
|
265
|
-
}
|
|
266
|
-
return out;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ─── Call-graph confidence (precise blast radius) ────────────────────────────────
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Split the bare name-match callers of a function into **high-confidence** (the
|
|
273
|
-
* real blast radius) vs **name-only** (an ambiguous same-named symbol elsewhere).
|
|
274
|
-
*
|
|
275
|
-
* The call graph matches by callee name, so `get_call_graph("save")` otherwise
|
|
276
|
-
* returns callers of *every* `save()` in the repo. This re-classifies them using
|
|
277
|
-
* only cheap, index-time signals — no type inference:
|
|
278
|
-
* • target uniqueness — one symbol named X ⇒ every caller is unambiguous;
|
|
279
|
-
* • receiver hints — `this.X` from the same class, or a direct `X()` to a
|
|
280
|
-
* free function (captured by parse/metadata.mjs extractCallSites);
|
|
281
|
-
* • the file import graph — a caller whose file imports the file defining X is
|
|
282
|
-
* very likely calling that X.
|
|
283
|
-
* `targetClass` scopes the question to one class's method.
|
|
284
|
-
*
|
|
285
|
-
* @returns {{ high:Array, nameOnly:Array, targetDefs:object[], ambiguous:boolean,
|
|
286
|
-
* hasSiteData:boolean, classFiltered:boolean }}
|
|
287
|
-
* high/nameOnly items are { chunk, reason, recvHint }.
|
|
288
|
-
*/
|
|
289
|
-
export function classifyCallers(db, targetFunction, { targetClass = null } = {}) {
|
|
290
|
-
const callers = db.findCallers(targetFunction);
|
|
291
|
-
const allDefs = db.resolveSymbol(targetFunction);
|
|
292
|
-
let targetDefs = allDefs;
|
|
293
|
-
|
|
294
|
-
let classFiltered = false;
|
|
295
|
-
if (targetClass) {
|
|
296
|
-
const tcl = String(targetClass).toLowerCase();
|
|
297
|
-
const scoped = allDefs.filter(d => (d.class_context || '').toLowerCase() === tcl);
|
|
298
|
-
if (scoped.length) { targetDefs = scoped; classFiltered = true; }
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const targetClasses = new Set(targetDefs.map(d => (d.class_context || '').toLowerCase()).filter(Boolean));
|
|
302
|
-
const targetFiles = new Set(targetDefs.map(d => d.file_path));
|
|
303
|
-
const targetIsFreeFn = targetDefs.some(d => !d.class_context);
|
|
304
|
-
const uniqueTarget = targetDefs.length <= 1;
|
|
305
|
-
const ambiguous = allDefs.length > 1;
|
|
306
|
-
const tcl = targetClass ? String(targetClass).toLowerCase() : null;
|
|
307
|
-
|
|
308
|
-
// Receiver-type promotion: a caller through a typed/inferred receiver whose type is
|
|
309
|
-
// the class that defines the target method is a real caller, even though its raw
|
|
310
|
-
// receiver hint is a variable name (`const s = getStore(); s.save()`). Match the
|
|
311
|
-
// inferred type against the target's defining class(es) — or an explicit target
|
|
312
|
-
// class. recv_via_call factories are resolved to their recorded return type here,
|
|
313
|
-
// at query time, so no extra index state is needed (memoised per call).
|
|
314
|
-
const typeMatchSet = new Set(targetClasses);
|
|
315
|
-
if (tcl) typeMatchSet.add(tcl);
|
|
316
|
-
const classDisplay = new Map();
|
|
317
|
-
for (const d of targetDefs) { const c = d.class_context; if (c) classDisplay.set(c.toLowerCase(), c); }
|
|
318
|
-
if (targetClass) classDisplay.set(String(targetClass).toLowerCase(), String(targetClass));
|
|
319
|
-
const returnTokenCache = new Map();
|
|
320
|
-
const calleeReturnTokens = (callee) => {
|
|
321
|
-
if (returnTokenCache.has(callee)) return returnTokenCache.get(callee);
|
|
322
|
-
const toks = new Set();
|
|
323
|
-
for (const def of db.resolveSymbol(callee) || []) for (const t of _typeMatchTokens(def.return_type)) toks.add(t);
|
|
324
|
-
returnTokenCache.set(callee, toks);
|
|
325
|
-
return toks;
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
let hasSiteData = false;
|
|
329
|
-
const high = [], nameOnly = [];
|
|
330
|
-
|
|
331
|
-
for (const caller of callers) {
|
|
332
|
-
const sites = (caller.call_sites || []).filter(s => s && s.name === targetFunction);
|
|
333
|
-
if (sites.length) hasSiteData = true;
|
|
334
|
-
const recvs = new Set(sites.map(s => s.recv));
|
|
335
|
-
const callerClass = (caller.class_context || '').toLowerCase();
|
|
336
|
-
const deps = db.getDependencies(caller.file_path) || [];
|
|
337
|
-
|
|
338
|
-
// First site whose inferred receiver type names a target class (lowercased token).
|
|
339
|
-
let typeMatch = null;
|
|
340
|
-
if (typeMatchSet.size) {
|
|
341
|
-
for (const s of sites) {
|
|
342
|
-
// Precedence: intra-procedural receiver type → the opt-in inter-procedural
|
|
343
|
-
// fixpoint result (recv_resolved_type, written at index time) → the 1-hop
|
|
344
|
-
// query-time factory return-type fallback. The fixpoint resolves multi-hop /
|
|
345
|
-
// unannotated factory chains the 1-hop path cannot.
|
|
346
|
-
const toks = s.recv_type ? _typeMatchTokens(s.recv_type)
|
|
347
|
-
: s.recv_resolved_type ? _typeMatchTokens(s.recv_resolved_type)
|
|
348
|
-
: s.recv_via_call ? [...calleeReturnTokens(s.recv_via_call)] : null;
|
|
349
|
-
const m = toks && toks.find(t => typeMatchSet.has(t));
|
|
350
|
-
if (m) { typeMatch = m; break; }
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// `proven` (A1): the binding is UNAMBIGUOUS — a sole definition, or pinned by the
|
|
355
|
-
// receiver's TYPE (typed receiver / A3 fixpoint / this-in-class / explicit target
|
|
356
|
-
// class). The precise resolver lifts these to the `resolved` edge tier. Import- and
|
|
357
|
-
// proximity-based reasons are likely-but-not-proven → stay `high`.
|
|
358
|
-
let reason = '', proven = false;
|
|
359
|
-
if (uniqueTarget && !classFiltered) { reason = 'sole definition'; proven = true; }
|
|
360
|
-
else if (recvs.has('this') && callerClass && targetClasses.has(callerClass)) { reason = `this.${targetFunction}()`; proven = true; }
|
|
361
|
-
else if (recvs.has('') && targetIsFreeFn) { reason = `${targetFunction}()`; proven = false; }
|
|
362
|
-
else if (typeMatch) { reason = `${classDisplay.get(typeMatch) || typeMatch}.${targetFunction}()`; proven = true; }
|
|
363
|
-
else if (deps.some(d => targetFiles.has(d))) { reason = 'imports definition'; proven = false; }
|
|
364
|
-
else if (targetFiles.has(caller.file_path)) { reason = 'same file'; proven = false; }
|
|
365
|
-
else if (tcl && [...recvs].some(r => r && r.toLowerCase() === tcl)) { reason = `${targetClass}.${targetFunction}()`; proven = true; }
|
|
366
|
-
|
|
367
|
-
const recvHint = [...recvs].map(r =>
|
|
368
|
-
r === '' ? `${targetFunction}()` : r === 'this' ? `this.${targetFunction}()` : `${r}.${targetFunction}()`
|
|
369
|
-
).join(', ');
|
|
370
|
-
|
|
371
|
-
(reason ? high : nameOnly).push({ chunk: caller, reason, recvHint, proven: reason ? proven : false });
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
return { high, nameOnly, targetDefs, ambiguous, hasSiteData, classFiltered };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ─── Symbol-level references (file→file topology, sharpened to symbol→symbol) ─────
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Resolve *which symbols* reference a target symbol — not just which files. Fuses
|
|
381
|
-
* the three reference kinds the index records and classifies each by confidence
|
|
382
|
-
* using the same cheap, index-time signals as the call graph (no type inference):
|
|
383
|
-
*
|
|
384
|
-
* • calls — `findCallers` + classifyCallers (high / name-only blast radius);
|
|
385
|
-
* • inherits — chunks whose `extends` names the symbol (subclasses / implementers);
|
|
386
|
-
* • types — chunks whose `type_refs` names the symbol (params, returns, fields).
|
|
387
|
-
*
|
|
388
|
-
* For the non-call kinds, a referer is **high-confidence** when the target is the
|
|
389
|
-
* sole definition, the referer's file imports a file that defines it, or it is
|
|
390
|
-
* defined in the same file; otherwise it is **name-only** (a same-named symbol
|
|
391
|
-
* may be meant). This is the symbol-granular "used by" that file-level topology
|
|
392
|
-
* (getImportedBy) can only approximate.
|
|
393
|
-
*
|
|
394
|
-
* @returns {{ symbol:string, targetDefs:object[], ambiguous:boolean,
|
|
395
|
-
* calls:ReturnType<typeof classifyCallers>,
|
|
396
|
-
* inherits:Array, types:Array }}
|
|
397
|
-
* inherits/types items are { chunk, confidence, reason }.
|
|
398
|
-
*/
|
|
399
|
-
export function findReferences(db, symbol, { targetClass = null } = {}) {
|
|
400
|
-
const calls = classifyCallers(db, symbol, { targetClass });
|
|
401
|
-
const { targetDefs, ambiguous } = calls;
|
|
402
|
-
const targetFiles = new Set(targetDefs.map(d => d.file_path));
|
|
403
|
-
const uniqueTarget = targetDefs.length <= 1;
|
|
404
|
-
const key = String(symbol).toLowerCase().trim();
|
|
405
|
-
|
|
406
|
-
const inherits = [], types = [];
|
|
407
|
-
for (const ref of db.findReferers(symbol)) {
|
|
408
|
-
const deps = db.getDependencies(ref.file_path) || [];
|
|
409
|
-
const imports = deps.some(d => targetFiles.has(d));
|
|
410
|
-
const sameFile = targetFiles.has(ref.file_path);
|
|
411
|
-
const reason = uniqueTarget ? 'sole definition'
|
|
412
|
-
: imports ? 'imports definition'
|
|
413
|
-
: sameFile ? 'same file' : '';
|
|
414
|
-
const confidence = reason ? 'high' : 'name-only';
|
|
415
|
-
// proven (A1): a sole definition is an unambiguous binding → eligible for `resolved`.
|
|
416
|
-
const proven = uniqueTarget;
|
|
417
|
-
if ((ref.extends || []).some(t => t.toLowerCase() === key)) inherits.push({ chunk: ref, confidence, reason, proven });
|
|
418
|
-
if ((ref.type_refs || []).some(t => t.toLowerCase() === key)) types.push({ chunk: ref, confidence, reason, proven });
|
|
419
|
-
}
|
|
420
|
-
const order = (a, b) => (a.confidence === b.confidence
|
|
421
|
-
? a.chunk.file_path.localeCompare(b.chunk.file_path)
|
|
422
|
-
: a.confidence === 'high' ? -1 : 1);
|
|
423
|
-
inherits.sort(order); types.sort(order);
|
|
424
|
-
|
|
425
|
-
// A2 v2: precise cross-file references from a locally-generated SCIP index (--resolver scip).
|
|
426
|
-
// SCIP occurrences are kind-agnostic ("symbol used here"), so this is the binding-precise,
|
|
427
|
-
// cross-file "used by" set: it both DISAMBIGUATES same-named symbols (each referer is attached
|
|
428
|
-
// to the one definition SCIP bound it to) and RECOVERS references the AST/name heuristic above
|
|
429
|
-
// misses entirely. Confidence is `resolved` — trustworthy, not a name guess. Empty (and so
|
|
430
|
-
// byte-identical) on every index built without a SCIP resolver. Isolated artifact → parity-free.
|
|
431
|
-
const references = [];
|
|
432
|
-
if (db.hasScipRefs?.()) {
|
|
433
|
-
const seen = new Set();
|
|
434
|
-
for (const def of targetDefs) {
|
|
435
|
-
for (const refId of db.getScipReferers(def.id)) {
|
|
436
|
-
if (seen.has(refId)) continue;
|
|
437
|
-
seen.add(refId);
|
|
438
|
-
const chunk = db.getChunk(refId);
|
|
439
|
-
if (chunk) references.push({ chunk, confidence: 'resolved', reason: 'scip' });
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
references.sort((a, b) =>
|
|
443
|
-
a.chunk.file_path.localeCompare(b.chunk.file_path)
|
|
444
|
-
|| ((a.chunk.start_line || 0) - (b.chunk.start_line || 0))
|
|
445
|
-
|| (a.chunk.id < b.chunk.id ? -1 : a.chunk.id > b.chunk.id ? 1 : 0));
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
return { symbol, targetDefs, ambiguous, calls, inherits, types, references };
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// ─── HTTP route → handler resolution ──────────────────────────────────────────────
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* HTTP routes mapped to their handler chunks. Pure helper over the store contract
|
|
455
|
-
* (`db.findRoutes`), so it is backend-agnostic and importable by tests/agent-trace.
|
|
456
|
-
*
|
|
457
|
-
* Filtering:
|
|
458
|
-
* • method — optional HTTP verb, case-insensitive (GET/POST/…); omitted = all.
|
|
459
|
-
* • path — a query starting with '/' is a PREFIX match (uses the backend's
|
|
460
|
-
* indexed prefix filter); one containing '{' or ':' (a route-pattern
|
|
461
|
-
* hint) — or any other non-'/' query — is a CONTAINS match.
|
|
462
|
-
*
|
|
463
|
-
* Each result inlines the handler chunk's id/name/node_type/start_line/end_line
|
|
464
|
-
* (null when the handler isn't a chunk). Deterministically sorted (file_path, line,
|
|
465
|
-
* method, path) so the in-memory and SQLite backends return byte-identical output.
|
|
466
|
-
*
|
|
467
|
-
* @returns {Array<{method,path,handler_name,handler_chunk_id,file_path,line,framework,
|
|
468
|
-
* name,node_type,start_line,end_line,id}>}
|
|
469
|
-
*/
|
|
470
|
-
export function findRoutes(db, { method = null, path: pathQuery = null } = {}) {
|
|
471
|
-
const isPrefix = typeof pathQuery === 'string' && pathQuery.startsWith('/');
|
|
472
|
-
const base = db.findRoutes({ method: method || null, pathPrefix: isPrefix ? pathQuery : null });
|
|
473
|
-
const needle = (typeof pathQuery === 'string' && pathQuery && !isPrefix) ? pathQuery.toLowerCase() : null;
|
|
474
|
-
const rows = (needle ? base.filter(r => String(r.path || '').toLowerCase().includes(needle)) : base)
|
|
475
|
-
.map(r => {
|
|
476
|
-
const c = r.chunk || null;
|
|
477
|
-
return {
|
|
478
|
-
method: r.method, path: r.path, handler_name: r.handler_name,
|
|
479
|
-
handler_chunk_id: r.handler_chunk_id, file_path: r.file_path,
|
|
480
|
-
line: r.line, framework: r.framework,
|
|
481
|
-
name: c?.name ?? null, node_type: c?.node_type ?? null,
|
|
482
|
-
start_line: c?.start_line ?? null, end_line: c?.end_line ?? null,
|
|
483
|
-
id: c?.id ?? null,
|
|
484
|
-
};
|
|
485
|
-
});
|
|
486
|
-
rows.sort((a, b) =>
|
|
487
|
-
(a.file_path < b.file_path ? -1 : a.file_path > b.file_path ? 1 : 0)
|
|
488
|
-
|| ((a.line || 0) - (b.line || 0))
|
|
489
|
-
|| (a.method < b.method ? -1 : a.method > b.method ? 1 : 0)
|
|
490
|
-
|| (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
491
|
-
return rows;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// ─── Bounded connected-subgraph traversal (multi-hop in one call) ─────────────────
|
|
495
|
-
|
|
496
|
-
/** ~tokens for a node card (1 token ≈ 4 chars). */
|
|
497
|
-
function _subgraphCardTokens(c) {
|
|
498
|
-
return Math.ceil(`${c.class_context ? c.class_context + '.' : ''}${c.name} [${c.node_type}] ${c.file_path}:${c.start_line}-${c.end_line} ${extractSignatureLine(c.code_snippet).split('\n')[0]}`.length / 4);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/** Stable order so the subgraph is byte-identical across backends and runs. */
|
|
502
|
-
function _subgraphSort(arr) {
|
|
503
|
-
return arr.slice().sort((a, b) =>
|
|
504
|
-
(a.file_path < b.file_path ? -1 : a.file_path > b.file_path ? 1 : 0)
|
|
505
|
-
|| (a.start_line - b.start_line)
|
|
506
|
-
|| (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Breadth-first connected subgraph around a seed symbol — callees (what it calls),
|
|
511
|
-
* high-confidence callers (the precise blast radius, via classifyCallers), and
|
|
512
|
-
* type/inheritance referers — bounded by node count, hop depth AND a token budget.
|
|
513
|
-
* One call replaces the search_code → get_call_graph → find_references round-trips a
|
|
514
|
-
* "trace this flow across files" task otherwise needs. Reuses only index-time signals
|
|
515
|
-
* (no type inference); fully deterministic (every neighbour list is sorted, ties broken
|
|
516
|
-
* on id) so it is reproducible and backend-agnostic.
|
|
517
|
-
*
|
|
518
|
-
* @returns {{ seed:string, found:boolean, truncated:boolean,
|
|
519
|
-
* nodes:Array<{id,name,node_type,class_context,file_path,start_line,end_line,signature,depth}>,
|
|
520
|
-
* edges:Array<{from:string,to:string,kind:'calls'|'references'}> }}
|
|
521
|
-
*/
|
|
522
|
-
export function buildSubgraph(db, seed, { maxNodes = 12, maxDepth = 2, tokenBudget = null } = {}) {
|
|
523
|
-
const seedDefs = db.resolveSymbol(seed);
|
|
524
|
-
if (seedDefs.length === 0) return { seed, found: false, truncated: false, nodes: [], edges: [] };
|
|
525
|
-
|
|
526
|
-
const nodes = new Map(); // id → { chunk, depth }
|
|
527
|
-
const edges = [];
|
|
528
|
-
const edgeSeen = new Set();
|
|
529
|
-
let budget = (tokenBudget != null && tokenBudget > 0) ? tokenBudget : Infinity;
|
|
530
|
-
let truncated = false;
|
|
531
|
-
|
|
532
|
-
const addEdge = (from, to, kind) => {
|
|
533
|
-
if (!from || !to || from === to) return;
|
|
534
|
-
const k = `${from} ${to} ${kind}`;
|
|
535
|
-
if (!edgeSeen.has(k)) { edgeSeen.add(k); edges.push({ from, to, kind }); }
|
|
536
|
-
};
|
|
537
|
-
const tryAdd = (c, depth) => {
|
|
538
|
-
if (!c) return false;
|
|
539
|
-
if (nodes.has(c.id)) return false;
|
|
540
|
-
if (nodes.size >= maxNodes) { truncated = true; return false; }
|
|
541
|
-
const t = _subgraphCardTokens(c);
|
|
542
|
-
if (nodes.size > 0 && t > budget) { truncated = true; return false; }
|
|
543
|
-
nodes.set(c.id, { chunk: c, depth });
|
|
544
|
-
budget -= t;
|
|
545
|
-
return true;
|
|
546
|
-
};
|
|
547
|
-
|
|
548
|
-
const queue = [];
|
|
549
|
-
for (const d of _subgraphSort(seedDefs)) if (tryAdd(d, 0)) queue.push(d.id);
|
|
550
|
-
|
|
551
|
-
for (let head = 0; head < queue.length; head++) {
|
|
552
|
-
const entry = nodes.get(queue[head]);
|
|
553
|
-
if (!entry || entry.depth >= maxDepth) continue;
|
|
554
|
-
const chunk = entry.chunk;
|
|
555
|
-
const d = entry.depth + 1;
|
|
556
|
-
|
|
557
|
-
for (const name of [...new Set(chunk.calls || [])].sort()) {
|
|
558
|
-
const defs = db.resolveSymbol(name);
|
|
559
|
-
if (!defs.length) continue;
|
|
560
|
-
const target = _subgraphSort(defs)[0];
|
|
561
|
-
const added = tryAdd(target, d);
|
|
562
|
-
if (nodes.has(target.id)) addEdge(chunk.id, target.id, 'calls');
|
|
563
|
-
if (added) queue.push(target.id);
|
|
564
|
-
}
|
|
565
|
-
if (chunk.name && chunk.name !== 'anonymous') {
|
|
566
|
-
for (const caller of _subgraphSort(classifyCallers(db, chunk.name).high.map(h => h.chunk))) {
|
|
567
|
-
const added = tryAdd(caller, d);
|
|
568
|
-
if (nodes.has(caller.id)) addEdge(caller.id, chunk.id, 'calls');
|
|
569
|
-
if (added) queue.push(caller.id);
|
|
570
|
-
}
|
|
571
|
-
for (const ref of _subgraphSort(db.findReferers(chunk.name))) {
|
|
572
|
-
const added = tryAdd(ref, d);
|
|
573
|
-
if (nodes.has(ref.id)) addEdge(ref.id, chunk.id, 'references');
|
|
574
|
-
if (added) queue.push(ref.id);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const nodeList = [...nodes.values()].map(({ chunk: c, depth }) => ({
|
|
580
|
-
id: c.id, name: c.name, node_type: c.node_type, class_context: c.class_context || null,
|
|
581
|
-
file_path: c.file_path, start_line: c.start_line, end_line: c.end_line,
|
|
582
|
-
signature: extractSignatureLine(c.code_snippet).split('\n')[0].trim().slice(0, 120), depth,
|
|
583
|
-
}));
|
|
584
|
-
return { seed, found: true, truncated, nodes: nodeList, edges };
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Reverse-direction transitive blast radius (C4): from a set of seed chunks (the symbols /
|
|
589
|
-
* files about to change), the transitively-affected REFERRERS — callers, subclasses, and type
|
|
590
|
-
* users — bounded by hop depth and node count.
|
|
591
|
-
*
|
|
592
|
-
* Uses the persistent symbol graph (getEdges) when present — precise and chunk-level — and
|
|
593
|
-
* otherwise falls back to query-time classifyCallers / findReferences. HIGH-confidence
|
|
594
|
-
* referrers drive the transitive closure (a precise blast radius that does not explode on
|
|
595
|
-
* ambiguous names); the DIRECT name_only referrers of the seed are surfaced separately for the
|
|
596
|
-
* agent to verify. Fully deterministic (every list is sorted: depth, file, line, id).
|
|
597
|
-
*
|
|
598
|
-
* @param {object} db
|
|
599
|
-
* @param {object[]} seedChunks The chunks being changed.
|
|
600
|
-
* @param {object} [opts]
|
|
601
|
-
* @param {number} [opts.maxDepth] Transitive hops to follow (default 3).
|
|
602
|
-
* @param {number} [opts.maxNodes] Cap on impacted chunks (default 200).
|
|
603
|
-
* @returns {{ impacted:Array<{chunk,depth,kind}>, ambiguous:Array<{chunk,kind}>,
|
|
604
|
-
* truncated:boolean, usedGraph:boolean }}
|
|
605
|
-
*/
|
|
606
|
-
export function buildImpact(db, seedChunks, { maxDepth = 3, maxNodes = 200, precision = 'standard' } = {}) {
|
|
607
|
-
const hasGraph = typeof db.hasSymbolGraph === 'function' && db.hasSymbolGraph();
|
|
608
|
-
const seedIds = new Set(seedChunks.map(c => c.id));
|
|
609
|
-
|
|
610
|
-
// A1 precision dial. `standard` follows the high-confidence closure (resolved + high);
|
|
611
|
-
// `strict` follows ONLY provably-unambiguous bindings (the `resolved` edge tier, or — with
|
|
612
|
-
// no graph — the `proven` query-time callers) for a false-positive-free blast radius.
|
|
613
|
-
const strict = precision === 'strict';
|
|
614
|
-
const keepEdge = strict ? (c) => c === 'resolved' : (c) => c === 'high' || c === 'resolved';
|
|
615
|
-
const keepRef = (proven) => !strict || Boolean(proven);
|
|
616
|
-
|
|
617
|
-
const highReferrers = (chunk) => {
|
|
618
|
-
const out = [];
|
|
619
|
-
if (hasGraph) {
|
|
620
|
-
for (const e of db.getEdges(chunk.id, { direction: 'in' }))
|
|
621
|
-
if (e.chunk && keepEdge(e.confidence)) out.push({ chunk: e.chunk, kind: e.kind });
|
|
622
|
-
return out;
|
|
623
|
-
}
|
|
624
|
-
if (!chunk.name || chunk.name === 'anonymous') return out;
|
|
625
|
-
const tcl = chunk.class_context || null;
|
|
626
|
-
const seen = new Set();
|
|
627
|
-
for (const h of classifyCallers(db, chunk.name, { targetClass: tcl }).high)
|
|
628
|
-
if (keepRef(h.proven) && !seen.has(h.chunk.id)) { seen.add(h.chunk.id); out.push({ chunk: h.chunk, kind: 'calls' }); }
|
|
629
|
-
const refs = findReferences(db, chunk.name, { targetClass: tcl });
|
|
630
|
-
for (const r of refs.inherits) if (r.confidence === 'high' && keepRef(r.proven) && !seen.has(r.chunk.id)) { seen.add(r.chunk.id); out.push({ chunk: r.chunk, kind: 'extends' }); }
|
|
631
|
-
for (const r of refs.types) if (r.confidence === 'high' && keepRef(r.proven) && !seen.has(r.chunk.id)) { seen.add(r.chunk.id); out.push({ chunk: r.chunk, kind: 'type' }); }
|
|
632
|
-
return out;
|
|
633
|
-
};
|
|
634
|
-
const nameOnlyReferrers = (chunk) => {
|
|
635
|
-
const out = [];
|
|
636
|
-
if (hasGraph) {
|
|
637
|
-
for (const e of db.getEdges(chunk.id, { direction: 'in' }))
|
|
638
|
-
if (e.chunk && e.confidence === 'name_only') out.push({ chunk: e.chunk, kind: e.kind });
|
|
639
|
-
return out;
|
|
640
|
-
}
|
|
641
|
-
if (!chunk.name || chunk.name === 'anonymous') return out;
|
|
642
|
-
for (const n of classifyCallers(db, chunk.name, { targetClass: chunk.class_context || null }).nameOnly)
|
|
643
|
-
out.push({ chunk: n.chunk, kind: 'calls' });
|
|
644
|
-
return out;
|
|
645
|
-
};
|
|
646
|
-
|
|
647
|
-
const ambiguous = new Map();
|
|
648
|
-
for (const s of seedChunks) for (const r of nameOnlyReferrers(s))
|
|
649
|
-
if (!seedIds.has(r.chunk.id) && !ambiguous.has(r.chunk.id)) ambiguous.set(r.chunk.id, r);
|
|
650
|
-
|
|
651
|
-
const impacted = new Map();
|
|
652
|
-
const visited = new Set(seedIds);
|
|
653
|
-
let truncated = false;
|
|
654
|
-
let frontier = seedChunks.map(c => ({ chunk: c }));
|
|
655
|
-
for (let depth = 1; depth <= maxDepth && !truncated; depth++) {
|
|
656
|
-
const next = [];
|
|
657
|
-
for (const { chunk } of frontier) {
|
|
658
|
-
for (const r of highReferrers(chunk)) {
|
|
659
|
-
if (visited.has(r.chunk.id)) continue;
|
|
660
|
-
if (impacted.size >= maxNodes) { truncated = true; break; }
|
|
661
|
-
visited.add(r.chunk.id);
|
|
662
|
-
impacted.set(r.chunk.id, { chunk: r.chunk, depth, kind: r.kind });
|
|
663
|
-
next.push({ chunk: r.chunk });
|
|
664
|
-
}
|
|
665
|
-
if (truncated) break;
|
|
666
|
-
}
|
|
667
|
-
if (next.length === 0) break;
|
|
668
|
-
frontier = next;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
const byPlace = (a, b) => (a.chunk.file_path < b.chunk.file_path ? -1 : a.chunk.file_path > b.chunk.file_path ? 1 : 0)
|
|
672
|
-
|| (a.chunk.start_line - b.chunk.start_line) || (a.chunk.id < b.chunk.id ? -1 : 1);
|
|
673
|
-
const impactedList = [...impacted.values()].sort((a, b) => (a.depth - b.depth) || byPlace(a, b));
|
|
674
|
-
const ambiguousList = [...ambiguous.values()].sort(byPlace);
|
|
675
|
-
return { impacted: impactedList, ambiguous: ambiguousList, truncated, usedGraph: hasGraph, precision };
|
|
676
|
-
}
|