weavatrix 0.2.5 → 0.2.6

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  Grep sees text. Weavatrix sees structure. It builds a dependency graph of any local repository —
6
6
  files, symbols, and the imports/calls/inheritance connecting them — and serves it to Claude Code,
7
7
  Codex, or any MCP client: change impact, transitive dependents, health audit, clone detection,
8
- coverage mapping. **34 tools available; 31 enabled by the default offline profile. Local-first: with
8
+ coverage mapping. **35 tools available; 32 enabled by the default offline profile. Local-first: with
9
9
  the defaults, no repository data leaves your machine.**
10
10
 
11
11
  - Website: [weavatrix.com](https://weavatrix.com)
@@ -22,7 +22,8 @@ answers grep can't produce:
22
22
  depends on them — with test coverage attached, so the **untested part of the blast radius** stands
23
23
  out before you ship.
24
24
  - *"Who calls this function?"* → `inspect_symbol` returns exact bounded TS/JS occurrences plus
25
- source context; `get_dependents` walks the symbol-level reverse graph transitively. Opt into
25
+ source context; `context_bundle` adds grouped graph relations, exact re-export sites, and a
26
+ smaller source workset. `get_dependents` walks the symbol-level reverse graph transitively. Opt into
26
27
  `include_container_importers:true` only when a broader module-import radius is intended.
27
28
  - *"Did my refactor actually decouple anything?"* → `graph_diff base_ref=HEAD~1` builds an immutable
28
29
  baseline graph without checking it out, then reports the structural delta: new module
@@ -110,7 +111,8 @@ An agent skill with recipes ships in [skill/SKILL.md](skill/SKILL.md) — instal
110
111
  `change_impact`, `git_history`, `graph_diff`, `get_architecture_contract`,
111
112
  `prepare_change`. Runtime dependencies, TypeScript type-only coupling and language
112
113
  compile-only edges (Rust module/use, Java imports) are reported separately where that distinction
113
- changes the result.
114
+ changes the result. TypeScript type-space and value-space declarations keep distinct identities;
115
+ classes and enums that inhabit both spaces are labelled `both`.
114
116
 
115
117
  Every current edge carries versioned provenance. The parser emits `EXTRACTED`, `RESOLVED`, and
116
118
  `INFERRED`; the built-in bounded TypeScript/JavaScript precision overlay upgrades only references
@@ -118,7 +120,7 @@ confirmed by its bundled `typescript-language-server` + TypeScript runtime to `E
118
120
  `CONFLICT` means evidence disagrees. `graph_stats` reports the provenance breakdown and the semantic
119
121
  provider's `COMPLETE`, `PARTIAL`, `UNAVAILABLE`, or `OFF` state; `OFF` means precision was explicitly
120
122
  disabled and only static evidence is active. Java and Rust language-server providers are not bundled
121
- in 0.2.5: their edges never become `EXACT_LSP`, even when a mixed repository reports a complete
123
+ in 0.2.6: their edges never become `EXACT_LSP`, even when a mixed repository reports a complete
122
124
  TypeScript/JavaScript overlay.
123
125
 
124
126
  The bounded JS/TS provider is enabled by default for new graphs. Set `WEAVATRIX_PRECISION=off`
@@ -127,8 +129,9 @@ before starting the MCP server for parser-only operation from the first build, o
127
129
  choice as **TypeScript/JavaScript semantic precision**.
128
130
 
129
131
  **search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
130
- symbol's actual code in one hop), `inspect_symbol` (one exact bounded TS/JS reference query,
131
- logical containers, impact and source excerpts), `list_endpoints` (HTTP route inventory:
132
+ symbol's actual code in one hop), `context_bundle` (definition plus grouped inbound/outbound
133
+ containers, exact re-export sites and a few bounded source excerpts), `inspect_symbol` (one exact
134
+ bounded TS/JS reference query, logical containers, impact and source excerpts), `list_endpoints` (HTTP route inventory:
132
135
  Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web …)
133
136
 
134
137
  **health** — `find_dead_code` (bounded review queue for statically unreferenced and test-only files,
@@ -193,6 +196,19 @@ disclosed instead of silently guessed; and the server **hot-reloads its watched
193
196
  modules and catalog** when those files change — other MCP helpers and analysis engines require a
194
197
  reconnect.
195
198
 
199
+ ### 0.2.6 compact-context and TypeScript identity patch
200
+
201
+ - New `context_bundle` turns a symbol into a bounded workset: definition, grouped inbound/outbound
202
+ containers, reference evidence, exact re-export locations and a small number of source excerpts.
203
+ - Re-export records retain their concrete file, line, alias, specifier, type-only flag and resolved
204
+ origin. Barrel propagation through `export *` stays exact and private declarations are not exposed.
205
+ - TypeScript interfaces and type aliases are first-class type-space nodes. Same-named runtime values
206
+ keep separate identities, while classes and enums are explicitly marked as inhabiting both spaces.
207
+ - Graph schema v5 and freshness gates rebuild older caches before these precision-sensitive results
208
+ are used. The changes remain local-only and add no runtime dependency.
209
+
210
+ Full patch notes: [docs/releases/v0.2.6.md](docs/releases/v0.2.6.md).
211
+
196
212
  ### 0.2.5 exact-symbol and graph-fidelity patch
197
213
 
198
214
  - New `inspect_symbol` spends a bounded LSP query on one requested TS/JS declaration, groups exact
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Weavatrix — code graph & blast-radius MCP server for AI coding agents: change impact, transitive dependents, health audit, duplicate detection, and coverage mapping over any local repository.",
6
6
  "author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
package/skill/SKILL.md CHANGED
@@ -40,13 +40,14 @@ Start from the task, not from the complete tool list:
40
40
  `get_dependents` and `read_source`.
41
41
  - **Trace an API across repositories**: `list_known_repos` -> `trace_api_contract` with an explicit
42
42
  backend and client list; inspect each `graphReconciliation.buildMode` before using the verdict.
43
- - **Inspect exact symbol references**: start with `graph_stats`, then call `inspect_symbol` with an
44
- exact node ID (or an unambiguous label). It spends a bounded point query beyond the broad overlay
45
- cap and returns occurrence containers plus source context. Use `get_dependents` for transitive
43
+ - **Inspect exact symbol references**: start with `graph_stats`, then call `context_bundle` with an
44
+ exact node ID (or an unambiguous label) for a compact definition, grouped relations, exact
45
+ re-export sites and source workset. Use `inspect_symbol` when the raw bounded point query is needed;
46
+ it spends evidence beyond the broad overlay cap. Use `get_dependents` for transitive
46
47
  graph impact and `find_dead_code` for bounded zero-reference
47
48
  candidates. Treat `PARTIAL`, `UNAVAILABLE`, `OFF`, or zero exact edges as incomplete evidence, not
48
49
  a compiler-exact result; `OFF` means the caller explicitly selected static-only mode. Java and Rust
49
- providers are not bundled in 0.2.5, so their edges never become `EXACT_LSP` even when a mixed
50
+ providers are not bundled in 0.2.6, so their edges never become `EXACT_LSP` even when a mixed
50
51
  repository has a complete TypeScript/JavaScript overlay.
51
52
  - **Explore an architectural question**: use broad `query_graph` when entry points are unknown; its
52
53
  bootstrap/tool-execution ranking prefers production executables over docs, sites and fixtures.
@@ -63,7 +63,9 @@ export function aggregateGraph(graph, repoRoot) {
63
63
  line: String(node.source_location || "").replace(/^L/, ""),
64
64
  endLine: String(node.source_end || "").replace(/^L/, ""),
65
65
  ...(node.complexity ? { complexity: node.complexity } : {}),
66
- ...(node.decorated ? { decorated: true } : {})
66
+ ...(node.decorated ? { decorated: true } : {}),
67
+ ...(node.symbol_kind ? {symbolKind: node.symbol_kind} : {}),
68
+ ...(node.symbol_space ? {symbolSpace: node.symbol_space} : {})
67
69
  });
68
70
  fileSymbols.set(fid, list);
69
71
  }
@@ -63,7 +63,7 @@ function importCandidates(fromFile, spec) {
63
63
  function stripModuleStatements(text) {
64
64
  return String(text || "")
65
65
  .replace(/\bimport\s+[\s\S]*?\s+from\s*['"][^'"]+['"]\s*;?/g, "")
66
- .replace(/\bexport\s+\{[\s\S]*?\}\s+from\s*['"][^'"]+['"]\s*;?/g, "")
66
+ .replace(/\bexport\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s*['"][^'"]+['"]\s*;?/g, "")
67
67
  .replace(/\b(?:const|let|var)\s+\{[\s\S]*?\}\s*=\s*require\(\s*['"][^'"]+['"]\s*\)\s*;?/g, "");
68
68
  }
69
69
 
@@ -73,8 +73,10 @@ function parseNamedSpecifiers(raw) {
73
73
  .map((part) => part.trim())
74
74
  .filter(Boolean)
75
75
  .map((part) => {
76
- const m = part.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
77
- return m ? { imported: m[1], local: m[2] || m[1] } : null;
76
+ const typeOnly = /^type\s+/.test(part);
77
+ const clean = part.replace(/^type\s+/, "").trim();
78
+ const m = clean.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
79
+ return m ? { imported: m[1], local: m[2] || m[1], typeOnly } : null;
78
80
  })
79
81
  .filter(Boolean);
80
82
  }
@@ -93,17 +95,20 @@ export function computeSymbolExternalRefs(filePath, fileSymbols, fileText) {
93
95
  const name = bareSymbolName(sym.label);
94
96
  if (!isIdentifierName(name)) continue;
95
97
  const ids = byName.get(name) || [];
96
- ids.push(sym.id);
98
+ ids.push({id: sym.id, space: sym.symbolSpace || "value"});
97
99
  byName.set(name, ids);
98
100
  }
99
101
  symbolIdsByFileAndName.set(fid, byName);
100
102
  }
101
103
 
102
104
  const symbolExternalRefs = new Map();
103
- const addExternalRefs = (targetFid, importedName, refs) => {
105
+ const addExternalRefs = (targetFid, importedName, refs, typeOnly = false) => {
104
106
  if (!targetFid || refs <= 0 || !isIdentifierName(importedName)) return;
105
107
  const ids = symbolIdsByFileAndName.get(targetFid)?.get(importedName) || [];
106
- for (const id of ids) symbolExternalRefs.set(id, (symbolExternalRefs.get(id) || 0) + refs);
108
+ for (const entry of ids) {
109
+ const matches = entry.space === "both" || (typeOnly ? entry.space === "type" : entry.space !== "type");
110
+ if (matches) symbolExternalRefs.set(entry.id, (symbolExternalRefs.get(entry.id) || 0) + refs);
111
+ }
107
112
  };
108
113
  const resolveImportedFid = (fromPath, spec) => {
109
114
  for (const candidate of importCandidates(fromPath, spec)) {
@@ -123,18 +128,21 @@ export function computeSymbolExternalRefs(filePath, fileSymbols, fileText) {
123
128
  if (!targetFid) continue;
124
129
  const named = String(m[1] || "").match(/\{([\s\S]*?)\}/);
125
130
  if (named) {
126
- for (const spec of parseNamedSpecifiers(named[1])) addExternalRefs(targetFid, spec.imported, countIdentifierInText(scrubbed, spec.local));
131
+ const statementTypeOnly = /^\s*type\b/.test(String(m[1] || ""));
132
+ for (const spec of parseNamedSpecifiers(named[1])) addExternalRefs(targetFid, spec.imported, countIdentifierInText(scrubbed, spec.local), statementTypeOnly || spec.typeOnly);
127
133
  }
128
134
  const ns = String(m[1] || "").match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
129
135
  if (ns) {
130
136
  const byName = symbolIdsByFileAndName.get(targetFid) || new Map();
131
- for (const name of byName.keys()) addExternalRefs(targetFid, name, countMemberAccess(scrubbed, ns[1], name));
137
+ const statementTypeOnly = /^\s*type\b/.test(String(m[1] || ""));
138
+ for (const name of byName.keys()) addExternalRefs(targetFid, name, countMemberAccess(scrubbed, ns[1], name), statementTypeOnly);
132
139
  }
133
140
  }
134
- for (const m of String(txt).matchAll(/\bexport\s+\{([\s\S]*?)\}\s+from\s*['"]([^'"]+)['"]\s*;?/g)) {
135
- const targetFid = resolveImportedFid(importerPath, m[2]);
141
+ for (const m of String(txt).matchAll(/\bexport\s+(type\s+)?\{([\s\S]*?)\}\s+from\s*['"]([^'"]+)['"]\s*;?/g)) {
142
+ const targetFid = resolveImportedFid(importerPath, m[3]);
136
143
  if (!targetFid) continue;
137
- for (const spec of parseNamedSpecifiers(m[1])) addExternalRefs(targetFid, spec.imported, 1);
144
+ const statementTypeOnly = Boolean(m[1]);
145
+ for (const spec of parseNamedSpecifiers(m[2])) addExternalRefs(targetFid, spec.imported, 1, statementTypeOnly || spec.typeOnly);
138
146
  }
139
147
  for (const m of String(txt).matchAll(/\b(?:const|let|var)\s+\{([\s\S]*?)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)\s*;?/g)) {
140
148
  const targetFid = resolveImportedFid(importerPath, m[2]);
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,
@@ -26,9 +26,9 @@ const PROFILE_CAPS = Object.freeze({
26
26
 
27
27
  // The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
28
28
  // loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
29
- export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'catalog.mjs']
29
+ export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'catalog.mjs']
30
30
 
31
- function buildTools({tg, ti, th, ts, ta, tar, thi, tc}) {
31
+ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc}) {
32
32
  const tools = [
33
33
  {cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
34
34
  {cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
@@ -86,6 +86,7 @@ function buildTools({tg, ti, th, ts, ta, tar, thi, tc}) {
86
86
  {cap: 'search', name: 'search_code', description: 'Full-text or regex search across the repo source (ripgrep-backed, Node fallback). The graph only stores structure — use this to find literal text/patterns, then get_node/get_neighbors for structure.', inputSchema: {type: 'object', properties: {query: {type: 'string', description: 'text or regex to search for'}, is_regex: {type: 'boolean', default: false}, glob: {type: 'string', description: 'optional path glob, e.g. "*.js" or "src/**"'}, max_results: {type: 'integer', default: 40}}, required: ['query']}, run: (g, a, ctx) => searchCode({repoRoot: ctx.repoRoot, resolveRg}, a)},
87
87
  {cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
88
88
  {cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
89
+ {cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, aggregated inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and a few focused source excerpts. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
89
90
  {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, e2e, generated code, mocks, stories, docs, benchmarks and temporary roots are classified separately and excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
90
91
  {cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
91
92
  {cap: 'health', name: 'run_audit', description: 'Full internal health audit. With base_ref, builds and audits an immutable Git checkout, derives changed files, and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
@@ -133,17 +134,18 @@ function buildTools({tg, ti, th, ts, ta, tar, thi, tc}) {
133
134
  // present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
134
135
  export async function loadHotApi(version, capsArg) {
135
136
  const v = version ? `?v=${version}` : ''
136
- const [tg, ti, th, ts, ta, tar, thi, tc] = await Promise.all([
137
+ const [tg, ti, th, ts, tb, ta, tar, thi, tc] = await Promise.all([
137
138
  import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
138
139
  import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
139
140
  import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
140
141
  import(new URL(`./tools-source.mjs${v}`, import.meta.url).href),
142
+ import(new URL(`./tools-context.mjs${v}`, import.meta.url).href),
141
143
  import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
142
144
  import(new URL(`./tools-architecture.mjs${v}`, import.meta.url).href),
143
145
  import(new URL(`./tools-history.mjs${v}`, import.meta.url).href),
144
146
  import(new URL(`./tools-company.mjs${v}`, import.meta.url).href),
145
147
  ])
146
- const all = buildTools({tg, ti, th, ts, ta, tar, thi, tc})
148
+ const all = buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc})
147
149
  const raw = capsArg == null ? 'offline' : String(capsArg).trim()
148
150
  const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
149
151
  .flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
@@ -62,6 +62,10 @@ export function loadGraph(path, {repoRoot = null} = {}) {
62
62
  edgeTypesV: Number(raw.edgeTypesV) || 0,
63
63
  edgeProvenanceV: Number(raw.edgeProvenanceV) || 0,
64
64
  barrelResolutionV: Number(raw.barrelResolutionV) || 0,
65
+ reExportOccurrencesV: Number(raw.reExportOccurrencesV) || 0,
66
+ symbolSpacesV: Number(raw.symbolSpacesV) || 0,
67
+ reExportOccurrences: Array.isArray(raw.reExportOccurrences) ? raw.reExportOccurrences : [],
68
+ jsExportRecords: raw.jsExportRecords && typeof raw.jsExportRecords === 'object' ? raw.jsExportRecords : {},
65
69
  extractorSchemaV: Number(raw.extractorSchemaV) || 0,
66
70
  extImportsV: Number(raw.extImportsV) || 0,
67
71
  complexityV: Number(raw.complexityV) || 0,
@@ -35,7 +35,7 @@ function stableNodeIndex(graph) {
35
35
  if (!raw) continue
36
36
  const stem = terminalLineSuffix(raw)
37
37
  const params = Number.isInteger(node?.complexity?.params) ? node.complexity.params : ''
38
- const signature = [stem, node?.symbol_kind || '', node?.exported === true ? 'exported' : '', params].join('\u0000')
38
+ const signature = [stem, node?.symbol_kind || '', node?.symbol_space || '', node?.exported === true ? 'exported' : '', params].join('\u0000')
39
39
  if (!groups.has(signature)) groups.set(signature, [])
40
40
  groups.get(signature).push(node)
41
41
  }
@@ -180,7 +180,7 @@ function sanitizeNodeV3(value) {
180
180
  else delete out.file_type;
181
181
  if (sourceFile) out.source_file = sourceFile;
182
182
  else delete out.source_file;
183
- for (const key of ['symbol_kind', 'member_of', 'visibility']) setIf(out, key, safeToken(value[key], 128));
183
+ for (const key of ['symbol_kind', 'symbol_space', 'member_of', 'visibility']) setIf(out, key, safeToken(value[key], 128));
184
184
  if (out.complexity) {
185
185
  for (const key of ['family', 'scope', 'complexityScope', 'confidence']) {
186
186
  const safe = safeToken(value.complexity?.[key], 32);
@@ -91,7 +91,9 @@ export async function tOpenRepo(g, args, ctx) {
91
91
  savedPrecision = ['lsp', 'off'].includes(saved.graphPrecisionMode) ? saved.graphPrecisionMode : defaultPrecisionMode()
92
92
  schemaUpgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
93
93
  || !Number.isInteger(saved.edgeProvenanceV) || saved.edgeProvenanceV < 1
94
- || !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 4
94
+ || !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 5
95
+ || !Number.isInteger(saved.reExportOccurrencesV) || saved.reExportOccurrencesV < 1
96
+ || !Number.isInteger(saved.symbolSpacesV) || saved.symbolSpacesV < 1
95
97
  if (savedPrecision === 'lsp') {
96
98
  const overlay = readPrecisionOverlay(graphPath, saved)
97
99
  precisionUpgrade = !overlay || (typeof overlay.semanticInputFingerprint === 'string'
@@ -0,0 +1,164 @@
1
+ import {isStructuralRelation} from '../graph/relations.js'
2
+ import {boundedInteger} from '../bounds.js'
3
+ import {isSymbol, labelOf} from './graph-context.mjs'
4
+ import {toolResult} from './tool-result.mjs'
5
+
6
+ const MAX_LINE_SAMPLES = 5
7
+
8
+ const fileOf = (g, id) => {
9
+ const node = g.byId.get(String(id))
10
+ return String(node?.source_file || (isSymbol(id) ? String(id).split('#')[0] : id))
11
+ }
12
+
13
+ function aggregateEdges(g, edges, cap) {
14
+ const groups = new Map()
15
+ for (const edge of edges || []) {
16
+ if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
17
+ const id = String(edge.id)
18
+ const relation = String(edge.relation || 'references')
19
+ const key = `${id}\0${relation}`
20
+ let group = groups.get(key)
21
+ if (!group) {
22
+ group = {id, label: labelOf(g, id), file: fileOf(g, id), relation, count: 0, lines: []}
23
+ groups.set(key, group)
24
+ }
25
+ group.count++
26
+ if (Number.isInteger(edge.line) && group.lines.length < MAX_LINE_SAMPLES && !group.lines.includes(edge.line)) group.lines.push(edge.line)
27
+ if (edge.typeOnly === true) group.typeOnly = true
28
+ if (edge.compileOnly === true) group.compileOnly = true
29
+ }
30
+ const all = [...groups.values()].sort((left, right) => right.count - left.count
31
+ || left.file.localeCompare(right.file) || left.id.localeCompare(right.id))
32
+ return {total: all.length, shown: all.slice(0, cap), capped: all.length > cap}
33
+ }
34
+
35
+ const sameOrigin = (occurrence, definition) => occurrence.originId === definition.id
36
+ || (occurrence.originFile === definition.file && occurrence.originName === definition.name)
37
+
38
+ function exactReExportSites(g, definition, cap) {
39
+ const occurrences = Array.isArray(g.reExportOccurrences) ? g.reExportOccurrences : []
40
+ const exposures = new Map()
41
+ if (definition.exported) exposures.set(definition.file, new Set([definition.name]))
42
+ const shown = new Map()
43
+ const add = (occurrence, exported = occurrence.exported) => {
44
+ const key = `${occurrence.file}\0${occurrence.line}\0${exported}\0${occurrence.kind}`
45
+ if (shown.has(key)) return false
46
+ shown.set(key, {
47
+ file: occurrence.file,
48
+ line: occurrence.line,
49
+ kind: occurrence.kind,
50
+ exported,
51
+ imported: occurrence.imported,
52
+ targetFile: occurrence.targetFile,
53
+ typeOnly: occurrence.typeOnly === true,
54
+ ...(occurrence.specifier ? {specifier: occurrence.specifier} : {}),
55
+ ...(occurrence.originFile ? {originFile: occurrence.originFile, originName: occurrence.originName} : {}),
56
+ })
57
+ return true
58
+ }
59
+ for (let pass = 0; pass <= occurrences.length; pass++) {
60
+ let changed = false
61
+ for (const occurrence of occurrences) {
62
+ if (occurrence.kind === 'star') {
63
+ const names = exposures.get(occurrence.targetFile)
64
+ if (!names) continue
65
+ for (const name of names) {
66
+ if (name === 'default') continue
67
+ changed = add(occurrence, name) || changed
68
+ let exported = exposures.get(occurrence.file)
69
+ if (!exported) exposures.set(occurrence.file, (exported = new Set()))
70
+ const before = exported.size
71
+ exported.add(name)
72
+ changed = exported.size !== before || changed
73
+ }
74
+ continue
75
+ }
76
+ if (!sameOrigin(occurrence, definition)) continue
77
+ changed = add(occurrence) || changed
78
+ if (occurrence.kind !== 'namespace') {
79
+ let exported = exposures.get(occurrence.file)
80
+ if (!exported) exposures.set(occurrence.file, (exported = new Set()))
81
+ const before = exported.size
82
+ exported.add(occurrence.exported)
83
+ changed = exported.size !== before || changed
84
+ }
85
+ }
86
+ if (!changed) break
87
+ }
88
+ const all = [...shown.values()].sort((left, right) => left.file.localeCompare(right.file)
89
+ || left.line - right.line || left.exported.localeCompare(right.exported))
90
+ return {total: all.length, shown: all.slice(0, cap), capped: all.length > cap}
91
+ }
92
+
93
+ function linesForGroups(title, groups) {
94
+ if (!groups.shown.length) return [`${title}: none`]
95
+ const lines = [`${title}: ${groups.total} container(s)${groups.capped ? ` (${groups.shown.length} shown)` : ''}`]
96
+ for (const group of groups.shown) {
97
+ const sites = group.lines.length ? `:${group.lines.join(',')}` : ''
98
+ lines.push(` ${group.count}× ${group.relation} ${group.label} [${group.file}${sites}]`)
99
+ }
100
+ return lines
101
+ }
102
+
103
+ function textFor(result) {
104
+ if (result.status !== 'OK') return result.text || `Context bundle: ${result.status}`
105
+ const definition = result.definition
106
+ const lines = [
107
+ `Context bundle: ${definition.label} [${definition.id}]`,
108
+ `Definition: ${definition.file}:${definition.line} (${definition.kind}, ${definition.space} space)`,
109
+ `Evidence: ${result.evidence.state}; ${result.references.occurrences} exact reference occurrence(s) in ${result.references.files} file(s).`,
110
+ ...linesForGroups('Inbound', result.inbound),
111
+ ...linesForGroups('Outbound', result.outbound),
112
+ `Re-export sites: ${result.reExports.total}${result.reExports.capped ? ` (${result.reExports.shown.length} shown)` : ''}`,
113
+ ]
114
+ for (const site of result.reExports.shown) lines.push(` ${site.file}:${site.line} ${site.kind} ${site.imported} → ${site.exported}${site.typeOnly ? ' [type]' : ''}`)
115
+ for (const source of result.source) lines.push('', `${source.role} source (${source.file}:${source.startLine}-${source.endLine}):`, source.text)
116
+ return lines.join('\n')
117
+ }
118
+
119
+ export async function tContextBundle(g, args = {}, ctx = {}, inspectSymbol) {
120
+ if (typeof inspectSymbol !== 'function') throw new Error('context_bundle requires inspect_symbol support')
121
+ const maxRelated = boundedInteger(args.max_related, 10, 1, 30)
122
+ const maxReExports = boundedInteger(args.max_reexports, 20, 1, 100)
123
+ const maxSourceFiles = boundedInteger(args.max_source_files, 4, 1, 8)
124
+ const inspected = await inspectSymbol(g, {
125
+ ...args,
126
+ max_containers: Math.min(maxRelated, boundedInteger(args.max_containers, 10, 1, 30)),
127
+ context_lines: boundedInteger(args.context_lines, 4, 0, 12),
128
+ }, ctx)
129
+ const inspection = inspected?.result
130
+ if (!inspection || inspection.status !== 'OK') return inspected
131
+ const definition = {
132
+ ...inspection.definition,
133
+ name: String(g.byId.get(inspection.definition.id)?.label || '').replace(/\(\)$/, ''),
134
+ }
135
+ const inbound = aggregateEdges(g, g.inn.get(definition.id), maxRelated)
136
+ const outbound = aggregateEdges(g, g.out.get(definition.id), maxRelated)
137
+ const reExports = exactReExportSites(g, definition, maxReExports)
138
+ const source = []
139
+ if (inspection.source.definition) source.push({role: 'Definition', ...inspection.source.definition})
140
+ for (const excerpt of inspection.source.callers || []) {
141
+ if (source.length >= maxSourceFiles) break
142
+ if (source.some((item) => item.file === excerpt.file && item.focusLine === excerpt.focusLine)) continue
143
+ source.push({role: 'Caller', ...excerpt})
144
+ }
145
+ const result = {
146
+ status: 'OK', definition, evidence: inspection.evidence,
147
+ references: {
148
+ occurrences: inspection.exact.occurrences,
149
+ files: inspection.exact.files,
150
+ containers: inspection.exact.containers,
151
+ capped: inspection.exact.capped,
152
+ },
153
+ inbound, outbound, reExports, source,
154
+ }
155
+ return toolResult(textFor(result), result, {
156
+ warnings: inspected.warnings,
157
+ completeness: {
158
+ status: inspection.evidence.state === 'EXACT' && !inspection.exact.capped && !inbound.capped && !outbound.capped && !reExports.capped ? 'complete' : 'bounded',
159
+ relatedLimit: maxRelated,
160
+ reExportLimit: maxReExports,
161
+ sourceFileLimit: maxSourceFiles,
162
+ },
163
+ })
164
+ }
@@ -48,6 +48,8 @@ export function tGraphStats(g, ctx) {
48
48
  g.edgeProvenanceV ? `- Edge provenance: v${g.edgeProvenanceV} (${fmt(provenance.counts)}; ${provenance.complete ? 'complete' : `${provenance.counts.UNKNOWN} unclassified`})` : `- Edge provenance: unavailable (rebuild_graph required)`,
49
49
  `- Semantic precision: ${precision.state}${precision.provider ? ` via ${precision.provider}${precision.providerVersion ? ` ${precision.providerVersion}` : ''}${precision.typescriptVersion ? ` (TypeScript ${precision.typescriptVersion})` : ''}` : ''}; ${precision.verifiedEdges || 0} EXACT_LSP edge(s), ${precision.queried || 0}/${precision.candidates || 0} bounded target(s) queried${precision.truncated ? ' (partial/truncated)' : ''}${precision.reason ? `; ${precision.reason}` : ''}`,
50
50
  g.barrelResolutionV ? `- Barrel resolution: v${g.barrelResolutionV} (semantic tools look through JS/TS re-export facades)` : `- Barrel resolution: unavailable (rebuild_graph required for JS/TS barrel transparency)`,
51
+ g.reExportOccurrencesV ? `- Re-export occurrences: v${g.reExportOccurrencesV} (${g.reExportOccurrences.length} exact site(s))` : `- Re-export occurrences: unavailable (rebuild_graph required)`,
52
+ g.symbolSpacesV ? `- TypeScript symbol spaces: v${g.symbolSpacesV} (type/value identities separated)` : `- TypeScript symbol spaces: unavailable (rebuild_graph required)`,
51
53
  `- Relations: ${fmt(relCount)}`,
52
54
  Object.keys(confCount).length ? `- Legacy confidence: ${fmt(confCount)}` : null,
53
55
  `- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
@@ -1,4 +1,5 @@
1
1
  import {readFileSync, statSync} from 'node:fs'
2
+ import {boundedInteger} from '../bounds.js'
2
3
  import {resolveRepoPath} from '../repo-path.js'
3
4
  import {isStructuralRelation} from '../graph/relations.js'
4
5
  import {querySymbolPrecision} from '../precision/symbol-query.js'
@@ -9,11 +10,6 @@ const MAX_EXCERPT_CHARS = 4_000
9
10
  const MAX_LOCATION_SAMPLES = 5
10
11
  const MAX_GRAPH_OCCURRENCES = 100
11
12
 
12
- const boundedInteger = (value, fallback, minimum, maximum) => {
13
- const number = Number(value)
14
- return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback))
15
- }
16
-
17
13
  const sourceLine = (node) => {
18
14
  const match = /L(\d+)/.exec(String(node?.source_location || ''))
19
15
  return match ? Number(match[1]) : 1
@@ -25,7 +21,10 @@ function candidateNodes(g, query, limit = 20) {
25
21
  const exact = g.byLabel.get(text)
26
22
  const nodes = exact?.length ? exact : g.nodes.filter((node) =>
27
23
  String(node.id).toLowerCase().includes(text) || String(node.label || '').toLowerCase().includes(text))
28
- return nodes.slice(0, limit).map((node) => ({id: String(node.id), label: String(node.label || node.id), file: node.source_file || null}))
24
+ return nodes.slice(0, limit).map((node) => ({
25
+ id: String(node.id), label: String(node.label || node.id), file: node.source_file || null,
26
+ ...(node.symbol_space ? {space: node.symbol_space} : {}),
27
+ }))
29
28
  }
30
29
 
31
30
  function excerpt(repoRoot, file, focusLine, contextLines) {
@@ -192,6 +191,8 @@ export async function tInspectSymbol(g, args = {}, ctx = {}) {
192
191
  id: targetId,
193
192
  label: String(node.label || targetId),
194
193
  kind: String(node.symbol_kind || 'symbol'),
194
+ space: String(node.symbol_space || 'value'),
195
+ exported: node.exported === true,
195
196
  file: String(node.source_file || targetId.split('#')[0]),
196
197
  line: sourceLine(node),
197
198
  ...(node.source_range ? {range: node.source_range} : {}),
@@ -425,7 +425,7 @@ export class StdioLspClient {
425
425
  await this.writeMessage({jsonrpc: JSON_RPC_VERSION, method, params})
426
426
  }
427
427
 
428
- async initialize({capabilities = {}, initializationOptions, clientInfo = {name: 'weavatrix', version: '0.2.5'}} = {}) {
428
+ async initialize({capabilities = {}, initializationOptions, clientInfo = {name: 'weavatrix', version: '0.2.6'}} = {}) {
429
429
  if (this.state !== 'running') throw new Error(`LSP initialize is invalid in state=${this.state}`)
430
430
  const result = await this.request('initialize', {
431
431
  processId: process.pid,
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { boundedInteger } from "../bounds.js";
5
6
  import { atomicWriteFileSync } from "../graph/file-lock.js";
6
7
  import { edgeProvenance } from "../graph/edge-provenance.js";
7
8
  import { isStructuralRelation } from "../graph/relations.js";
@@ -450,11 +451,6 @@ function publicSemanticSafetyReason(reason) {
450
451
  : "TypeScript project configuration could not be verified safely";
451
452
  }
452
453
 
453
- function boundedInteger(value, fallback, minimum, maximum) {
454
- const number = Number(value);
455
- return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback));
456
- }
457
-
458
454
  export async function buildLspPrecisionOverlay({
459
455
  repoRoot,
460
456
  graph,
@@ -1,5 +1,6 @@
1
1
  import {existsSync, readFileSync, statSync} from 'node:fs'
2
2
  import {dirname, resolve} from 'node:path'
3
+ import {boundedInteger} from '../bounds.js'
3
4
  import {atomicWriteFileSync, withFileLock} from '../graph/file-lock.js'
4
5
  import {
5
6
  buildLspPrecisionOverlay,
@@ -15,11 +16,6 @@ const MAX_CACHE_BYTES = 8 * 1024 * 1024
15
16
  const MAX_CACHE_READ_BYTES = 16 * 1024 * 1024
16
17
  const inFlight = new Map()
17
18
 
18
- const boundedInteger = (value, fallback, minimum, maximum) => {
19
- const number = Number(value)
20
- return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback))
21
- }
22
-
23
19
  export function symbolPrecisionCachePath(graphPath) {
24
20
  return resolve(dirname(graphPath), SYMBOL_PRECISION_CACHE_FILE)
25
21
  }