@vyuhlabs/dxkit 2.6.0 → 2.7.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 +54 -13
- package/README.md +208 -459
- package/dist/analyzers/bom/discovery.d.ts +3 -4
- package/dist/analyzers/bom/discovery.d.ts.map +1 -1
- package/dist/analyzers/bom/discovery.js +3 -4
- package/dist/analyzers/bom/discovery.js.map +1 -1
- package/dist/analyzers/bom/types.d.ts +1 -1
- package/dist/analyzers/dashboard/index.d.ts.map +1 -1
- package/dist/analyzers/dashboard/index.js +42 -5
- package/dist/analyzers/dashboard/index.js.map +1 -1
- package/dist/analyzers/quality/detailed.d.ts +8 -1
- package/dist/analyzers/quality/detailed.d.ts.map +1 -1
- package/dist/analyzers/quality/detailed.js +43 -10
- package/dist/analyzers/quality/detailed.js.map +1 -1
- package/dist/analyzers/security/detailed.d.ts +8 -1
- package/dist/analyzers/security/detailed.d.ts.map +1 -1
- package/dist/analyzers/security/detailed.js +14 -1
- package/dist/analyzers/security/detailed.js.map +1 -1
- package/dist/analyzers/tests/detailed.d.ts +8 -1
- package/dist/analyzers/tests/detailed.d.ts.map +1 -1
- package/dist/analyzers/tests/detailed.js +26 -7
- package/dist/analyzers/tests/detailed.js.map +1 -1
- package/dist/analyzers/tools/cloc.js +3 -3
- package/dist/analyzers/tools/cloc.js.map +1 -1
- package/dist/analyzers/tools/exclusions.d.ts +12 -12
- package/dist/analyzers/tools/exclusions.d.ts.map +1 -1
- package/dist/analyzers/tools/exclusions.js +27 -13
- package/dist/analyzers/tools/exclusions.js.map +1 -1
- package/dist/analyzers/tools/graphify.d.ts +39 -5
- package/dist/analyzers/tools/graphify.d.ts.map +1 -1
- package/dist/analyzers/tools/graphify.js +609 -45
- package/dist/analyzers/tools/graphify.js.map +1 -1
- package/dist/analyzers/tools/nuget-package-reference.d.ts +4 -4
- package/dist/analyzers/tools/nuget-package-reference.js +4 -4
- package/dist/analyzers/tools/osv-scanner-fix.d.ts +4 -5
- package/dist/analyzers/tools/osv-scanner-fix.d.ts.map +1 -1
- package/dist/analyzers/tools/osv-scanner-fix.js +4 -5
- package/dist/analyzers/tools/osv-scanner-fix.js.map +1 -1
- package/dist/analyzers/tools/parallel.d.ts.map +1 -1
- package/dist/analyzers/tools/parallel.js +7 -0
- package/dist/analyzers/tools/parallel.js.map +1 -1
- package/dist/analyzers/tools/vendored-advisor.d.ts.map +1 -1
- package/dist/analyzers/tools/vendored-advisor.js +3 -4
- package/dist/analyzers/tools/vendored-advisor.js.map +1 -1
- package/dist/analyzers/xlsx/licenses.d.ts +7 -7
- package/dist/analyzers/xlsx/licenses.js +7 -7
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +80 -3
- package/dist/cli.js.map +1 -1
- package/dist/dashboard/graph-adapter.d.ts +151 -0
- package/dist/dashboard/graph-adapter.d.ts.map +1 -0
- package/dist/dashboard/graph-adapter.js +415 -0
- package/dist/dashboard/graph-adapter.js.map +1 -0
- package/dist/dashboard/graph-tab.d.ts +109 -0
- package/dist/dashboard/graph-tab.d.ts.map +1 -0
- package/dist/dashboard/graph-tab.js +297 -0
- package/dist/dashboard/graph-tab.js.map +1 -0
- package/dist/dashboard/vendor/vis-network.min.js +34 -0
- package/dist/explore/cli/api-surface.d.ts +12 -0
- package/dist/explore/cli/api-surface.d.ts.map +1 -0
- package/dist/explore/cli/api-surface.js +57 -0
- package/dist/explore/cli/api-surface.js.map +1 -0
- package/dist/explore/cli/communities.d.ts +10 -0
- package/dist/explore/cli/communities.d.ts.map +1 -0
- package/dist/explore/cli/communities.js +47 -0
- package/dist/explore/cli/communities.js.map +1 -0
- package/dist/explore/cli/context.d.ts +16 -0
- package/dist/explore/cli/context.d.ts.map +1 -0
- package/dist/explore/cli/context.js +118 -0
- package/dist/explore/cli/context.js.map +1 -0
- package/dist/explore/cli/entry-points.d.ts +12 -0
- package/dist/explore/cli/entry-points.d.ts.map +1 -0
- package/dist/explore/cli/entry-points.js +85 -0
- package/dist/explore/cli/entry-points.js.map +1 -0
- package/dist/explore/cli/feature.d.ts +16 -0
- package/dist/explore/cli/feature.d.ts.map +1 -0
- package/dist/explore/cli/feature.js +89 -0
- package/dist/explore/cli/feature.js.map +1 -0
- package/dist/explore/cli/file.d.ts +12 -0
- package/dist/explore/cli/file.d.ts.map +1 -0
- package/dist/explore/cli/file.js +139 -0
- package/dist/explore/cli/file.js.map +1 -0
- package/dist/explore/cli/hot-files.d.ts +11 -0
- package/dist/explore/cli/hot-files.d.ts.map +1 -0
- package/dist/explore/cli/hot-files.js +63 -0
- package/dist/explore/cli/hot-files.js.map +1 -0
- package/dist/explore/context-hook.d.ts +42 -0
- package/dist/explore/context-hook.d.ts.map +1 -0
- package/dist/explore/context-hook.js +131 -0
- package/dist/explore/context-hook.js.map +1 -0
- package/dist/explore/finding-context.d.ts +69 -0
- package/dist/explore/finding-context.d.ts.map +1 -0
- package/dist/explore/finding-context.js +102 -0
- package/dist/explore/finding-context.js.map +1 -0
- package/dist/explore/format.d.ts +64 -0
- package/dist/explore/format.d.ts.map +1 -0
- package/dist/explore/format.js +99 -0
- package/dist/explore/format.js.map +1 -0
- package/dist/explore/load.d.ts +50 -0
- package/dist/explore/load.d.ts.map +1 -0
- package/dist/explore/load.js +197 -0
- package/dist/explore/load.js.map +1 -0
- package/dist/explore/queries.d.ts +413 -0
- package/dist/explore/queries.d.ts.map +1 -0
- package/dist/explore/queries.js +855 -0
- package/dist/explore/queries.js.map +1 -0
- package/dist/explore/types.d.ts +130 -0
- package/dist/explore/types.d.ts.map +1 -0
- package/dist/explore/types.js +28 -0
- package/dist/explore/types.js.map +1 -0
- package/dist/explore-cli.d.ts +45 -0
- package/dist/explore-cli.d.ts.map +1 -0
- package/dist/explore-cli.js +213 -0
- package/dist/explore-cli.js.map +1 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +19 -0
- package/dist/generator.js.map +1 -1
- package/dist/languages/csharp.d.ts.map +1 -1
- package/dist/languages/csharp.js +31 -11
- package/dist/languages/csharp.js.map +1 -1
- package/dist/languages/go.d.ts.map +1 -1
- package/dist/languages/go.js +4 -0
- package/dist/languages/go.js.map +1 -1
- package/dist/languages/index.d.ts +27 -0
- package/dist/languages/index.d.ts.map +1 -1
- package/dist/languages/index.js +35 -0
- package/dist/languages/index.js.map +1 -1
- package/dist/languages/java.d.ts.map +1 -1
- package/dist/languages/java.js +4 -0
- package/dist/languages/java.js.map +1 -1
- package/dist/languages/kotlin.d.ts.map +1 -1
- package/dist/languages/kotlin.js +4 -0
- package/dist/languages/kotlin.js.map +1 -1
- package/dist/languages/python.d.ts.map +1 -1
- package/dist/languages/python.js +4 -0
- package/dist/languages/python.js.map +1 -1
- package/dist/languages/ruby.d.ts.map +1 -1
- package/dist/languages/ruby.js +4 -0
- package/dist/languages/ruby.js.map +1 -1
- package/dist/languages/rust.d.ts.map +1 -1
- package/dist/languages/rust.js +4 -0
- package/dist/languages/rust.js.map +1 -1
- package/dist/languages/types.d.ts +54 -0
- package/dist/languages/types.d.ts.map +1 -1
- package/dist/languages/typescript.d.ts.map +1 -1
- package/dist/languages/typescript.js +5 -1
- package/dist/languages/typescript.js.map +1 -1
- package/package.json +2 -1
- package/templates/.claude/skills/dxkit-action/SKILL.md +21 -1
- package/templates/.claude/skills/dxkit-reports/SKILL.md +3 -1
- package/templates/AGENTS.md.template +8 -1
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Canonical graph query module. Per CLAUDE.md Rule 12, every consumer
|
|
4
|
+
* (explore CLI subcommands, dashboard viz adapter, future 2.8 context
|
|
5
|
+
* CLI, future 2.8 reachability) imports from here — never reimplements
|
|
6
|
+
* graph traversal. Arch-check enforces.
|
|
7
|
+
*
|
|
8
|
+
* Sprint 1 ships the SKELETON: type signatures + empty implementations
|
|
9
|
+
* so the canonical entry points exist for the arch rule to lock onto.
|
|
10
|
+
* Sprint 2 fills the bodies as the explore CLI subcommands land.
|
|
11
|
+
*
|
|
12
|
+
* Every query is a pure function: takes a `Graph` (and optionally
|
|
13
|
+
* other args), returns a typed result. No side effects, no I/O,
|
|
14
|
+
* no caching — caching belongs at the loader level, not the query
|
|
15
|
+
* level.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.callersOf = callersOf;
|
|
19
|
+
exports.calleesOf = calleesOf;
|
|
20
|
+
exports.nodesInFile = nodesInFile;
|
|
21
|
+
exports.hotFilesQuery = hotFilesQuery;
|
|
22
|
+
exports.communitiesQuery = communitiesQuery;
|
|
23
|
+
exports.fileSummaryQuery = fileSummaryQuery;
|
|
24
|
+
exports.entryPointsQuery = entryPointsQuery;
|
|
25
|
+
exports.apiSurfaceQuery = apiSurfaceQuery;
|
|
26
|
+
exports.featureQuery = featureQuery;
|
|
27
|
+
exports.contextQuery = contextQuery;
|
|
28
|
+
exports.findingContextQuery = findingContextQuery;
|
|
29
|
+
// ─── Low-level primitives ────────────────────────────────────────────────────
|
|
30
|
+
/** Nodes that call into the given nodeId (predecessors via `calls` edges). */
|
|
31
|
+
function callersOf(graph, nodeId) {
|
|
32
|
+
const incoming = graph.edgesToNode.get(nodeId) ?? [];
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const e of incoming) {
|
|
35
|
+
if (e.relation !== 'calls')
|
|
36
|
+
continue;
|
|
37
|
+
const n = graph.nodeById.get(e.from);
|
|
38
|
+
if (n)
|
|
39
|
+
out.push(n);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Nodes that the given nodeId calls into (successors via `calls` edges). */
|
|
44
|
+
function calleesOf(graph, nodeId) {
|
|
45
|
+
const outgoing = graph.edgesFromNode.get(nodeId) ?? [];
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const e of outgoing) {
|
|
48
|
+
if (e.relation !== 'calls')
|
|
49
|
+
continue;
|
|
50
|
+
const n = graph.nodeById.get(e.to);
|
|
51
|
+
if (n)
|
|
52
|
+
out.push(n);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/** All nodes declared in the given source file. */
|
|
57
|
+
function nodesInFile(graph, sourceFile) {
|
|
58
|
+
return [...(graph.nodesByFile.get(sourceFile) ?? [])];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Top-N files by total in-degree (callers + importers). The
|
|
62
|
+
* "centrality" proxy — files many other files depend on. Useful as
|
|
63
|
+
* a "what's the foundational layer of this repo?" answer.
|
|
64
|
+
*
|
|
65
|
+
* Files are derived from the union of `sourceFile` across all nodes;
|
|
66
|
+
* the per-file aggregation traverses each node's inbound/outbound
|
|
67
|
+
* edges. Limit defaults to 20 per the Sprint 0 spec.
|
|
68
|
+
*/
|
|
69
|
+
function hotFilesQuery(graph, limit = 20) {
|
|
70
|
+
const perFile = new Map();
|
|
71
|
+
for (const node of graph.nodes) {
|
|
72
|
+
if (!node.sourceFile)
|
|
73
|
+
continue;
|
|
74
|
+
let agg = perFile.get(node.sourceFile);
|
|
75
|
+
if (!agg) {
|
|
76
|
+
agg = { callsIn: 0, callsOut: 0, nodes: [] };
|
|
77
|
+
perFile.set(node.sourceFile, agg);
|
|
78
|
+
}
|
|
79
|
+
agg.nodes.push(node);
|
|
80
|
+
for (const e of graph.edgesToNode.get(node.id) ?? []) {
|
|
81
|
+
if (e.relation === 'calls')
|
|
82
|
+
agg.callsIn++;
|
|
83
|
+
}
|
|
84
|
+
for (const e of graph.edgesFromNode.get(node.id) ?? []) {
|
|
85
|
+
if (e.relation === 'calls')
|
|
86
|
+
agg.callsOut++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Imports-in: count edges into the FILE's module node. Module
|
|
90
|
+
// nodes have `kind === 'module'` and their `sourceFile` IS the
|
|
91
|
+
// file path. Aggregate to the file by matching on that.
|
|
92
|
+
const importsInByFile = new Map();
|
|
93
|
+
for (const node of graph.nodes) {
|
|
94
|
+
if (node.kind !== 'module' || !node.sourceFile)
|
|
95
|
+
continue;
|
|
96
|
+
let count = 0;
|
|
97
|
+
for (const e of graph.edgesToNode.get(node.id) ?? []) {
|
|
98
|
+
if (e.relation === 'imports_from')
|
|
99
|
+
count++;
|
|
100
|
+
}
|
|
101
|
+
importsInByFile.set(node.sourceFile, (importsInByFile.get(node.sourceFile) ?? 0) + count);
|
|
102
|
+
}
|
|
103
|
+
const results = [];
|
|
104
|
+
for (const [sourceFile, agg] of perFile) {
|
|
105
|
+
const importsIn = importsInByFile.get(sourceFile) ?? 0;
|
|
106
|
+
// Pick a community via any of the file's nodes — module node
|
|
107
|
+
// first if present, else any symbol's community.
|
|
108
|
+
const moduleNode = agg.nodes.find((n) => n.kind === 'module');
|
|
109
|
+
const sampleNode = moduleNode ?? agg.nodes[0];
|
|
110
|
+
const community = sampleNode ? graph.communityByNode.get(sampleNode.id) : undefined;
|
|
111
|
+
results.push({
|
|
112
|
+
sourceFile,
|
|
113
|
+
callsIn: agg.callsIn,
|
|
114
|
+
importsIn,
|
|
115
|
+
callsOut: agg.callsOut,
|
|
116
|
+
communityId: community?.id,
|
|
117
|
+
communityLabel: community?.dominantSourceDir || undefined,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Rank by total in-degree (calls + imports). Ties broken by
|
|
121
|
+
// alphabetical source file path for stable output.
|
|
122
|
+
results.sort((a, b) => {
|
|
123
|
+
const ai = a.callsIn + a.importsIn;
|
|
124
|
+
const bi = b.callsIn + b.importsIn;
|
|
125
|
+
if (bi !== ai)
|
|
126
|
+
return bi - ai;
|
|
127
|
+
return a.sourceFile.localeCompare(b.sourceFile);
|
|
128
|
+
});
|
|
129
|
+
return results.slice(0, limit);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Top-N communities by node count, with each community's top-3 hot
|
|
133
|
+
* files (by in-degree within the community). Gives a "what are the
|
|
134
|
+
* natural modules in this repo?" answer that complements `hot-files`
|
|
135
|
+
* (which is global).
|
|
136
|
+
*/
|
|
137
|
+
function communitiesQuery(graph, limit = 8) {
|
|
138
|
+
const callsInByNode = computeCallsInByNode(graph);
|
|
139
|
+
const sortedCommunities = [...graph.communities].sort((a, b) => b.nodeIds.length - a.nodeIds.length);
|
|
140
|
+
return sortedCommunities.slice(0, limit).map((c) => {
|
|
141
|
+
// Per-file in-degree within this community only.
|
|
142
|
+
const inDegByFile = new Map();
|
|
143
|
+
for (const nid of c.nodeIds) {
|
|
144
|
+
const node = graph.nodeById.get(nid);
|
|
145
|
+
if (!node?.sourceFile)
|
|
146
|
+
continue;
|
|
147
|
+
const d = callsInByNode.get(nid) ?? 0;
|
|
148
|
+
inDegByFile.set(node.sourceFile, (inDegByFile.get(node.sourceFile) ?? 0) + d);
|
|
149
|
+
}
|
|
150
|
+
const topHotFiles = [...inDegByFile.entries()]
|
|
151
|
+
.filter(([, d]) => d > 0)
|
|
152
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
153
|
+
.slice(0, 3)
|
|
154
|
+
.map(([f]) => f);
|
|
155
|
+
return {
|
|
156
|
+
id: c.id,
|
|
157
|
+
nodeCount: c.nodeIds.length,
|
|
158
|
+
dominantSourceDir: c.dominantSourceDir,
|
|
159
|
+
dominantPack: c.dominantPack,
|
|
160
|
+
cohesion: c.cohesion,
|
|
161
|
+
topHotFiles,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Internal helper: precompute per-node call in-degree. Shared between
|
|
167
|
+
* `hotFilesQuery` (file-level aggregation) and `communitiesQuery`
|
|
168
|
+
* (community-bounded ranking). Per CLAUDE.md Rule 2 — one source of
|
|
169
|
+
* truth for the calls-in-degree counter.
|
|
170
|
+
*/
|
|
171
|
+
function computeCallsInByNode(graph) {
|
|
172
|
+
const m = new Map();
|
|
173
|
+
for (const node of graph.nodes) {
|
|
174
|
+
let d = 0;
|
|
175
|
+
for (const e of graph.edgesToNode.get(node.id) ?? []) {
|
|
176
|
+
if (e.relation === 'calls')
|
|
177
|
+
d++;
|
|
178
|
+
}
|
|
179
|
+
if (d > 0)
|
|
180
|
+
m.set(node.id, d);
|
|
181
|
+
}
|
|
182
|
+
return m;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Build the per-file summary. `found: false` when the file isn't in
|
|
186
|
+
* the graph (excluded by minified detection / vendored / unsupported
|
|
187
|
+
* extension); the consumer handles that case with an explanatory
|
|
188
|
+
* note instead of an empty result.
|
|
189
|
+
*/
|
|
190
|
+
function fileSummaryQuery(graph, sourceFile) {
|
|
191
|
+
const nodes = nodesInFile(graph, sourceFile);
|
|
192
|
+
if (nodes.length === 0) {
|
|
193
|
+
return {
|
|
194
|
+
sourceFile,
|
|
195
|
+
found: false,
|
|
196
|
+
symbols: [],
|
|
197
|
+
callerFiles: [],
|
|
198
|
+
calleeFiles: [],
|
|
199
|
+
importsIn: [],
|
|
200
|
+
importsOut: [],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
// Per-symbol summary for non-module nodes.
|
|
204
|
+
const symbols = [];
|
|
205
|
+
// Caller / callee aggregation, deduped at the file level.
|
|
206
|
+
const callerCounts = new Map();
|
|
207
|
+
const calleeCounts = new Map();
|
|
208
|
+
// Imports in/out aggregation against the file's module node.
|
|
209
|
+
const importsInFiles = new Set();
|
|
210
|
+
const importsOutFiles = new Set();
|
|
211
|
+
for (const node of nodes) {
|
|
212
|
+
if (node.kind !== 'module') {
|
|
213
|
+
let inCalls = 0;
|
|
214
|
+
let outCalls = 0;
|
|
215
|
+
for (const e of graph.edgesToNode.get(node.id) ?? []) {
|
|
216
|
+
if (e.relation === 'calls') {
|
|
217
|
+
inCalls++;
|
|
218
|
+
const src = graph.nodeById.get(e.from);
|
|
219
|
+
if (src?.sourceFile && src.sourceFile !== sourceFile) {
|
|
220
|
+
callerCounts.set(src.sourceFile, (callerCounts.get(src.sourceFile) ?? 0) + 1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const e of graph.edgesFromNode.get(node.id) ?? []) {
|
|
225
|
+
if (e.relation === 'calls') {
|
|
226
|
+
outCalls++;
|
|
227
|
+
const dst = graph.nodeById.get(e.to);
|
|
228
|
+
if (dst?.sourceFile && dst.sourceFile !== sourceFile) {
|
|
229
|
+
calleeCounts.set(dst.sourceFile, (calleeCounts.get(dst.sourceFile) ?? 0) + 1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
symbols.push({
|
|
234
|
+
id: node.id,
|
|
235
|
+
kind: node.kind,
|
|
236
|
+
label: node.label,
|
|
237
|
+
line: node.line,
|
|
238
|
+
exported: node.exported,
|
|
239
|
+
callsIn: inCalls,
|
|
240
|
+
callsOut: outCalls,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
// Module node: harvest imports edges.
|
|
245
|
+
for (const e of graph.edgesToNode.get(node.id) ?? []) {
|
|
246
|
+
if (e.relation === 'imports_from') {
|
|
247
|
+
const src = graph.nodeById.get(e.from);
|
|
248
|
+
if (src?.sourceFile && src.sourceFile !== sourceFile) {
|
|
249
|
+
importsInFiles.add(src.sourceFile);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const e of graph.edgesFromNode.get(node.id) ?? []) {
|
|
254
|
+
if (e.relation === 'imports_from') {
|
|
255
|
+
const dst = graph.nodeById.get(e.to);
|
|
256
|
+
if (dst?.sourceFile && dst.sourceFile !== sourceFile) {
|
|
257
|
+
importsOutFiles.add(dst.sourceFile);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Pick a representative node for community lookup — module first,
|
|
264
|
+
// else any symbol.
|
|
265
|
+
const moduleNode = nodes.find((n) => n.kind === 'module');
|
|
266
|
+
const sampleNode = moduleNode ?? nodes[0];
|
|
267
|
+
const community = sampleNode ? graph.communityByNode.get(sampleNode.id) : undefined;
|
|
268
|
+
return {
|
|
269
|
+
sourceFile,
|
|
270
|
+
found: true,
|
|
271
|
+
symbols: symbols.sort((a, b) => b.callsIn - a.callsIn || a.label.localeCompare(b.label)),
|
|
272
|
+
callerFiles: [...callerCounts.entries()]
|
|
273
|
+
.map(([sourceFile, count]) => ({ sourceFile, count }))
|
|
274
|
+
.sort((a, b) => b.count - a.count || a.sourceFile.localeCompare(b.sourceFile)),
|
|
275
|
+
calleeFiles: [...calleeCounts.entries()]
|
|
276
|
+
.map(([sourceFile, count]) => ({ sourceFile, count }))
|
|
277
|
+
.sort((a, b) => b.count - a.count || a.sourceFile.localeCompare(b.sourceFile)),
|
|
278
|
+
importsIn: [...importsInFiles].sort().map((sourceFile) => ({ sourceFile })),
|
|
279
|
+
importsOut: [...importsOutFiles].sort().map((sourceFile) => ({ sourceFile })),
|
|
280
|
+
communityId: community?.id,
|
|
281
|
+
communityLabel: community?.dominantSourceDir || undefined,
|
|
282
|
+
communityPack: community?.dominantPack || undefined,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Discover entry-point symbols by intersecting graph nodes with the
|
|
287
|
+
* union of active packs' `primaryComponentPaths` + `routePaths`. The
|
|
288
|
+
* rank is by call out-degree — entry points typically fan OUT (they
|
|
289
|
+
* receive a request, then call many downstream functions). A high
|
|
290
|
+
* out-degree node in a primary-architecture path is almost certainly
|
|
291
|
+
* a real entry point.
|
|
292
|
+
*
|
|
293
|
+
* `flags` is the per-pack boolean map from `DetectedStack.languages`;
|
|
294
|
+
* only patterns from active packs contribute. This matches the
|
|
295
|
+
* existing pack-driven analyzer pattern.
|
|
296
|
+
*/
|
|
297
|
+
function entryPointsQuery(graph, primaryPaths, routePaths, limit = 10) {
|
|
298
|
+
if (primaryPaths.length === 0 && routePaths.length === 0) {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
// Classify each source file by whether it matches a pattern.
|
|
302
|
+
// Patterns are case-insensitive substrings of the relative POSIX
|
|
303
|
+
// path (per the architecturalShape contract). routePaths overlap
|
|
304
|
+
// primaryComponentPaths in many packs; tag a file by the most
|
|
305
|
+
// specific match (route > primary > none).
|
|
306
|
+
const classify = (sourceFile) => {
|
|
307
|
+
const lower = sourceFile.toLowerCase();
|
|
308
|
+
for (const p of routePaths) {
|
|
309
|
+
if (lower.includes(p.toLowerCase())) {
|
|
310
|
+
return { matched: true, label: patternLabel(p), isRoute: true };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
for (const p of primaryPaths) {
|
|
314
|
+
if (lower.includes(p.toLowerCase())) {
|
|
315
|
+
return { matched: true, label: patternLabel(p), isRoute: false };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { matched: false, label: '', isRoute: false };
|
|
319
|
+
};
|
|
320
|
+
const results = [];
|
|
321
|
+
for (const node of graph.nodes) {
|
|
322
|
+
if (node.kind === 'module')
|
|
323
|
+
continue;
|
|
324
|
+
if (!node.sourceFile)
|
|
325
|
+
continue;
|
|
326
|
+
const c = classify(node.sourceFile);
|
|
327
|
+
if (!c.matched)
|
|
328
|
+
continue;
|
|
329
|
+
let callsOut = 0;
|
|
330
|
+
for (const e of graph.edgesFromNode.get(node.id) ?? []) {
|
|
331
|
+
if (e.relation === 'calls')
|
|
332
|
+
callsOut++;
|
|
333
|
+
}
|
|
334
|
+
if (callsOut === 0)
|
|
335
|
+
continue; // entry points fan out; zero-out-degree symbols aren't entry points
|
|
336
|
+
// Pack: derive from extension via the helper below. Avoids a
|
|
337
|
+
// second pack registry import — keeps queries.ts independent of
|
|
338
|
+
// languages/index.ts for this lookup.
|
|
339
|
+
results.push({
|
|
340
|
+
sourceFile: node.sourceFile,
|
|
341
|
+
line: node.line,
|
|
342
|
+
symbol: node.label,
|
|
343
|
+
componentType: c.label,
|
|
344
|
+
callsOut,
|
|
345
|
+
pack: packFromExt(node.sourceFile),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// Rank by callsOut desc, ties by sourceFile asc for stability.
|
|
349
|
+
results.sort((a, b) => b.callsOut - a.callsOut || a.sourceFile.localeCompare(b.sourceFile));
|
|
350
|
+
return results.slice(0, limit);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Extract a human-readable label from a path pattern. E.g.
|
|
354
|
+
* `/controllers/` → `controllers`, `/Forms/` → `forms`.
|
|
355
|
+
*/
|
|
356
|
+
function patternLabel(pattern) {
|
|
357
|
+
return pattern.replace(/[/\\]/g, '').toLowerCase();
|
|
358
|
+
}
|
|
359
|
+
// Per-extension pack id derivation. Mirrors EXT_TO_PACK in the
|
|
360
|
+
// Python script. Kept here as a private helper rather than imported
|
|
361
|
+
// from the registry to keep queries.ts importable without pulling
|
|
362
|
+
// the whole pack module surface.
|
|
363
|
+
function packFromExt(sourceFile) {
|
|
364
|
+
const i = sourceFile.lastIndexOf('.');
|
|
365
|
+
if (i < 0)
|
|
366
|
+
return '';
|
|
367
|
+
const ext = sourceFile.slice(i).toLowerCase();
|
|
368
|
+
const map = {
|
|
369
|
+
'.ts': 'typescript',
|
|
370
|
+
'.tsx': 'typescript',
|
|
371
|
+
'.js': 'typescript',
|
|
372
|
+
'.jsx': 'typescript',
|
|
373
|
+
'.mjs': 'typescript',
|
|
374
|
+
'.cjs': 'typescript',
|
|
375
|
+
'.py': 'python',
|
|
376
|
+
'.go': 'go',
|
|
377
|
+
'.rs': 'rust',
|
|
378
|
+
'.cs': 'csharp',
|
|
379
|
+
'.kt': 'kotlin',
|
|
380
|
+
'.kts': 'kotlin',
|
|
381
|
+
'.java': 'java',
|
|
382
|
+
'.rb': 'ruby',
|
|
383
|
+
};
|
|
384
|
+
return map[ext] ?? '';
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Find exported symbols with zero internal callers. `packsExcluded`
|
|
388
|
+
* lists pack ids whose `exportDetection.reliability === 'unreliable'`
|
|
389
|
+
* — those packs' nodes are skipped because we can't trust their
|
|
390
|
+
* `exported` flag (today: ruby). The consumer surfaces the exclusion
|
|
391
|
+
* as a note in its output.
|
|
392
|
+
*/
|
|
393
|
+
function apiSurfaceQuery(graph, packsExcluded, limit = 25) {
|
|
394
|
+
const excluded = new Set(packsExcluded);
|
|
395
|
+
const results = [];
|
|
396
|
+
for (const node of graph.nodes) {
|
|
397
|
+
if (node.kind === 'module')
|
|
398
|
+
continue;
|
|
399
|
+
if (node.exported !== true)
|
|
400
|
+
continue; // absent or false → skip
|
|
401
|
+
const pack = packFromExt(node.sourceFile);
|
|
402
|
+
if (excluded.has(pack))
|
|
403
|
+
continue;
|
|
404
|
+
// Zero internal callers — check inbound calls edges. Note: the
|
|
405
|
+
// calls in-degree includes potential graphify same-name conflicts
|
|
406
|
+
// (run() at one site can attract calls meant for run() at another),
|
|
407
|
+
// so this is a "best effort" — but consumers know that's the limit.
|
|
408
|
+
let hasCaller = false;
|
|
409
|
+
for (const e of graph.edgesToNode.get(node.id) ?? []) {
|
|
410
|
+
if (e.relation === 'calls') {
|
|
411
|
+
hasCaller = true;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (hasCaller)
|
|
416
|
+
continue;
|
|
417
|
+
results.push({
|
|
418
|
+
sourceFile: node.sourceFile,
|
|
419
|
+
line: node.line,
|
|
420
|
+
symbol: node.label,
|
|
421
|
+
kind: node.kind,
|
|
422
|
+
pack,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Sort by sourceFile asc (groups by file naturally) then line asc.
|
|
426
|
+
results.sort((a, b) => a.sourceFile.localeCompare(b.sourceFile) || (a.line ?? 0) - (b.line ?? 0));
|
|
427
|
+
return results.slice(0, limit);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The marquee query — "where is feature X implemented?" Three-stage
|
|
431
|
+
* resolution:
|
|
432
|
+
*
|
|
433
|
+
* 1. Direct symbolIndex lookup (case-insensitive, exact match on
|
|
434
|
+
* the stripped name)
|
|
435
|
+
* 2. Substring expansion (opt-in via opts.substring) — scans every
|
|
436
|
+
* node's label for substring match
|
|
437
|
+
* 3. Structural expansion — for each seed, gather community
|
|
438
|
+
* membership + immediate callers + callees, group by community
|
|
439
|
+
*
|
|
440
|
+
* On zero hits, computes edit-distance suggestions against the
|
|
441
|
+
* symbolIndex keys so the caller can prompt the user with "did you
|
|
442
|
+
* mean..."
|
|
443
|
+
*/
|
|
444
|
+
function featureQuery(graph, keyword, opts = {}) {
|
|
445
|
+
const limit = opts.limit ?? 50;
|
|
446
|
+
const kw = keyword.toLowerCase().trim();
|
|
447
|
+
if (!kw) {
|
|
448
|
+
return { results: [], suggestions: [] };
|
|
449
|
+
}
|
|
450
|
+
// Stage 1 + 2: direct symbolIndex match + optional substring expansion.
|
|
451
|
+
const seedIds = findSeedIds(graph, kw, opts.substring ?? false);
|
|
452
|
+
if (seedIds.size === 0) {
|
|
453
|
+
return { results: [], suggestions: suggestionsFor(graph, kw) };
|
|
454
|
+
}
|
|
455
|
+
// Stage 3: structural expansion. For each seed, gather its
|
|
456
|
+
// community + direct callers/callees. Group expanded set by
|
|
457
|
+
// community id.
|
|
458
|
+
const expandedByComm = new Map();
|
|
459
|
+
const unclusteredExpansion = new Set();
|
|
460
|
+
for (const seedId of seedIds) {
|
|
461
|
+
const community = graph.communityByNode.get(seedId);
|
|
462
|
+
const bucket = community
|
|
463
|
+
? (expandedByComm.get(community.id) ?? new Set())
|
|
464
|
+
: unclusteredExpansion;
|
|
465
|
+
bucket.add(seedId);
|
|
466
|
+
// Direct callers (1 hop)
|
|
467
|
+
for (const e of graph.edgesToNode.get(seedId) ?? []) {
|
|
468
|
+
if (e.relation === 'calls')
|
|
469
|
+
bucket.add(e.from);
|
|
470
|
+
}
|
|
471
|
+
// Direct callees (1 hop)
|
|
472
|
+
for (const e of graph.edgesFromNode.get(seedId) ?? []) {
|
|
473
|
+
if (e.relation === 'calls')
|
|
474
|
+
bucket.add(e.to);
|
|
475
|
+
}
|
|
476
|
+
if (community)
|
|
477
|
+
expandedByComm.set(community.id, bucket);
|
|
478
|
+
}
|
|
479
|
+
// Build cluster objects, ranked by seed count then size.
|
|
480
|
+
const clusters = [];
|
|
481
|
+
let clusterIdx = 0;
|
|
482
|
+
const buildCluster = (nodeIds, community) => {
|
|
483
|
+
const files = new Set();
|
|
484
|
+
const keySymbols = new Set();
|
|
485
|
+
let seedHits = 0;
|
|
486
|
+
for (const nid of nodeIds) {
|
|
487
|
+
const node = graph.nodeById.get(nid);
|
|
488
|
+
if (!node)
|
|
489
|
+
continue;
|
|
490
|
+
if (node.sourceFile)
|
|
491
|
+
files.add(node.sourceFile);
|
|
492
|
+
if (seedIds.has(nid)) {
|
|
493
|
+
seedHits++;
|
|
494
|
+
// Promote seed nodes to keySymbols list.
|
|
495
|
+
const stripped = stripParens(node.label);
|
|
496
|
+
if (stripped)
|
|
497
|
+
keySymbols.add(stripped);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Top-8 key symbols by alpha for stable output.
|
|
501
|
+
const keySymbolsList = [...keySymbols].sort().slice(0, 8);
|
|
502
|
+
const filesList = [...files].sort();
|
|
503
|
+
const role = roleLabel(community);
|
|
504
|
+
return {
|
|
505
|
+
clusterId: clusterIdx++,
|
|
506
|
+
communityId: community?.id,
|
|
507
|
+
role,
|
|
508
|
+
dominantSourceDir: community?.dominantSourceDir ?? '',
|
|
509
|
+
files: filesList,
|
|
510
|
+
keySymbols: keySymbolsList,
|
|
511
|
+
seedHits,
|
|
512
|
+
};
|
|
513
|
+
};
|
|
514
|
+
for (const [commId, ids] of expandedByComm) {
|
|
515
|
+
const community = graph.communityById.get(commId);
|
|
516
|
+
clusters.push(buildCluster(ids, community));
|
|
517
|
+
}
|
|
518
|
+
if (unclusteredExpansion.size > 0) {
|
|
519
|
+
clusters.push(buildCluster(unclusteredExpansion, undefined));
|
|
520
|
+
}
|
|
521
|
+
// Rank clusters by seedHits desc, then size desc, then community id asc.
|
|
522
|
+
clusters.sort((a, b) => b.seedHits - a.seedHits ||
|
|
523
|
+
b.files.length - a.files.length ||
|
|
524
|
+
(a.communityId ?? 9999) - (b.communityId ?? 9999));
|
|
525
|
+
const limitedClusters = clusters.slice(0, limit);
|
|
526
|
+
// Central entry point: across all seed ids, the one with the
|
|
527
|
+
// highest call in-degree globally.
|
|
528
|
+
let centralId;
|
|
529
|
+
let centralCount = 0;
|
|
530
|
+
for (const seedId of seedIds) {
|
|
531
|
+
let inDeg = 0;
|
|
532
|
+
for (const e of graph.edgesToNode.get(seedId) ?? []) {
|
|
533
|
+
if (e.relation === 'calls')
|
|
534
|
+
inDeg++;
|
|
535
|
+
}
|
|
536
|
+
if (inDeg > centralCount) {
|
|
537
|
+
centralCount = inDeg;
|
|
538
|
+
centralId = seedId;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
let centralEntryPoint;
|
|
542
|
+
if (centralId && centralCount > 0) {
|
|
543
|
+
const n = graph.nodeById.get(centralId);
|
|
544
|
+
if (n) {
|
|
545
|
+
centralEntryPoint = {
|
|
546
|
+
sourceFile: n.sourceFile,
|
|
547
|
+
line: n.line,
|
|
548
|
+
symbol: n.label,
|
|
549
|
+
calledFrom: centralCount,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return { results: limitedClusters, suggestions: [], centralEntryPoint };
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* The marquee token-reduction primitive — "give me just the relevant
|
|
557
|
+
* structural slice for this query." Resolves seeds the same way
|
|
558
|
+
* `featureQuery` does (shared `findSeedIds`), then expands breadth-
|
|
559
|
+
* first through `calls` edges, stopping when the running token
|
|
560
|
+
* estimate fills the budget (or an optional `maxDepth` ceiling is
|
|
561
|
+
* reached). Adaptive depth falls out for free: a hot symbol's
|
|
562
|
+
* immediate neighbors fill the budget at hop 1, while a cold symbol's
|
|
563
|
+
* sparse neighborhood leaves room to reach hop 2+.
|
|
564
|
+
*
|
|
565
|
+
* Pure: no I/O, no formatting. Same `Graph` in → same `ContextResult`
|
|
566
|
+
* out.
|
|
567
|
+
*/
|
|
568
|
+
function contextQuery(graph, keyword, opts = {}) {
|
|
569
|
+
const budget = opts.budget ?? 2000;
|
|
570
|
+
const tokensPerNode = opts.tokensPerNode ?? 15;
|
|
571
|
+
const maxDepth = opts.maxDepth ?? Infinity;
|
|
572
|
+
const kw = keyword.toLowerCase().trim();
|
|
573
|
+
const empty = {
|
|
574
|
+
query: keyword,
|
|
575
|
+
matched: false,
|
|
576
|
+
selection: [],
|
|
577
|
+
byCommunity: [],
|
|
578
|
+
blastRadius: { callers: 0, callerFiles: 0 },
|
|
579
|
+
truncated: false,
|
|
580
|
+
omittedCount: 0,
|
|
581
|
+
estimatedTokens: 0,
|
|
582
|
+
budget,
|
|
583
|
+
suggestions: [],
|
|
584
|
+
};
|
|
585
|
+
if (!kw)
|
|
586
|
+
return empty;
|
|
587
|
+
const seedIds = findSeedIds(graph, kw, opts.substring ?? false);
|
|
588
|
+
if (seedIds.size === 0) {
|
|
589
|
+
return { ...empty, suggestions: suggestionsFor(graph, kw) };
|
|
590
|
+
}
|
|
591
|
+
// Budget-bounded BFS over `calls` edges. The queue carries (id, hop);
|
|
592
|
+
// seeds enter at hop 0. We add a node to the selection only if its
|
|
593
|
+
// estimated render cost still fits the budget; the first node that
|
|
594
|
+
// would overflow flips `truncated` and we drain the remaining queue
|
|
595
|
+
// into `omittedCount` (a lower bound — honest "+N more").
|
|
596
|
+
const selection = [];
|
|
597
|
+
const visited = new Set();
|
|
598
|
+
const queue = [];
|
|
599
|
+
for (const id of seedIds)
|
|
600
|
+
queue.push({ id, hop: 0 });
|
|
601
|
+
let estimatedTokens = 0;
|
|
602
|
+
let truncated = false;
|
|
603
|
+
let omittedCount = 0;
|
|
604
|
+
while (queue.length > 0) {
|
|
605
|
+
const { id, hop } = queue.shift();
|
|
606
|
+
if (visited.has(id))
|
|
607
|
+
continue;
|
|
608
|
+
visited.add(id);
|
|
609
|
+
const node = graph.nodeById.get(id);
|
|
610
|
+
if (!node || node.kind === 'module')
|
|
611
|
+
continue;
|
|
612
|
+
if (estimatedTokens + tokensPerNode > budget) {
|
|
613
|
+
// Budget exhausted — this node + everything still queued is omitted.
|
|
614
|
+
truncated = true;
|
|
615
|
+
omittedCount++;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const callers = callersOf(graph, id);
|
|
619
|
+
const callees = calleesOf(graph, id);
|
|
620
|
+
selection.push({
|
|
621
|
+
id,
|
|
622
|
+
symbol: stripParens(node.label),
|
|
623
|
+
sourceFile: node.sourceFile,
|
|
624
|
+
line: node.line,
|
|
625
|
+
kind: node.kind,
|
|
626
|
+
hop,
|
|
627
|
+
callsIn: callers.length,
|
|
628
|
+
callsOut: callees.length,
|
|
629
|
+
});
|
|
630
|
+
estimatedTokens += tokensPerNode;
|
|
631
|
+
if (hop < maxDepth) {
|
|
632
|
+
for (const n of callers)
|
|
633
|
+
if (!visited.has(n.id))
|
|
634
|
+
queue.push({ id: n.id, hop: hop + 1 });
|
|
635
|
+
for (const n of callees)
|
|
636
|
+
if (!visited.has(n.id))
|
|
637
|
+
queue.push({ id: n.id, hop: hop + 1 });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Anchor: highest call-in-degree seed (the "start here" symbol).
|
|
641
|
+
let anchor;
|
|
642
|
+
let anchorCount = -1;
|
|
643
|
+
for (const seedId of seedIds) {
|
|
644
|
+
const node = graph.nodeById.get(seedId);
|
|
645
|
+
if (!node)
|
|
646
|
+
continue;
|
|
647
|
+
const inDeg = callersOf(graph, seedId).length;
|
|
648
|
+
if (inDeg > anchorCount) {
|
|
649
|
+
anchorCount = inDeg;
|
|
650
|
+
anchor = {
|
|
651
|
+
sourceFile: node.sourceFile,
|
|
652
|
+
line: node.line,
|
|
653
|
+
symbol: stripParens(node.label),
|
|
654
|
+
calledFrom: inDeg,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Blast radius: unique callers of the SEEDS + distinct caller files
|
|
659
|
+
// (the surface a change to the matched symbols would touch).
|
|
660
|
+
const callerIds = new Set();
|
|
661
|
+
const callerFiles = new Set();
|
|
662
|
+
for (const seedId of seedIds) {
|
|
663
|
+
for (const caller of callersOf(graph, seedId)) {
|
|
664
|
+
callerIds.add(caller.id);
|
|
665
|
+
if (caller.sourceFile)
|
|
666
|
+
callerFiles.add(caller.sourceFile);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Group the selection by community for orientation. Communities
|
|
670
|
+
// containing a SEED (hop-0) symbol rank first — the reader cares
|
|
671
|
+
// most about where the matched symbols live, not the biggest
|
|
672
|
+
// incidental cluster the BFS fanned into. (graphify often lumps
|
|
673
|
+
// much of a repo into one mega-community; sorting purely by size
|
|
674
|
+
// would bury the seed under that grab-bag.)
|
|
675
|
+
const groupsByComm = new Map();
|
|
676
|
+
for (const sel of selection) {
|
|
677
|
+
const community = graph.communityByNode.get(sel.id);
|
|
678
|
+
const key = community?.id;
|
|
679
|
+
let group = groupsByComm.get(key);
|
|
680
|
+
if (!group) {
|
|
681
|
+
group = {
|
|
682
|
+
communityId: community?.id,
|
|
683
|
+
role: roleLabel(community),
|
|
684
|
+
files: [],
|
|
685
|
+
symbols: [],
|
|
686
|
+
hasSeed: false,
|
|
687
|
+
};
|
|
688
|
+
groupsByComm.set(key, group);
|
|
689
|
+
}
|
|
690
|
+
if (sel.sourceFile && !group.files.includes(sel.sourceFile))
|
|
691
|
+
group.files.push(sel.sourceFile);
|
|
692
|
+
group.symbols.push(sel.symbol);
|
|
693
|
+
if (sel.hop === 0)
|
|
694
|
+
group.hasSeed = true;
|
|
695
|
+
}
|
|
696
|
+
const byCommunity = [...groupsByComm.values()]
|
|
697
|
+
.sort((a, b) => Number(b.hasSeed) - Number(a.hasSeed) ||
|
|
698
|
+
b.symbols.length - a.symbols.length ||
|
|
699
|
+
(a.communityId ?? 9999) - (b.communityId ?? 9999))
|
|
700
|
+
.map(({ hasSeed: _hasSeed, ...group }) => group);
|
|
701
|
+
return {
|
|
702
|
+
query: keyword,
|
|
703
|
+
matched: true,
|
|
704
|
+
anchor,
|
|
705
|
+
selection,
|
|
706
|
+
byCommunity,
|
|
707
|
+
blastRadius: { callers: callerIds.size, callerFiles: callerFiles.size },
|
|
708
|
+
truncated,
|
|
709
|
+
omittedCount,
|
|
710
|
+
estimatedTokens,
|
|
711
|
+
budget,
|
|
712
|
+
suggestions: [],
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Graph context for one finding location. Reuses `fileSummaryQuery`
|
|
717
|
+
* for the file-level caller aggregation + community lookup (Rule 2 —
|
|
718
|
+
* one source of truth for "who depends on this file"), then maps an
|
|
719
|
+
* optional `line` to the nearest enclosing declaration.
|
|
720
|
+
*
|
|
721
|
+
* Pure: same `Graph` + location in → same `FindingContext` out. The
|
|
722
|
+
* enrichment adapter (`src/explore/finding-context.ts`) owns the
|
|
723
|
+
* graph load, the per-finding loop, and the dedupe budget.
|
|
724
|
+
*/
|
|
725
|
+
function findingContextQuery(graph, sourceFile, line, opts = {}) {
|
|
726
|
+
const topN = opts.topCallerFiles ?? 5;
|
|
727
|
+
const summary = fileSummaryQuery(graph, sourceFile);
|
|
728
|
+
if (!summary.found) {
|
|
729
|
+
return {
|
|
730
|
+
found: false,
|
|
731
|
+
sourceFile,
|
|
732
|
+
blastRadius: { callerFiles: 0, callers: 0, topCallerFiles: [] },
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const callers = summary.callerFiles.reduce((acc, c) => acc + c.count, 0);
|
|
736
|
+
const topCallerFiles = summary.callerFiles.slice(0, topN).map((c) => c.sourceFile);
|
|
737
|
+
let enclosingSymbol;
|
|
738
|
+
if (typeof line === 'number') {
|
|
739
|
+
let best;
|
|
740
|
+
for (const sym of summary.symbols) {
|
|
741
|
+
if (typeof sym.line !== 'number')
|
|
742
|
+
continue;
|
|
743
|
+
if (sym.line <= line && (best?.line === undefined || sym.line > best.line)) {
|
|
744
|
+
best = sym;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (best)
|
|
748
|
+
enclosingSymbol = { symbol: stripParens(best.label), line: best.line };
|
|
749
|
+
}
|
|
750
|
+
const community = summary.communityId !== undefined
|
|
751
|
+
? {
|
|
752
|
+
id: summary.communityId,
|
|
753
|
+
role: summary.communityLabel ?? `community-${summary.communityId}`,
|
|
754
|
+
}
|
|
755
|
+
: { role: 'unclustered' };
|
|
756
|
+
return {
|
|
757
|
+
found: true,
|
|
758
|
+
sourceFile,
|
|
759
|
+
community,
|
|
760
|
+
blastRadius: { callerFiles: summary.callerFiles.length, callers, topCallerFiles },
|
|
761
|
+
enclosingSymbol,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
function roleLabel(community) {
|
|
765
|
+
if (!community)
|
|
766
|
+
return 'unclustered';
|
|
767
|
+
if (community.dominantSourceDir)
|
|
768
|
+
return community.dominantSourceDir;
|
|
769
|
+
return `community-${community.id}`;
|
|
770
|
+
}
|
|
771
|
+
function stripParens(label) {
|
|
772
|
+
if (!label)
|
|
773
|
+
return '';
|
|
774
|
+
let s = label.replace(/\(\)$/, '');
|
|
775
|
+
if (s.includes('.'))
|
|
776
|
+
s = s.split('.').pop() ?? s;
|
|
777
|
+
return s;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Iterative Levenshtein with O(min(a, b)) memory. Fast enough for
|
|
781
|
+
* the suggestions scan (a few hundred symbolIndex keys × ≤20 chars).
|
|
782
|
+
*/
|
|
783
|
+
function levenshtein(a, b) {
|
|
784
|
+
if (a === b)
|
|
785
|
+
return 0;
|
|
786
|
+
if (a.length === 0)
|
|
787
|
+
return b.length;
|
|
788
|
+
if (b.length === 0)
|
|
789
|
+
return a.length;
|
|
790
|
+
const long = a.length >= b.length ? a : b;
|
|
791
|
+
const short = a.length >= b.length ? b : a;
|
|
792
|
+
let prev = new Array(short.length + 1);
|
|
793
|
+
let curr = new Array(short.length + 1);
|
|
794
|
+
for (let i = 0; i <= short.length; i++)
|
|
795
|
+
prev[i] = i;
|
|
796
|
+
for (let i = 1; i <= long.length; i++) {
|
|
797
|
+
curr[0] = i;
|
|
798
|
+
for (let j = 1; j <= short.length; j++) {
|
|
799
|
+
const cost = long[i - 1] === short[j - 1] ? 0 : 1;
|
|
800
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
801
|
+
}
|
|
802
|
+
[prev, curr] = [curr, prev];
|
|
803
|
+
}
|
|
804
|
+
return prev[short.length];
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Resolve seed node ids for a (lowercased, trimmed) keyword. Stage 1
|
|
808
|
+
* is an exact symbolIndex hit; stage 2 (opt-in) adds substring
|
|
809
|
+
* matches across the index keys. Shared by `featureQuery` and
|
|
810
|
+
* `contextQuery` so the two surfaces match identically — one source
|
|
811
|
+
* of truth for "what does this keyword resolve to" (Rule 2).
|
|
812
|
+
*/
|
|
813
|
+
function findSeedIds(graph, kw, substring) {
|
|
814
|
+
const seedIds = new Set(graph.symbolIndex[kw] ?? []);
|
|
815
|
+
if (substring) {
|
|
816
|
+
for (const [key, ids] of Object.entries(graph.symbolIndex)) {
|
|
817
|
+
if (key.includes(kw)) {
|
|
818
|
+
for (const id of ids)
|
|
819
|
+
seedIds.add(id);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return seedIds;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* "Did you mean" suggestions for a keyword that matched no symbols.
|
|
827
|
+
* Two merged flavors: substring matches (symbols whose name CONTAINS
|
|
828
|
+
* the keyword — the common case, since users rarely type exact long
|
|
829
|
+
* symbol names) and Levenshtein ≤2 typo candidates. Substring is only
|
|
830
|
+
* tried for keywords of length ≥ 3 (shorter generates too many false
|
|
831
|
+
* positives). Top-5 by hit count. Shared by `featureQuery` +
|
|
832
|
+
* `contextQuery`.
|
|
833
|
+
*/
|
|
834
|
+
function suggestionsFor(graph, kw) {
|
|
835
|
+
const suggestions = [];
|
|
836
|
+
const seen = new Set();
|
|
837
|
+
if (kw.length >= 3) {
|
|
838
|
+
for (const key of Object.keys(graph.symbolIndex)) {
|
|
839
|
+
if (key.includes(kw)) {
|
|
840
|
+
suggestions.push({ key, hits: graph.symbolIndex[key].length });
|
|
841
|
+
seen.add(key);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
for (const key of Object.keys(graph.symbolIndex)) {
|
|
846
|
+
if (seen.has(key))
|
|
847
|
+
continue;
|
|
848
|
+
if (levenshtein(kw, key) <= 2) {
|
|
849
|
+
suggestions.push({ key, hits: graph.symbolIndex[key].length });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
suggestions.sort((a, b) => b.hits - a.hits || a.key.localeCompare(b.key));
|
|
853
|
+
return suggestions.slice(0, 5);
|
|
854
|
+
}
|
|
855
|
+
//# sourceMappingURL=queries.js.map
|