weavatrix 0.2.5 → 0.2.7

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 (41) hide show
  1. package/README.md +77 -14
  2. package/SECURITY.md +14 -0
  3. package/docs/agent-task-benchmark.md +58 -0
  4. package/docs/releases/v0.2.7.md +93 -0
  5. package/package.json +6 -1
  6. package/scripts/run-agent-task-benchmark.mjs +119 -0
  7. package/skill/SKILL.md +31 -10
  8. package/src/analysis/allowed-test-runner.js +59 -0
  9. package/src/analysis/data-flow-evidence.js +114 -0
  10. package/src/analysis/dead-code-review.js +51 -0
  11. package/src/analysis/dep-check.js +42 -3
  12. package/src/analysis/duplicate-groups.js +84 -0
  13. package/src/analysis/graph-analysis.aggregate.js +3 -1
  14. package/src/analysis/graph-analysis.refs.js +19 -11
  15. package/src/analysis/hot-path-review.js +20 -2
  16. package/src/analysis/internal-audit.run.js +6 -1
  17. package/src/analysis/task-retrieval.js +116 -0
  18. package/src/bounds.js +4 -0
  19. package/src/graph/builder/lang-js.js +52 -17
  20. package/src/graph/freshness-probe.js +4 -2
  21. package/src/graph/incremental-refresh.js +4 -2
  22. package/src/graph/internal-builder.barrels.js +39 -2
  23. package/src/graph/internal-builder.build.js +58 -12
  24. package/src/mcp/catalog.mjs +28 -6
  25. package/src/mcp/graph-context.mjs +5 -1
  26. package/src/mcp/graph-diff.mjs +1 -1
  27. package/src/mcp/sync-payload.mjs +1 -1
  28. package/src/mcp/tools-actions.mjs +3 -1
  29. package/src/mcp/tools-context.mjs +164 -0
  30. package/src/mcp/tools-graph.mjs +48 -3
  31. package/src/mcp/tools-health.mjs +15 -28
  32. package/src/mcp/tools-impact-change.mjs +1 -0
  33. package/src/mcp/tools-source.mjs +7 -6
  34. package/src/mcp/tools-verified-change.mjs +169 -0
  35. package/src/precision/lsp-client.js +1 -1
  36. package/src/precision/lsp-overlay.js +1 -5
  37. package/src/precision/symbol-query.js +1 -5
  38. package/src/security/malware-heuristics.exclusions.js +3 -3
  39. package/src/security/malware-heuristics.roots.js +4 -1
  40. package/src/security/malware-heuristics.scan.js +4 -2
  41. package/src/security/malware-scoring.js +22 -5
@@ -0,0 +1,116 @@
1
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
2
+
3
+ const words = (value) => new Set(String(value || '').toLowerCase().match(/[\p{L}_$][\p{L}\p{N}_$-]{2,}/gu) || [])
4
+ const fileOf = (node) => String(node?.source_file || (String(node?.id || '').includes('#') ? String(node.id).split('#')[0] : node?.id || '')).replace(/\\/g, '/')
5
+ const isSymbol = (id) => String(id || '').includes('#')
6
+ const NON_PRODUCT_CLASSES = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
7
+ const CLASS_TERMS = Object.freeze({
8
+ test: ['test', 'tests', 'testing', 'spec', 'coverage', 'verify'],
9
+ e2e: ['e2e', 'playwright', 'cypress'],
10
+ generated: ['generated', 'autogenerated', 'dist'],
11
+ mock: ['mock', 'mocks', 'fixture', 'fixtures', 'fake'],
12
+ story: ['story', 'stories', 'storybook'],
13
+ docs: ['doc', 'docs', 'documentation', 'readme', 'guide'],
14
+ benchmark: ['benchmark', 'benchmarks', 'bench'],
15
+ temp: ['temp', 'temporary', 'tmp'],
16
+ })
17
+
18
+ const INTENT_TRANSLATIONS = [
19
+ [/авториз|аутентиф|логин|сесси|токен/iu, 'auth authentication login session token'],
20
+ [/маршрут|роут|эндпоинт|апи|http/iu, 'route router endpoint api http'],
21
+ [/тест|покрыти/iu, 'test spec coverage verify'],
22
+ [/к[эе]ш|хранилищ/iu, 'cache store storage'],
23
+ [/баз[аы]|запрос|sql/iu, 'database query sql'],
24
+ [/конфиг|настройк/iu, 'config settings configuration'],
25
+ [/безопас|вредонос|секрет/iu, 'security malware secret'],
26
+ [/зависим|импорт/iu, 'dependency import module'],
27
+ [/дублик|клон/iu, 'duplicate clone'],
28
+ ]
29
+
30
+ export function expandTaskQuery(task) {
31
+ const text = String(task || '')
32
+ const expansions = INTENT_TRANSLATIONS.filter(([pattern]) => pattern.test(text)).map(([, value]) => value)
33
+ return [...new Set([text, ...expansions])].join(' ')
34
+ }
35
+
36
+ function overlapScore(taskWords, node) {
37
+ const haystack = words(`${node?.label || ''} ${node?.id || ''} ${node?.symbol_kind || ''}`)
38
+ let score = 0
39
+ for (const word of taskWords) if (haystack.has(word)) score += 8
40
+ return score
41
+ }
42
+
43
+ function addCandidate(map, node, score, reason) {
44
+ if (!node?.id) return
45
+ const id = String(node.id)
46
+ const current = map.get(id) || {node, score: 0, reasons: new Set()}
47
+ current.score += score
48
+ current.reasons.add(reason)
49
+ map.set(id, current)
50
+ }
51
+
52
+ function containedSymbols(g, node) {
53
+ if (isSymbol(node?.id)) return [node]
54
+ return (g.out.get(String(node?.id)) || [])
55
+ .filter((edge) => edge.relation === 'contains')
56
+ .map((edge) => g.byId.get(String(edge.id)))
57
+ .filter(Boolean)
58
+ }
59
+
60
+ // Combines intent-expanded search seeds with exact changed symbols. The result deliberately stays
61
+ // deterministic and source-free; exact LSP/source evidence is collected by context_bundle later.
62
+ export function retrieveTaskContext(g, {
63
+ task, semanticSeeds = [], changedSeedIds = [], maxSymbols = 3, repoRoot = null, includeClassified = false,
64
+ } = {}) {
65
+ const expandedTask = expandTaskQuery(task)
66
+ const taskWords = words(expandedTask)
67
+ const requestedClasses = new Set(Object.entries(CLASS_TERMS)
68
+ .filter(([, terms]) => terms.some((term) => taskWords.has(term)))
69
+ .map(([name]) => name))
70
+ if (requestedClasses.has('test')) requestedClasses.add('e2e')
71
+ if (requestedClasses.has('e2e')) requestedClasses.add('test')
72
+ const classifier = createPathClassifier(repoRoot)
73
+ const classificationCache = new Map()
74
+ const changedFiles = new Set((changedSeedIds || []).map((id) => fileOf(g.byId.get(String(id)) || {id})))
75
+ const pathAllowed = (node) => {
76
+ const file = fileOf(node)
77
+ if (!file || changedFiles.has(file) || includeClassified === true) return true
78
+ if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
79
+ const info = classificationCache.get(file)
80
+ const classes = NON_PRODUCT_CLASSES.filter((name) => hasPathClass(info, name))
81
+ if (!info?.excluded && !classes.length) return true
82
+ return classes.some((name) => requestedClasses.has(name))
83
+ }
84
+ const candidates = new Map()
85
+ for (const id of changedSeedIds || []) {
86
+ const node = g.byId.get(String(id))
87
+ if (node) addCandidate(candidates, node, 100 + overlapScore(taskWords, node), 'changed-symbol')
88
+ }
89
+ for (const seed of semanticSeeds || []) addCandidate(candidates, seed, 45 + overlapScore(taskWords, seed), 'task-intent')
90
+
91
+ for (const candidate of [...candidates.values()]) {
92
+ for (const symbol of containedSymbols(g, candidate.node)) {
93
+ const degree = (g.out.get(String(symbol.id)) || []).length + (g.inn.get(String(symbol.id)) || []).length
94
+ addCandidate(candidates, symbol, 22 + overlapScore(taskWords, symbol) + Math.min(12, degree), `symbol-in:${fileOf(candidate.node)}`)
95
+ }
96
+ }
97
+
98
+ const symbolCandidates = [...candidates.values()].filter((item) => isSymbol(item.node.id))
99
+ const ranked = symbolCandidates
100
+ .filter((item) => pathAllowed(item.node))
101
+ .sort((left, right) => right.score - left.score || String(left.node.id).localeCompare(String(right.node.id)))
102
+ const limit = Math.max(1, Math.min(5, Number(maxSymbols) || 3))
103
+ return {
104
+ method: 'intent-expanded graph retrieval + exact changed-symbol seeds',
105
+ status: ranked.length ? 'COMPLETE' : 'NO_SYMBOLS',
106
+ selected: ranked.slice(0, limit).map((item) => ({
107
+ id: String(item.node.id), label: item.node.label || String(item.node.id), file: fileOf(item.node),
108
+ kind: item.node.symbol_kind || null, score: item.score, reasons: [...item.reasons].sort(),
109
+ })),
110
+ candidateCount: ranked.length,
111
+ suppressedClassified: symbolCandidates.length - ranked.length,
112
+ pathPolicy: includeClassified === true
113
+ ? {mode: 'ALL_CLASSIFIED'}
114
+ : {mode: 'PRODUCTION_FIRST', requestedClasses: [...requestedClasses].sort(), changedFilesPinned: changedFiles.size},
115
+ }
116
+ }
package/src/bounds.js ADDED
@@ -0,0 +1,4 @@
1
+ export function boundedInteger(value, fallback, minimum, maximum) {
2
+ const number = Number(value);
3
+ return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback));
4
+ }
@@ -14,6 +14,10 @@ const TOPVARS = `
14
14
  (program (variable_declaration (variable_declarator) @decl))
15
15
  (program (export_statement (lexical_declaration (variable_declarator) @decl)))
16
16
  (program (export_statement (variable_declaration (variable_declarator) @decl)))`;
17
+ const TYPES = `
18
+ (interface_declaration name: (_) @interface)
19
+ (type_alias_declaration name: (_) @type)
20
+ (enum_declaration name: (_) @enum)`;
17
21
  const REQUIRE = `(variable_declarator name: (_) @lhs value: (call_expression function: (identifier) @req arguments: (arguments (string (string_fragment) @src))))`;
18
22
 
19
23
  function parseExportSpecifiers(raw) {
@@ -95,7 +99,13 @@ export default {
95
99
  sourceNode: cap.node.parent,
96
100
  selectionNode: cap.node,
97
101
  ...(exportStatement ? { exported: true } : {}),
98
- ...(isMethod ? methodMetadata(cap.node) : { symbolKind: cap.name === "class" ? "class" : "function", moduleDeclaration: isModuleDeclaration(cap.node) })
102
+ ...(isMethod
103
+ ? {...methodMetadata(cap.node), symbolSpace: "value"}
104
+ : {
105
+ symbolKind: cap.name === "class" ? "class" : "function",
106
+ symbolSpace: cap.name === "class" ? "both" : "value",
107
+ moduleDeclaration: isModuleDeclaration(cap.node),
108
+ })
99
109
  });
100
110
  if (exportStatement) {
101
111
  recordJsExport({
@@ -103,6 +113,7 @@ export default {
103
113
  exported: /^\s*export\s+default\b/.test(exportStatement.text) ? "default" : cap.node.text,
104
114
  local: cap.node.text,
105
115
  typeOnly: false,
116
+ line: exportStatement.startPosition.row + 1,
106
117
  });
107
118
  }
108
119
  }
@@ -115,9 +126,40 @@ export default {
115
126
  selectionNode: nameNode,
116
127
  ...(exported ? { exported: true } : {}),
117
128
  symbolKind: "variable",
129
+ symbolSpace: "value",
118
130
  moduleDeclaration: true
119
131
  });
120
- if (exported) recordJsExport({ kind: "local", exported: nameNode.text, local: nameNode.text, typeOnly: false });
132
+ if (exported) recordJsExport({
133
+ kind: "local", exported: nameNode.text, local: nameNode.text, typeOnly: false,
134
+ line: exportStatementOf(cap.node)?.startPosition.row + 1 || nameNode.startPosition.row + 1,
135
+ });
136
+ }
137
+
138
+ // TypeScript has separate type and value namespaces. Keep interface/type declarations as
139
+ // first-class type-space nodes instead of folding them into a same-named runtime declaration.
140
+ // Enums and classes intentionally occupy both spaces because TypeScript emits runtime values.
141
+ if (grammar !== "javascript") for (const cap of caps(grammar, TYPES, tree.rootNode)) {
142
+ const declaration = cap.node.parent;
143
+ const exportStatement = exportStatementOf(cap.node);
144
+ const symbolKind = cap.name === "interface" ? "interface" : cap.name === "type" ? "type" : "enum";
145
+ const symbolSpace = cap.name === "enum" ? "both" : "type";
146
+ addSym(cap.node.text, cap.node.startPosition.row + 1, false, {
147
+ sourceNode: declaration,
148
+ selectionNode: cap.node,
149
+ idSuffix: symbolSpace === "type" ? ":type" : "",
150
+ ...(exportStatement ? {exported: true} : {}),
151
+ symbolKind,
152
+ symbolSpace,
153
+ moduleDeclaration: isModuleDeclaration(cap.node),
154
+ });
155
+ if (exportStatement) recordJsExport({
156
+ kind: "local",
157
+ exported: cap.node.text,
158
+ local: cap.node.text,
159
+ typeOnly: symbolSpace === "type",
160
+ line: exportStatement.startPosition.row + 1,
161
+ symbolSpace,
162
+ });
121
163
  }
122
164
 
123
165
  const importTypeOnly = (node) => {
@@ -218,12 +260,12 @@ export default {
218
260
  const namespace = text.match(/^export\s+(type\s+)?\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\b/);
219
261
  const clause = text.match(/^export\s+(type\s+)?\{([\s\S]*?)\}\s+from\b/);
220
262
  if (namespace) {
221
- recordJsExport({ kind: "namespace", exported: namespace[2], targetFile: tgt, typeOnly: !!namespace[1] });
263
+ recordJsExport({ kind: "namespace", exported: namespace[2], imported: "*", targetFile: tgt, typeOnly: !!namespace[1], line, specifier: rawSpec });
222
264
  } else if (star) {
223
- recordJsExport({ kind: "star", targetFile: tgt, typeOnly: !!star[1] });
265
+ recordJsExport({ kind: "star", exported: "*", imported: "*", targetFile: tgt, typeOnly: !!star[1], line, specifier: rawSpec });
224
266
  } else if (clause) {
225
267
  for (const spec of parseExportSpecifiers(clause[2])) {
226
- recordJsExport({ kind: "named", exported: spec.exported, imported: spec.imported, targetFile: tgt, typeOnly: !!clause[1] || spec.typeOnly });
268
+ recordJsExport({ kind: "named", exported: spec.exported, imported: spec.imported, targetFile: tgt, typeOnly: !!clause[1] || spec.typeOnly, line, specifier: rawSpec });
227
269
  }
228
270
  }
229
271
  }
@@ -239,26 +281,19 @@ export default {
239
281
  const text = node.text.trim();
240
282
  const clause = text.match(/^export\s+(type\s+)?\{([\s\S]*?)\}/);
241
283
  if (clause) for (const spec of parseExportSpecifiers(clause[2])) {
242
- recordJsExport({ kind: "local", exported: spec.exported, local: spec.imported, typeOnly: !!clause[1] || spec.typeOnly });
284
+ recordJsExport({ kind: "local", exported: spec.exported, local: spec.imported, typeOnly: !!clause[1] || spec.typeOnly, line: node.startPosition.row + 1 });
243
285
  }
244
286
  const identifierDefault = text.match(/^export\s+default\s+([A-Za-z_$][\w$]*)\s*;?$/);
245
- if (identifierDefault) recordJsExport({ kind: "local", exported: "default", local: identifierDefault[1], typeOnly: false });
246
- const typedDeclaration = text.match(/^export\s+(?:declare\s+)?(type|interface|enum)\s+([A-Za-z_$][\w$]*)\b/);
247
- if (typedDeclaration) recordJsExport({
248
- kind: "local",
249
- exported: typedDeclaration[2],
250
- local: typedDeclaration[2],
251
- typeOnly: typedDeclaration[1] !== "enum",
252
- });
287
+ if (identifierDefault) recordJsExport({ kind: "local", exported: "default", local: identifierDefault[1], typeOnly: false, line: node.startPosition.row + 1 });
253
288
  const defaultValue = field(node, "value");
254
289
  if (defaultValue?.type === "object") {
255
290
  // Service facades commonly expose local helpers through `export default { getSchema,
256
291
  // save: persist }`. Record the public member -> local binding instead of treating the
257
292
  // object as an opaque default export.
258
- recordJsExport({kind: "facade-root", exported: "default", local: "default", typeOnly: false});
293
+ recordJsExport({kind: "facade-root", exported: "default", local: "default", typeOnly: false, line: node.startPosition.row + 1});
259
294
  for (const property of defaultValue.namedChildren || []) {
260
295
  if (["shorthand_property_identifier", "shorthand_property_identifier_pattern"].includes(property.type)) {
261
- recordJsExport({kind: "facade-member", member: property.text, local: property.text, typeOnly: false});
296
+ recordJsExport({kind: "facade-member", member: property.text, local: property.text, typeOnly: false, line: node.startPosition.row + 1});
262
297
  markExported(property.text);
263
298
  continue;
264
299
  }
@@ -268,7 +303,7 @@ export default {
268
303
  if (!key || value?.type !== "identifier") continue;
269
304
  const member = key.text.replace(/^['"`]|['"`]$/g, "");
270
305
  if (!/^[A-Za-z_$][\w$]*$/.test(member)) continue;
271
- recordJsExport({kind: "facade-member", member, local: value.text, typeOnly: false});
306
+ recordJsExport({kind: "facade-member", member, local: value.text, typeOnly: false, line: node.startPosition.row + 1});
272
307
  markExported(value.text);
273
308
  }
274
309
  }
@@ -9,7 +9,7 @@ import {childProcessEnv} from '../child-env.js'
9
9
  import {createRepoBoundary} from '../repo-path.js'
10
10
 
11
11
  export const REPOSITORY_FRESHNESS_PROBE_V = 1
12
- export const GRAPH_BUILDER_SCHEMA_V = 4
12
+ export const GRAPH_BUILDER_SCHEMA_V = 5
13
13
  export const GRAPH_BUILDER_VERSION = (() => {
14
14
  try { return String(createRequire(import.meta.url)('../../package.json').version) }
15
15
  catch { return '0.0.0' }
@@ -25,7 +25,9 @@ const CURRENT_GRAPH_SCHEMA = Object.freeze({
25
25
  complexityV: 2,
26
26
  repoBoundaryV: 1,
27
27
  barrelResolutionV: 1,
28
- extractorSchemaV: 4,
28
+ reExportOccurrencesV: 1,
29
+ symbolSpacesV: 1,
30
+ extractorSchemaV: 5,
29
31
  })
30
32
  const CONTROL_FILES = ['.gitignore', '.weavatrixignore', '.weavatrix.json']
31
33
  const MAX_CONTROL_BYTES = 1_000_000
@@ -157,12 +157,13 @@ function mergeScopedGraph(base, scoped, affected, snapshot) {
157
157
  links,
158
158
  externalImports,
159
159
  jsExportRecords: scoped.jsExportRecords || base.jsExportRecords || {},
160
+ reExportOccurrences: scoped.reExportOccurrences || base.reExportOccurrences || [],
160
161
  fileHashes: snapshot.fileHashes,
161
162
  fileExportSignatures: snapshot.fileExportSignatures,
162
163
  controlHashes: snapshot.controlHashes,
163
164
  graphRevision: snapshot.revision,
164
165
  };
165
- for (const key of ["extImportsV", "edgeTypesV", "edgeProvenanceV", "complexityV", "repoBoundaryV", "barrelResolutionV", "extractorSchemaV"]) {
166
+ for (const key of ["extImportsV", "edgeTypesV", "edgeProvenanceV", "complexityV", "repoBoundaryV", "barrelResolutionV", "reExportOccurrencesV", "symbolSpacesV", "extractorSchemaV"]) {
166
167
  merged[key] = Math.max(Number(base[key]) || 0, Number(scoped[key]) || 0);
167
168
  }
168
169
  return merged;
@@ -183,7 +184,8 @@ export async function refreshGraphIncrementally(repoDir, existingGraph, {
183
184
 
184
185
  if (!existingGraph || !existingGraph.fileHashes || !existingGraph.fileExportSignatures
185
186
  || !existingGraph.controlHashes || !existingGraph.jsExportRecords || Number(existingGraph.barrelResolutionV) < 1
186
- || Number(existingGraph.extractorSchemaV) < 4 || Number(existingGraph.edgeProvenanceV) < 1) {
187
+ || Number(existingGraph.extractorSchemaV) < 5 || Number(existingGraph.reExportOccurrencesV) < 1
188
+ || Number(existingGraph.symbolSpacesV) < 1 || Number(existingGraph.edgeProvenanceV) < 1) {
187
189
  return full("incremental-baseline-unavailable");
188
190
  }
189
191
  if (!sameRecord(existingGraph.controlHashes, snapshot.controlHashes)) return full("ignore-or-control-config-changed");
@@ -28,7 +28,7 @@ function withTypeOnly(result, typeOnly) {
28
28
  return resolved(result.origin.file, result.origin.name, true);
29
29
  }
30
30
 
31
- export function resolveJsBarrels({ jsExports, importedLocals, links }) {
31
+ export function resolveJsBarrels({ jsExports, importedLocals, links, resolveSymbol = () => null }) {
32
32
  const tables = new Map();
33
33
  for (const [file, records] of jsExports) {
34
34
  const table = { explicit: new Map(), stars: [], facadeMembers: new Map() };
@@ -111,6 +111,43 @@ export function resolveJsBarrels({ jsExports, importedLocals, links }) {
111
111
  }));
112
112
  };
113
113
 
114
+ // Materialize source-free, occurrence-level re-export evidence. Named/local alias records retain
115
+ // their exact public name and final declaration origin; wildcard/namespace records retain the
116
+ // concrete facade statement and target for on-demand propagation in context tools.
117
+ const reExportOccurrences = [];
118
+ for (const [file, records] of jsExports) for (const record of records || []) {
119
+ if (["facade-root", "facade-member"].includes(record.kind)) continue;
120
+ const resolution = record.kind === "star" || !record.exported
121
+ ? MISSING
122
+ : resolveExport(file, record.exported);
123
+ if (resolution.status === "resolved") {
124
+ record.originFile = resolution.origin.file;
125
+ record.originName = resolution.origin.name;
126
+ record.originTypeOnly = resolution.origin.typeOnly === true;
127
+ record.originId = resolveSymbol(resolution.origin.file, resolution.origin.name, resolution.origin.typeOnly === true) || null;
128
+ }
129
+ const reExportedLocal = record.kind === "local" && resolution.status === "resolved" && resolution.origin.file !== file;
130
+ if (!record.targetFile && !reExportedLocal) continue;
131
+ reExportOccurrences.push({
132
+ file,
133
+ line: Number(record.line) || 1,
134
+ kind: record.kind,
135
+ exported: record.exported || record.member || "*",
136
+ imported: record.imported || record.local || "*",
137
+ targetFile: record.targetFile || resolution.origin?.file || file,
138
+ typeOnly: record.typeOnly === true || resolution.origin?.typeOnly === true,
139
+ ...(record.specifier ? {specifier: record.specifier} : {}),
140
+ status: record.kind === "star" ? "wildcard" : resolution.status,
141
+ ...(resolution.status === "resolved" ? {
142
+ originFile: resolution.origin.file,
143
+ originName: resolution.origin.name,
144
+ ...(record.originId ? {originId: record.originId} : {}),
145
+ } : {}),
146
+ });
147
+ }
148
+ reExportOccurrences.sort((left, right) => left.file.localeCompare(right.file)
149
+ || left.line - right.line || left.exported.localeCompare(right.exported));
150
+
114
151
  // Every internal re-export is a physical facade hop. It remains in the graph for runtime cycle truth,
115
152
  // but semantic consumers ignore it in favor of importer -> declaration edges below.
116
153
  for (const link of links) {
@@ -194,5 +231,5 @@ export function resolveJsBarrels({ jsExports, importedLocals, links }) {
194
231
  return result;
195
232
  };
196
233
 
197
- return { resolveExport, resolveNamespaceMember, resolveFacadeMember };
234
+ return { resolveExport, resolveNamespaceMember, resolveFacadeMember, reExportOccurrences };
198
235
  }
@@ -37,6 +37,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
37
37
  const addNode = (n) => { if (!nodeIds.has(n.id)) { nodeIds.add(n.id); nodes.push(n); nodeById.set(n.id, n); } };
38
38
  const perFileSymbols = new Map();
39
39
  const symByFileName = new Map();
40
+ const symIdsByFileName = new Map();
40
41
  const importedLocals = new Map();
41
42
  const jsExports = new Map();
42
43
  if (requestedFiles && opts.baseGraph?.jsExportRecords) {
@@ -54,6 +55,11 @@ export async function buildInternalGraph(repoDir, opts = {}) {
54
55
  let names = symByFileName.get(file);
55
56
  if (!names) symByFileName.set(file, (names = new Map()));
56
57
  if (!names.has(match[1])) names.set(match[1], id);
58
+ let idsByName = symIdsByFileName.get(file);
59
+ if (!idsByName) symIdsByFileName.set(file, (idsByName = new Map()));
60
+ const ids = idsByName.get(match[1]) || [];
61
+ ids.push(id);
62
+ idsByName.set(match[1], ids);
57
63
  nodeById.set(id, node);
58
64
  }
59
65
  }
@@ -73,17 +79,17 @@ export async function buildInternalGraph(repoDir, opts = {}) {
73
79
  for (const abs of files) {
74
80
  const fileRel = rel(abs); const ext = extname(abs);
75
81
  addNode({ id: fileRel, label: fileRel.split("/").pop(), file_type: "code", source_file: fileRel, source_location: "L1" });
76
- if (isDataFile(fileRel) || isDocFile(fileRel)) { perFileSymbols.set(fileRel, []); symByFileName.set(fileRel, new Map()); continue; } // config/infra/docs file-only node
82
+ if (isDataFile(fileRel) || isDocFile(fileRel)) { perFileSymbols.set(fileRel, []); symByFileName.set(fileRel, new Map()); symIdsByFileName.set(fileRel, new Map()); continue; } // config/infra/docs file-only node
77
83
  const grammar = EXT_LANG[ext]; const lang = LANGS[FAMILY[grammar]]; if (!lang || !langs[grammar]) continue;
78
84
  let code; try { code = readFileSync(abs, "utf8"); } catch { continue; }
79
85
  // giant / generated / minified file → keep the file NODE but don't parse symbols (avoids a wedged parse)
80
- if (code.length > MAX_PARSE_BYTES) { perFileSymbols.set(fileRel, []); symByFileName.set(fileRel, new Map()); continue; }
86
+ if (code.length > MAX_PARSE_BYTES) { perFileSymbols.set(fileRel, []); symByFileName.set(fileRel, new Map()); symIdsByFileName.set(fileRel, new Map()); continue; }
81
87
  if ((++_parsed % 24) === 0) await new Promise((r) => setImmediate(r)); // breathe: let the UI paint between chunks
82
88
  if (typeof opts.onParseFile === "function") opts.onParseFile(fileRel);
83
89
  const parser = new Parser(); parser.setLanguage(langs[grammar]);
84
90
  let tree; try { tree = parser.parse(code); } catch { continue; }
85
91
 
86
- const syms = []; const nameToId = new Map(); const moduleNameToId = new Map();
92
+ const syms = []; const nameToId = new Map(); const nameToIds = new Map(); const moduleNameToId = new Map();
87
93
  const addSym = (name, line, callable, extra) => {
88
94
  if (!name || !/^[A-Za-z_$][\w$]*$/.test(name)) return;
89
95
  const suffix = /^:[A-Za-z0-9_-]+$/.test(extra?.idSuffix || "") ? extra.idSuffix : "";
@@ -135,6 +141,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
135
141
  ...(extra && extra.exported ? { exported: true } : {}),
136
142
  ...(extra && extra.decorated ? { decorated: true } : {}),
137
143
  ...(extra && extra.symbolKind ? { symbol_kind: extra.symbolKind } : {}),
144
+ ...(extra && extra.symbolSpace ? { symbol_space: extra.symbolSpace } : {}),
138
145
  ...(extra && extra.memberOf ? { member_of: extra.memberOf } : {}),
139
146
  ...(extra && extra.visibility ? { visibility: extra.visibility } : {})
140
147
  });
@@ -146,8 +153,12 @@ export async function buildInternalGraph(repoDir, opts = {}) {
146
153
  end: endLine >= line ? endLine : 0,
147
154
  ...(extra?.memberOf ? {memberOf: extra.memberOf} : {}),
148
155
  ...(extra?.symbolKind ? {symbolKind: extra.symbolKind} : {}),
156
+ ...(extra?.symbolSpace ? {symbolSpace: extra.symbolSpace} : {}),
149
157
  });
150
158
  if (!nameToId.has(name)) nameToId.set(name, id);
159
+ const ids = nameToIds.get(name) || [];
160
+ ids.push(id);
161
+ nameToIds.set(name, ids);
151
162
  if (extra?.moduleDeclaration && !moduleNameToId.has(name)) moduleNameToId.set(name, id);
152
163
  return id;
153
164
  };
@@ -196,7 +207,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
196
207
  for (let i = 0; i < syms.length; i++) {
197
208
  if (!syms[i].end || syms[i].end < syms[i].start) syms[i].end = i + 1 < syms.length ? syms[i + 1].start - 1 : eof;
198
209
  }
199
- perFileSymbols.set(fileRel, syms); symByFileName.set(fileRel, nameToId);
210
+ perFileSymbols.set(fileRel, syms); symByFileName.set(fileRel, nameToId); symIdsByFileName.set(fileRel, nameToIds);
200
211
  tree.delete();
201
212
  }
202
213
 
@@ -211,7 +222,27 @@ export async function buildInternalGraph(repoDir, opts = {}) {
211
222
  let dm = goDirSymbols.get(d); if (!dm) goDirSymbols.set(d, (dm = new Map()));
212
223
  for (const [n, id] of m) if (!dm.has(n)) dm.set(n, id);
213
224
  }
214
- const { resolveNamespaceMember } = resolveJsBarrels({ jsExports, importedLocals, links });
225
+ const inferredSymbolSpace = (node) => {
226
+ const explicit = String(node?.symbol_space || "");
227
+ if (["value", "type", "both"].includes(explicit)) return explicit;
228
+ const kind = String(node?.symbol_kind || "").toLowerCase();
229
+ if (["interface", "type"].includes(kind)) return "type";
230
+ if (["class", "enum"].includes(kind)) return "both";
231
+ return "value";
232
+ };
233
+ const resolveNamedSymbol = (file, name, space = "value") => {
234
+ const ids = symIdsByFileName.get(file)?.get(name) || [];
235
+ const accepts = (id) => {
236
+ const symbolSpace = inferredSymbolSpace(nodeById.get(id));
237
+ return symbolSpace === "both" || symbolSpace === space;
238
+ };
239
+ const exact = ids.find((id) => inferredSymbolSpace(nodeById.get(id)) === space);
240
+ return exact || ids.find(accepts) || null;
241
+ };
242
+ const { resolveNamespaceMember, reExportOccurrences } = resolveJsBarrels({
243
+ jsExports, importedLocals, links,
244
+ resolveSymbol: (file, name, typeOnly) => resolveNamedSymbol(file, name, typeOnly ? "type" : "value"),
245
+ });
215
246
  // Exact source ranges can overlap (a named function nested inside another function). Attribute a call
216
247
  // to the innermost matching symbol, not whichever outer declaration happened to be added first.
217
248
  const enclosing = (fileRel, line) => {
@@ -223,13 +254,13 @@ export async function buildInternalGraph(repoDir, opts = {}) {
223
254
  return best;
224
255
  };
225
256
  const resolveCall = (name, fileRel) => {
226
- const local = symByFileName.get(fileRel); if (local && local.has(name)) return local.get(name);
257
+ const local = resolveNamedSymbol(fileRel, name, "value"); if (local) return local;
227
258
  if (sharesDirScope(fileRel)) { const d = fileRel.includes("/") ? fileRel.slice(0, fileRel.lastIndexOf("/")) : ""; const dm = goDirSymbols.get(d); if (dm && dm.has(name)) return dm.get(name); }
228
259
  const imp = importedLocals.get(fileRel) && importedLocals.get(fileRel).get(name);
229
260
  if (imp && imp.targetFile) {
230
261
  const targetFile = imp.originFile || imp.targetFile;
231
262
  const importedName = imp.originName || imp.imported;
232
- const tf = symByFileName.get(targetFile); if (tf && tf.has(importedName)) return tf.get(importedName);
263
+ return resolveNamedSymbol(targetFile, importedName, "value");
233
264
  }
234
265
  return null;
235
266
  };
@@ -295,7 +326,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
295
326
  if (!imp || !["*", "default"].includes(imp.imported) || imp.typeOnly) continue;
296
327
  const origin = resolveNamespaceMember(fileRel, imp, property.text, "call");
297
328
  if (origin.status !== "resolved") continue;
298
- const target = symByFileName.get(origin.origin.file)?.get(origin.origin.name);
329
+ const target = resolveNamedSymbol(origin.origin.file, origin.origin.name, "value");
299
330
  const caller = enclosing(fileRel, cap.node.startPosition.row + 1);
300
331
  if (target && caller && target !== caller.id) links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED", line: cap.node.startPosition.row + 1 });
301
332
  }
@@ -315,13 +346,25 @@ export async function buildInternalGraph(repoDir, opts = {}) {
315
346
  const origin = resolveNamespaceMember(fileRel, imp, parts[parts.length - 1], "jsx");
316
347
  if (origin.status === "resolved") { targetFile = origin.origin.file; importedName = origin.origin.name; }
317
348
  }
318
- const targetSymbols = symByFileName.get(targetFile);
319
- if (!targetSymbols) continue;
320
- const target = targetSymbols.get(importedName) || targetSymbols.get(localName);
349
+ const target = resolveNamedSymbol(targetFile, importedName, "value") || resolveNamedSymbol(targetFile, localName, "value");
321
350
  if (!target) continue;
322
351
  const owner = enclosing(fileRel, cap.node.startPosition.row + 1);
323
352
  links.push({ source: owner?.id || fileRel, target, relation: "references", confidence: "INFERRED", usage: "jsx", line: cap.node.startPosition.row + 1 });
324
353
  }
354
+ // Type identifiers are a separate namespace in TypeScript. Resolve them to type/both-space
355
+ // declarations without creating runtime calls or keeping a same-named value symbol alive.
356
+ if (grammar !== "javascript") for (const cap of caps(grammar, `(type_identifier) @typeRef`, tree.rootNode)) {
357
+ const name = cap.node.text;
358
+ const imp = importedLocals.get(fileRel)?.get(name);
359
+ const targetFile = imp?.originFile || imp?.targetFile || fileRel;
360
+ const targetName = imp?.originName || imp?.imported || name;
361
+ const target = resolveNamedSymbol(targetFile, targetName, "type");
362
+ if (!target || String(target).startsWith(`${fileRel}#${name}@${cap.node.startPosition.row + 1}`)) continue;
363
+ const owner = enclosing(fileRel, cap.node.startPosition.row + 1);
364
+ const source = owner?.id || fileRel;
365
+ if (source === target) continue;
366
+ links.push({source, target, relation: "references", confidence: "EXTRACTED", provenance: "RESOLVED", typeOnly: true, line: cap.node.startPosition.row + 1, usage: "type"});
367
+ }
325
368
  }
326
369
  // Go value references: a top-level const/var/type/func used BY NAME (bare `X`, or cross-package `pkg.X`) in
327
370
  // another file/scope → a `references` edge, so used-but-never-called symbols (message-type consts, etc.) are
@@ -379,7 +422,10 @@ export async function buildInternalGraph(repoDir, opts = {}) {
379
422
  complexityV: 2,
380
423
  repoBoundaryV: 1,
381
424
  barrelResolutionV: 1,
382
- extractorSchemaV: 4,
425
+ reExportOccurrencesV: 1,
426
+ symbolSpacesV: 1,
427
+ extractorSchemaV: 5,
428
+ reExportOccurrences,
383
429
  jsExportRecords: Object.fromEntries([...jsExports.entries()].sort(([a], [b]) => a.localeCompare(b))),
384
430
  fileHashes: snapshot.fileHashes,
385
431
  fileExportSignatures: snapshot.fileExportSignatures,