brainclaw 1.24.0 → 1.26.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.
Files changed (46) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-code-map.js +9 -2
  3. package/dist/commands/code-map.js +120 -6
  4. package/dist/commands/mcp-catalog.js +46 -0
  5. package/dist/commands/mcp.js +58 -6
  6. package/dist/commands/session-start.js +84 -13
  7. package/dist/core/bootstrap.js +28 -4
  8. package/dist/core/code-map/aggregate.js +36 -31
  9. package/dist/core/code-map/backend.js +162 -5
  10. package/dist/core/code-map/core.js +1 -0
  11. package/dist/core/code-map/export.js +212 -0
  12. package/dist/core/code-map/finalizer.js +57 -2
  13. package/dist/core/code-map/freshness.js +81 -15
  14. package/dist/core/code-map/impact.js +409 -0
  15. package/dist/core/code-map/indexes.js +64 -3
  16. package/dist/core/code-map/lang/python/index.js +4 -2
  17. package/dist/core/code-map/lang/query-runtime.js +2 -0
  18. package/dist/core/code-map/lang/typescript/config.js +271 -0
  19. package/dist/core/code-map/lang/typescript/index.js +24 -6
  20. package/dist/core/code-map/lang/usages.js +333 -0
  21. package/dist/core/code-map/memory-reader.js +15 -0
  22. package/dist/core/code-map/query.js +285 -71
  23. package/dist/core/code-map/refresh.js +0 -0
  24. package/dist/core/code-map/resolve.js +28 -2
  25. package/dist/core/code-map/store.js +1 -0
  26. package/dist/core/code-map/types.js +70 -9
  27. package/dist/core/code-map/vocabulary.js +6 -0
  28. package/dist/core/code-map/work-section.js +12 -14
  29. package/dist/core/context-diff.js +17 -3
  30. package/dist/core/entity-operations.js +14 -2
  31. package/dist/core/federation-pull.js +151 -3
  32. package/dist/core/federation-push.js +16 -3
  33. package/dist/core/hint-aging.js +4 -1
  34. package/dist/core/identity.js +69 -17
  35. package/dist/core/io.js +27 -0
  36. package/dist/core/project-discovery.js +7 -1
  37. package/dist/core/protocol-tool-policy.js +3 -0
  38. package/dist/core/runtime.js +23 -0
  39. package/dist/core/worktree.js +89 -2
  40. package/dist/facts.js +15 -12
  41. package/dist/facts.json +14 -11
  42. package/docs/cli.md +8 -0
  43. package/docs/code-map.md +60 -28
  44. package/docs/integrations/mcp.md +5 -2
  45. package/docs/mcp-schema-changelog.md +11 -1
  46. package/package.json +1 -1
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Bounded, explainable Code Map impact analysis.
3
+ *
4
+ * This module deliberately traverses only the persisted P1c resolution graph
5
+ * through P1d's reverse ResolutionIndex. It neither reparses files nor infers
6
+ * import edges from a naming convention. Naming is limited to a clearly marked,
7
+ * low-confidence test suggestion after resolved test imports have been reported.
8
+ */
9
+ import path from 'node:path';
10
+ import { fileId } from './ids.js';
11
+ import { makeFreshnessBadge } from './freshness.js';
12
+ import { readManifest, readResolutionIndex, readShard, readSymbolsIndex, } from './store.js';
13
+ import { deriveBadge, isTestPath, makeLazyChecker, newAccumulator, validateStoreEntry, } from './query.js';
14
+ /** Direct results and each optional transitive layer are independently bounded. */
15
+ export const IMPACT_DEPENDENT_CAP = 100;
16
+ /** A depth of one is direct only; transitives require an explicit depth of two or more. */
17
+ export const IMPACT_MAX_DEPTH = 4;
18
+ export const IMPACT_NAMING_SUGGESTION_CONFIDENCE = 0.25;
19
+ function normalizeIdentifier(value) {
20
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
21
+ }
22
+ function looksLikePathTarget(target) {
23
+ return /[\\/]/.test(target) || /\.(?:[cm]?[jt]sx?|py|php|java|go|rs|cs|rb|c|cc|cpp|cxx|h|hpp)$/i.test(target);
24
+ }
25
+ function normalizePathTarget(target, projectRoot) {
26
+ const root = path.resolve(projectRoot);
27
+ const absolute = path.resolve(root, target);
28
+ const relative = path.relative(root, absolute);
29
+ if (!relative || relative === '.' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
30
+ return null;
31
+ }
32
+ return relative.replace(/\\/g, '/');
33
+ }
34
+ function entriesForToken(index, target) {
35
+ const normalizedTarget = normalizeIdentifier(target);
36
+ if (!normalizedTarget)
37
+ return [];
38
+ const seen = new Set();
39
+ const candidates = [];
40
+ for (const entries of Object.values(index.entries)) {
41
+ for (const entry of entries) {
42
+ if (seen.has(entry.node_id))
43
+ continue;
44
+ const normalizedName = normalizeIdentifier(entry.name);
45
+ if (normalizedName === normalizedTarget || normalizedName.includes(normalizedTarget)) {
46
+ seen.add(entry.node_id);
47
+ candidates.push(entry);
48
+ }
49
+ }
50
+ }
51
+ const exact = candidates.filter((entry) => normalizeIdentifier(entry.name) === normalizedTarget);
52
+ return (exact.length > 0 ? exact : candidates).sort((a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name) || a.node_id.localeCompare(b.node_id));
53
+ }
54
+ function entriesForPath(index, target) {
55
+ const normalizedTarget = target.replace(/\\/g, '/');
56
+ const seen = new Set();
57
+ const entries = [];
58
+ for (const bucket of Object.values(index.entries)) {
59
+ for (const entry of bucket) {
60
+ const candidatePath = entry.path.replace(/\\/g, '/');
61
+ if ((candidatePath === normalizedTarget || candidatePath.endsWith(`/${normalizedTarget}`)) && !seen.has(entry.node_id)) {
62
+ seen.add(entry.node_id);
63
+ entries.push(entry);
64
+ }
65
+ }
66
+ }
67
+ return entries.sort((a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
68
+ }
69
+ function asDefinition(entry) {
70
+ return {
71
+ node_id: entry.node_id,
72
+ name: entry.name,
73
+ kind: 'symbol',
74
+ subtype: entry.subtype ?? null,
75
+ path: entry.path,
76
+ file_id: entry.file_id,
77
+ span: null,
78
+ confidence: entry.score_hint,
79
+ };
80
+ }
81
+ function fallbackReasons(entry, kind) {
82
+ const indexed = entry.reasons.filter((reason) => reason.kind === kind);
83
+ if (indexed.length > 0)
84
+ return indexed;
85
+ // Existing P1d indexes (written before P3) still have a compact aggregate.
86
+ // Preserve their factual resolution evidence rather than manufacturing a guess.
87
+ return [{
88
+ kind,
89
+ ...(entry.module ? { module: entry.module } : {}),
90
+ imported: entry.imported,
91
+ ...(typeof entry.confidence === 'number' ? { confidence: entry.confidence } : {}),
92
+ }];
93
+ }
94
+ function causeKey(cause) {
95
+ return [
96
+ cause.kind,
97
+ cause.module ?? '',
98
+ cause.imported.join('\u0000'),
99
+ String(cause.confidence ?? ''),
100
+ String(cause.source_line ?? ''),
101
+ cause.target.kind,
102
+ cause.target.path,
103
+ cause.target.node_id ?? '',
104
+ cause.caller?.node_id ?? '',
105
+ ].join('\u0001');
106
+ }
107
+ function compareCause(a, b) {
108
+ return a.kind.localeCompare(b.kind)
109
+ || a.target.path.localeCompare(b.target.path)
110
+ || (a.target.node_id ?? '').localeCompare(b.target.node_id ?? '')
111
+ || (a.caller?.node_id ?? '').localeCompare(b.caller?.node_id ?? '')
112
+ || (a.module ?? '').localeCompare(b.module ?? '')
113
+ || (a.source_line ?? -1) - (b.source_line ?? -1)
114
+ || a.imported.join('\u0000').localeCompare(b.imported.join('\u0000'))
115
+ || (a.confidence ?? -1) - (b.confidence ?? -1);
116
+ }
117
+ function addRelation(rows, entry, depth, target, kind) {
118
+ const current = rows.get(entry.path) ?? {
119
+ path: entry.path,
120
+ file_id: entry.file_id,
121
+ depth,
122
+ causes: [],
123
+ causeKeys: new Set(),
124
+ };
125
+ current.depth = Math.min(current.depth, depth);
126
+ for (const reason of fallbackReasons(entry, kind)) {
127
+ const cause = {
128
+ kind: reason.kind,
129
+ ...(reason.module ? { module: reason.module } : {}),
130
+ imported: [...reason.imported],
131
+ ...(typeof reason.confidence === 'number' ? { confidence: reason.confidence } : {}),
132
+ ...(reason.source_line !== undefined ? { source_line: reason.source_line } : {}),
133
+ target,
134
+ };
135
+ const key = causeKey(cause);
136
+ if (!current.causeKeys.has(key)) {
137
+ current.causeKeys.add(key);
138
+ current.causes.push(cause);
139
+ }
140
+ }
141
+ current.causes.sort(compareCause);
142
+ rows.set(entry.path, current);
143
+ }
144
+ function addUsageRelation(rows, entry, depth, target) {
145
+ const current = rows.get(entry.path) ?? {
146
+ path: entry.path,
147
+ file_id: entry.file_id,
148
+ depth,
149
+ causes: [],
150
+ causeKeys: new Set(),
151
+ };
152
+ current.depth = Math.min(current.depth, depth);
153
+ for (const reason of entry.reasons) {
154
+ const cause = {
155
+ kind: reason.kind,
156
+ imported: [],
157
+ confidence: reason.confidence,
158
+ ...(reason.source_line !== undefined ? { source_line: reason.source_line } : {}),
159
+ caller: { node_id: reason.caller_node_id },
160
+ target,
161
+ };
162
+ const key = causeKey(cause);
163
+ if (!current.causeKeys.has(key)) {
164
+ current.causeKeys.add(key);
165
+ current.causes.push(cause);
166
+ }
167
+ }
168
+ current.causes.sort(compareCause);
169
+ rows.set(entry.path, current);
170
+ }
171
+ function publicRelation(row) {
172
+ return {
173
+ path: row.path,
174
+ file_id: row.file_id,
175
+ depth: row.depth,
176
+ causes: row.causes,
177
+ };
178
+ }
179
+ function relationConfidence(row) {
180
+ return row.causes.reduce((best, cause) => Math.max(best, cause.confidence ?? 0), 0);
181
+ }
182
+ function testStem(filePath) {
183
+ const base = filePath.replace(/\\/g, '/').split('/').pop() ?? filePath;
184
+ return normalizeIdentifier(base
185
+ .replace(/\.[^.]+$/, '')
186
+ .replace(/(?:[._-](?:test|spec)|(?:test|spec)s?)$/i, '')
187
+ .replace(/^(?:test|spec)[_-]/i, ''));
188
+ }
189
+ function clampDepth(depth) {
190
+ if (depth === undefined || !Number.isFinite(depth))
191
+ return 1;
192
+ return Math.min(Math.max(Math.floor(depth), 1), IMPACT_MAX_DEPTH);
193
+ }
194
+ function clampLimit(limit) {
195
+ if (limit === undefined || !Number.isFinite(limit))
196
+ return IMPACT_DEPENDENT_CAP;
197
+ return Math.min(Math.max(Math.floor(limit), 0), IMPACT_DEPENDENT_CAP);
198
+ }
199
+ /**
200
+ * Read a bounded blast radius from existing resolved imports. A direct relation
201
+ * is distance 1. Supplying depth=2 (or more) opts into transitively importing
202
+ * files; depth is clamped to {@link IMPACT_MAX_DEPTH}.
203
+ */
204
+ export function impact(target, options, ctx) {
205
+ const symbolsIndex = readSymbolsIndex(ctx.cwd, ctx.preferredDirName);
206
+ const manifest = readManifest(ctx.cwd, ctx.preferredDirName);
207
+ const maxDepth = clampDepth(options?.depth);
208
+ const limit = clampLimit(options?.limit);
209
+ const checker = makeLazyChecker();
210
+ const acc = newAccumulator();
211
+ const empty = (freshness) => ({
212
+ target,
213
+ definition: { match_kind: 'none', entries: [] },
214
+ direct_dependents: [],
215
+ transitive_dependents: [],
216
+ tests_for: [],
217
+ risk: {
218
+ score: 0,
219
+ formula: 'direct_dependents + transitive_dependents',
220
+ counters: {
221
+ definitions: 0,
222
+ direct_dependents: 0,
223
+ transitive_dependents: 0,
224
+ resolved_test_files: 0,
225
+ suggested_test_files: 0,
226
+ max_depth_returned: 0,
227
+ },
228
+ },
229
+ limits: {
230
+ max_depth: maxDepth,
231
+ max_dependents_per_section: limit,
232
+ direct_truncated: false,
233
+ transitive_truncated: false,
234
+ },
235
+ freshness_badge: freshness,
236
+ });
237
+ if (!symbolsIndex || !manifest) {
238
+ return empty(makeFreshnessBadge('missing_index', { extra: { hint: 'run refresh' } }));
239
+ }
240
+ let matchKind = 'none';
241
+ let rawDefinitions = [];
242
+ if (looksLikePathTarget(target)) {
243
+ const safePath = normalizePathTarget(target, manifest.project_root);
244
+ if (safePath) {
245
+ const symbols = entriesForPath(symbolsIndex, safePath);
246
+ rawDefinitions = symbols.map(asDefinition);
247
+ if (rawDefinitions.length > 0) {
248
+ matchKind = 'path';
249
+ }
250
+ else {
251
+ const shard = readShard(fileId(manifest.project_id, safePath), ctx.cwd, ctx.preferredDirName);
252
+ const fileNode = shard?.nodes.find((node) => node.kind === 'file');
253
+ if (shard && fileNode) {
254
+ rawDefinitions = [{
255
+ node_id: fileNode.id,
256
+ name: fileNode.name,
257
+ kind: 'file',
258
+ subtype: null,
259
+ path: shard.path,
260
+ file_id: shard.file_id,
261
+ span: null,
262
+ confidence: fileNode.confidence,
263
+ }];
264
+ matchKind = 'path';
265
+ }
266
+ }
267
+ }
268
+ }
269
+ else {
270
+ const symbols = entriesForToken(symbolsIndex, target);
271
+ rawDefinitions = symbols.map(asDefinition);
272
+ if (rawDefinitions.length > 0) {
273
+ matchKind = rawDefinitions.every((entry) => normalizeIdentifier(entry.name) === normalizeIdentifier(target)) ? 'exact' : 'fuzzy';
274
+ }
275
+ }
276
+ const definitions = rawDefinitions
277
+ .filter((entry) => validateStoreEntry({ path: entry.path, file_id: entry.file_id }, checker, acc, ctx.cwd, ctx.preferredDirName))
278
+ .map((entry) => {
279
+ // SymbolIndexEntry intentionally stores only a ranking hint. The shard is
280
+ // the authoritative persisted source for the definition span/confidence.
281
+ const node = readShard(entry.file_id, ctx.cwd, ctx.preferredDirName)?.nodes.find((candidate) => candidate.id === entry.node_id);
282
+ return node ? { ...entry, span: node.span ?? null, confidence: node.confidence } : entry;
283
+ });
284
+ const definitionByNodeId = new Map(definitions.filter((entry) => entry.kind === 'symbol').map((entry) => [entry.node_id, entry]));
285
+ const definitionPaths = new Set(definitions.map((entry) => entry.path));
286
+ const resolution = readResolutionIndex(ctx.cwd, ctx.preferredDirName);
287
+ const directRows = new Map();
288
+ if (resolution) {
289
+ for (const definition of definitionByNodeId.values()) {
290
+ const target = { kind: 'symbol', path: definition.path, node_id: definition.node_id, name: definition.name };
291
+ for (const dependent of resolution.dependents_by_symbol[definition.node_id] ?? []) {
292
+ addRelation(directRows, dependent, 1, target, 'imports_symbol');
293
+ }
294
+ for (const dependent of resolution.usages_by_symbol[definition.node_id] ?? []) {
295
+ addUsageRelation(directRows, dependent, 1, target);
296
+ }
297
+ }
298
+ for (const definitionPath of definitionPaths) {
299
+ for (const dependent of resolution.dependents_by_file[definitionPath] ?? []) {
300
+ addRelation(directRows, dependent, 1, { kind: 'file', path: definitionPath }, 'resolves_to');
301
+ }
302
+ }
303
+ }
304
+ const sortedDirect = [...directRows.values()].sort((a, b) => a.path.localeCompare(b.path));
305
+ const directCandidates = sortedDirect.slice(0, limit);
306
+ const direct = directCandidates
307
+ .filter((row) => validateStoreEntry(row, checker, acc, ctx.cwd, ctx.preferredDirName))
308
+ .map(publicRelation);
309
+ const transitive = [];
310
+ let transitiveTruncated = false;
311
+ if (resolution && maxDepth > 1 && limit > 0) {
312
+ const visited = new Set([...definitionPaths, ...direct.map((row) => row.path)]);
313
+ const queue = direct.map((row) => ({ path: row.path, depth: 1 }));
314
+ for (let offset = 0; offset < queue.length; offset++) {
315
+ const current = queue[offset];
316
+ if (current.depth >= maxDepth)
317
+ continue;
318
+ for (const dependent of resolution.dependents_by_file[current.path] ?? []) {
319
+ if (visited.has(dependent.path))
320
+ continue;
321
+ visited.add(dependent.path);
322
+ if (transitive.length >= limit) {
323
+ transitiveTruncated = true;
324
+ continue;
325
+ }
326
+ const rowMap = new Map();
327
+ addRelation(rowMap, dependent, current.depth + 1, { kind: 'file', path: current.path }, 'resolves_to');
328
+ const row = rowMap.get(dependent.path);
329
+ if (!validateStoreEntry(row, checker, acc, ctx.cwd, ctx.preferredDirName))
330
+ continue;
331
+ const output = publicRelation(row);
332
+ transitive.push(output);
333
+ queue.push({ path: output.path, depth: output.depth });
334
+ }
335
+ }
336
+ }
337
+ transitive.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
338
+ const confirmedTests = [...direct, ...transitive]
339
+ .filter((row) => isTestPath(row.path))
340
+ .map((row) => ({
341
+ path: row.path,
342
+ file_id: row.file_id,
343
+ relation: 'resolved_import',
344
+ confidence: relationConfidence(row),
345
+ depth: row.depth,
346
+ causes: row.causes,
347
+ reason: `resolved import at graph depth ${row.depth}`,
348
+ }));
349
+ const confirmedPaths = new Set(confirmedTests.map((test) => test.path));
350
+ const targetNames = new Set([
351
+ ...definitions.map((definition) => normalizeIdentifier(definition.name)),
352
+ ...definitions.map((definition) => testStem(definition.path)),
353
+ ].filter(Boolean));
354
+ const suggestions = new Map();
355
+ if (targetNames.size > 0) {
356
+ const seenFiles = new Map();
357
+ for (const entries of Object.values(symbolsIndex.entries)) {
358
+ for (const entry of entries)
359
+ if (!seenFiles.has(entry.path))
360
+ seenFiles.set(entry.path, entry.file_id);
361
+ }
362
+ for (const [testPath, testFileId] of seenFiles) {
363
+ if (suggestions.size >= limit || confirmedPaths.has(testPath) || !isTestPath(testPath))
364
+ continue;
365
+ if (!targetNames.has(testStem(testPath)))
366
+ continue;
367
+ if (!validateStoreEntry({ path: testPath, file_id: testFileId }, checker, acc, ctx.cwd, ctx.preferredDirName))
368
+ continue;
369
+ suggestions.set(testPath, {
370
+ path: testPath,
371
+ file_id: testFileId,
372
+ relation: 'naming_convention_suggestion',
373
+ confidence: IMPACT_NAMING_SUGGESTION_CONFIDENCE,
374
+ reason: 'filename convention matches the target; no resolved import proves this relationship',
375
+ });
376
+ }
377
+ }
378
+ const testsFor = [...confirmedTests, ...suggestions.values()].sort((a, b) => a.relation.localeCompare(b.relation) || a.path.localeCompare(b.path));
379
+ const maxDepthReturned = Math.max(0, ...direct.map((row) => row.depth), ...transitive.map((row) => row.depth));
380
+ const risk = {
381
+ score: direct.length + transitive.length,
382
+ formula: 'direct_dependents + transitive_dependents',
383
+ counters: {
384
+ definitions: definitions.length,
385
+ direct_dependents: direct.length,
386
+ transitive_dependents: transitive.length,
387
+ resolved_test_files: confirmedTests.length,
388
+ suggested_test_files: suggestions.size,
389
+ max_depth_returned: maxDepthReturned,
390
+ },
391
+ };
392
+ const freshnessBadge = deriveBadge(manifest.freshness.status, acc, checker.exhausted, definitions.length > 0 || direct.length > 0 || transitive.length > 0, definitions.length === 0);
393
+ return {
394
+ target,
395
+ definition: { match_kind: definitions.length > 0 ? matchKind : 'none', entries: definitions },
396
+ direct_dependents: direct,
397
+ transitive_dependents: transitive,
398
+ tests_for: testsFor,
399
+ risk,
400
+ limits: {
401
+ max_depth: maxDepth,
402
+ max_dependents_per_section: limit,
403
+ direct_truncated: sortedDirect.length > limit,
404
+ transitive_truncated: transitiveTruncated,
405
+ },
406
+ freshness_badge: freshnessBadge,
407
+ };
408
+ }
409
+ //# sourceMappingURL=impact.js.map
@@ -130,7 +130,30 @@ export function buildResolutionIndex(projectId, shards) {
130
130
  // target key -> (importer path -> merged entry)
131
131
  const byFile = new Map();
132
132
  const bySymbol = new Map();
133
- const addDependent = (bucket, targetKey, importerPath, importerFileId, module, imported, confidence) => {
133
+ const byUsageSymbol = new Map();
134
+ const addUsage = (targetSymbolId, importerPath, importerFileId, edge) => {
135
+ const perImporter = byUsageSymbol.get(targetSymbolId) ?? new Map();
136
+ const entry = perImporter.get(importerPath) ?? { path: importerPath, file_id: importerFileId, reasons: [] };
137
+ const reason = {
138
+ kind: edge.kind,
139
+ caller_node_id: edge.from,
140
+ confidence: edge.confidence,
141
+ ...(edge.source?.line !== undefined ? { source_line: edge.source.line } : {}),
142
+ };
143
+ if (!entry.reasons.some((existing) => existing.kind === reason.kind
144
+ && existing.caller_node_id === reason.caller_node_id
145
+ && existing.confidence === reason.confidence
146
+ && existing.source_line === reason.source_line)) {
147
+ entry.reasons.push(reason);
148
+ entry.reasons.sort((a, b) => a.kind.localeCompare(b.kind)
149
+ || a.caller_node_id.localeCompare(b.caller_node_id)
150
+ || (a.source_line ?? -1) - (b.source_line ?? -1)
151
+ || a.confidence - b.confidence);
152
+ }
153
+ perImporter.set(importerPath, entry);
154
+ byUsageSymbol.set(targetSymbolId, perImporter);
155
+ };
156
+ const addDependent = (bucket, targetKey, importerPath, importerFileId, module, imported, confidence, kind, sourceLine) => {
134
157
  const perImporter = bucket.get(targetKey) ?? new Map();
135
158
  const prev = perImporter.get(importerPath);
136
159
  if (!prev) {
@@ -140,6 +163,7 @@ export function buildResolutionIndex(projectId, shards) {
140
163
  module,
141
164
  imported: [...new Set(imported)].sort(),
142
165
  confidence,
166
+ reasons: [],
143
167
  });
144
168
  }
145
169
  else {
@@ -154,6 +178,29 @@ export function buildResolutionIndex(projectId, shards) {
154
178
  prev.confidence = typeof prev.confidence === 'number' ? Math.max(prev.confidence, confidence) : confidence;
155
179
  }
156
180
  }
181
+ const current = perImporter.get(importerPath);
182
+ const reason = {
183
+ kind,
184
+ ...(module ? { module } : {}),
185
+ imported: [...new Set(imported)].sort(),
186
+ ...(typeof confidence === 'number' ? { confidence } : {}),
187
+ ...(sourceLine !== undefined ? { source_line: sourceLine } : {}),
188
+ };
189
+ // Multiple module nodes can resolve to one target from one importer. Keep
190
+ // every concrete cause, deduping an identical extractor edge defensively.
191
+ if (!current.reasons.some((existing) => existing.kind === reason.kind
192
+ && existing.module === reason.module
193
+ && existing.confidence === reason.confidence
194
+ && existing.source_line === reason.source_line
195
+ && existing.imported.length === reason.imported.length
196
+ && existing.imported.every((name, index) => name === reason.imported[index]))) {
197
+ current.reasons.push(reason);
198
+ current.reasons.sort((a, b) => a.kind.localeCompare(b.kind)
199
+ || (a.module ?? '').localeCompare(b.module ?? '')
200
+ || (a.source_line ?? -1) - (b.source_line ?? -1)
201
+ || a.imported.join('\0').localeCompare(b.imported.join('\0'))
202
+ || (a.confidence ?? -1) - (b.confidence ?? -1));
203
+ }
157
204
  bucket.set(targetKey, perImporter);
158
205
  };
159
206
  const ordered = [...shards].sort((a, b) => a.path.localeCompare(b.path));
@@ -165,6 +212,12 @@ export function buildResolutionIndex(projectId, shards) {
165
212
  moduleById.set(n.id, { name: n.name, imported: n.imported_names ?? [] });
166
213
  }
167
214
  for (const e of shard.edges) {
215
+ if (e.kind === 'calls' || e.kind === 'references') {
216
+ // `possible_textual_match` remains deliberately absent: it is a hint on
217
+ // the shard, never an impact dependency.
218
+ addUsage(e.to, shard.path, shard.file_id, { ...e, kind: e.kind });
219
+ continue;
220
+ }
168
221
  if (e.kind !== 'resolves_to' && e.kind !== 'imports_symbol')
169
222
  continue;
170
223
  const mod = moduleById.get(e.from);
@@ -172,10 +225,10 @@ export function buildResolutionIndex(projectId, shards) {
172
225
  const targetPath = fileNodeIdToPath.get(e.to);
173
226
  if (!targetPath)
174
227
  continue; // target id not an indexed file (defensive)
175
- addDependent(byFile, targetPath, shard.path, shard.file_id, mod?.name, mod?.imported ?? [], e.confidence);
228
+ addDependent(byFile, targetPath, shard.path, shard.file_id, mod?.name, mod?.imported ?? [], e.confidence, 'resolves_to', e.source?.line);
176
229
  }
177
230
  else {
178
- addDependent(bySymbol, e.to, shard.path, shard.file_id, mod?.name, mod?.imported ?? [], e.confidence);
231
+ addDependent(bySymbol, e.to, shard.path, shard.file_id, mod?.name, mod?.imported ?? [], e.confidence, 'imports_symbol', e.source?.line);
179
232
  }
180
233
  }
181
234
  }
@@ -186,12 +239,20 @@ export function buildResolutionIndex(projectId, shards) {
186
239
  }
187
240
  return out;
188
241
  };
242
+ const finalizeUsages = (bucket) => {
243
+ const out = Object.create(null);
244
+ for (const key of [...bucket.keys()].sort()) {
245
+ out[key] = [...bucket.get(key).values()].sort((a, b) => a.path.localeCompare(b.path));
246
+ }
247
+ return out;
248
+ };
189
249
  return {
190
250
  schema_version: CODE_MAP_SCHEMA_VERSION,
191
251
  project_id: projectId,
192
252
  updated_at: new Date().toISOString(),
193
253
  dependents_by_file: finalize(byFile),
194
254
  dependents_by_symbol: finalize(bySymbol),
255
+ usages_by_symbol: finalizeUsages(byUsageSymbol),
195
256
  };
196
257
  }
197
258
  //# sourceMappingURL=indexes.js.map
@@ -30,6 +30,7 @@ import path from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
31
  import { loadGrammarWasm, grammarHashForWasm } from '../../wasm-loader.js';
32
32
  import { extractWithQueries } from '../query-runtime.js';
33
+ import { extractLexicalUsages } from '../usages.js';
33
34
  const HERE = path.dirname(fileURLToPath(import.meta.url));
34
35
  /** The python grammar .wasm: dist basename + node_modules devDep fallback spec. */
35
36
  const PY_WASM_BASENAME = 'tree-sitter-python.wasm';
@@ -106,7 +107,7 @@ const queries = {
106
107
  };
107
108
  const vocabulary = {
108
109
  nodeSubtypes: ['function', 'method', 'class', 'variable', 'constant', 'property'],
109
- edgeKinds: ['contains', 'defines', 'imports'],
110
+ edgeKinds: ['contains', 'defines', 'imports', 'calls', 'references', 'possible_textual_match'],
110
111
  captureMap: queries.captureMap,
111
112
  };
112
113
  const capabilities = {
@@ -284,7 +285,8 @@ export class PythonProvider {
284
285
  }
285
286
  return d;
286
287
  });
287
- return { ...draft, definitions };
288
+ const tree = draft.attributes?.__tree;
289
+ return { ...draft, definitions, usages: extractLexicalUsages(tree?.rootNode, definitions, 'python') };
288
290
  }
289
291
  /**
290
292
  * P1c file-level import resolution (T3). Returns at most one resolution per import:
@@ -121,6 +121,7 @@ export async function extractWithQueries(input) {
121
121
  imports: [],
122
122
  exports: [],
123
123
  tests: [],
124
+ usages: [],
124
125
  facts,
125
126
  attributes: { parseStatus, __tree: tree },
126
127
  });
@@ -367,6 +368,7 @@ export async function extractWithQueries(input) {
367
368
  imports,
368
369
  exports,
369
370
  tests: [],
371
+ usages: [],
370
372
  facts,
371
373
  attributes: { parseStatus, __tree: tree },
372
374
  };