weavatrix 0.2.4 → 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. **32 tools available; 29 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)
@@ -21,8 +21,10 @@ answers grep can't produce:
21
21
  untracked included), maps the changed files and symbols onto the graph, and lists everything that
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
- - *"Who calls this function?"* → `get_dependents` walks reverse edges transitively: every caller,
25
- importer and subclass that can feel the refactor, ranked by proximity × connectivity.
24
+ - *"Who calls this function?"* → `inspect_symbol` returns exact bounded TS/JS occurrences plus
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
27
+ `include_container_importers:true` only when a broader module-import radius is intended.
26
28
  - *"Did my refactor actually decouple anything?"* → `graph_diff base_ref=HEAD~1` builds an immutable
27
29
  baseline graph without checking it out, then reports the structural delta: new module
28
30
  dependencies, broken or introduced import cycles, and symbols that lost their last caller.
@@ -109,7 +111,8 @@ An agent skill with recipes ships in [skill/SKILL.md](skill/SKILL.md) — instal
109
111
  `change_impact`, `git_history`, `graph_diff`, `get_architecture_contract`,
110
112
  `prepare_change`. Runtime dependencies, TypeScript type-only coupling and language
111
113
  compile-only edges (Rust module/use, Java imports) are reported separately where that distinction
112
- 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`.
113
116
 
114
117
  Every current edge carries versioned provenance. The parser emits `EXTRACTED`, `RESOLVED`, and
115
118
  `INFERRED`; the built-in bounded TypeScript/JavaScript precision overlay upgrades only references
@@ -117,7 +120,7 @@ confirmed by its bundled `typescript-language-server` + TypeScript runtime to `E
117
120
  `CONFLICT` means evidence disagrees. `graph_stats` reports the provenance breakdown and the semantic
118
121
  provider's `COMPLETE`, `PARTIAL`, `UNAVAILABLE`, or `OFF` state; `OFF` means precision was explicitly
119
122
  disabled and only static evidence is active. Java and Rust language-server providers are not bundled
120
- in 0.2.4: 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
121
124
  TypeScript/JavaScript overlay.
122
125
 
123
126
  The bounded JS/TS provider is enabled by default for new graphs. Set `WEAVATRIX_PRECISION=off`
@@ -126,19 +129,28 @@ before starting the MCP server for parser-only operation from the first build, o
126
129
  choice as **TypeScript/JavaScript semantic precision**.
127
130
 
128
131
  **search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
129
- symbol's actual code in one hop), `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:
130
135
  Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web …)
131
136
 
132
- **health** — `find_dead_code` (bounded review queue for statically unreferenced files, functions,
133
- methods, and symbols, with confidence/evidence and explicit public/framework/dynamic caveats),
134
- `run_audit` (unused files/exports/dependencies, missing npm/Go/Python deps, runtime
137
+ **health** — `find_dead_code` (bounded review queue for statically unreferenced and test-only files,
138
+ functions, methods, and symbols, with confidence/evidence and explicit public/framework/dynamic caveats),
139
+ `run_audit` (unused files/exports/dependencies with an explicit manifest-check summary,
140
+ missing npm/Go/Python deps, runtime
135
141
  cycles, type-only/compile-only coupling, orphans, boundary rules, offline OSV vulnerabilities + typosquat +
136
142
  lockfile drift; accepts an immutable `base_ref`, `changed_files`, and `debt: new|existing|all` for
137
143
  review-scoped results), `find_duplicates` (MOSS winnowing over method bodies — catches copy-paste even
138
- after renames), `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots
139
- ranked by connectivity — tests are never executed), `verify_architecture`,
144
+ after renames and can inspect strict small clones down to 12 tokens), `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots
145
+ ranked by connectivity — tests are never executed), `hot_path_review` (bounded local-cost evidence
146
+ with separate graph/test risk), `verify_architecture`,
140
147
  `explain_architecture_violation`, `propose_architecture_exception`
141
148
 
149
+ `hot_path_review` ranks production symbols using parser-derived local time, memory, cyclomatic and
150
+ call-site facts plus exact inside-loop allocation, copy, scan, sort and recursion evidence. Graph
151
+ fan-in/fan-out and measured coverage (or explicitly labelled static test reachability) remain
152
+ separate from local syntax cost. The ranking is not profiler data or interprocedural Big-O.
153
+
142
154
  **build** — `rebuild_graph` (reports the structural delta, keeps the prior state as
143
155
  `graph.prev.json`)
144
156
 
@@ -184,6 +196,36 @@ disclosed instead of silently guessed; and the server **hot-reloads its watched
184
196
  modules and catalog** when those files change — other MCP helpers and analysis engines require a
185
197
  reconnect.
186
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
+
212
+ ### 0.2.5 exact-symbol and graph-fidelity patch
213
+
214
+ - New `inspect_symbol` spends a bounded LSP query on one requested TS/JS declaration, groups exact
215
+ occurrences by logical container, and returns definition/caller source context. Its separate LRU
216
+ cache is revision/config fingerprinted and never replaces the broad precision overlay.
217
+ - Default-object service facades resolve to their underlying helpers, while `get_dependents` no
218
+ longer silently expands every symbol query to all importers of its file.
219
+ - Python receiver-aware method dispatch and unambiguous wildcard imports distinguish same-named
220
+ methods across classes without promoting ambiguous dynamic evidence.
221
+ - `run_audit` makes unused-dependency checking visible even when clean; dead-code review identifies
222
+ production symbols referenced only by tests; small-clone scanning can be lowered to 12 tokens with
223
+ stricter evidence rules.
224
+ - `hot_path_review` adds a bounded offline performance-review queue with line-addressable local cost
225
+ evidence, separate graph risk, and honest measured/unavailable coverage states.
226
+
227
+ Full patch notes: [docs/releases/v0.2.5.md](docs/releases/v0.2.5.md).
228
+
187
229
  ### 0.2.4 graph-mode correctness and semantic precision patch
188
230
 
189
231
  - `graph_diff base_ref=...` builds the immutable baseline in the current graph mode, so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.2.4",
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,12 +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` and read semantic precision state,
44
- provider and `EXACT_LSP` count. For TypeScript/JavaScript, use an exact node id with
45
- `get_dependents` and inspect the edge provenance; use `find_dead_code` for bounded zero-reference
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
47
+ graph impact and `find_dead_code` for bounded zero-reference
46
48
  candidates. Treat `PARTIAL`, `UNAVAILABLE`, `OFF`, or zero exact edges as incomplete evidence, not
47
49
  a compiler-exact result; `OFF` means the caller explicitly selected static-only mode. Java and Rust
48
- providers are not bundled in 0.2.4, 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
49
51
  repository has a complete TypeScript/JavaScript overlay.
50
52
  - **Explore an architectural question**: use broad `query_graph` when entry points are unknown; its
51
53
  bootstrap/tool-execution ranking prefers production executables over docs, sites and fixtures.
@@ -89,7 +91,13 @@ Start from the task, not from the complete tool list:
89
91
  `seed_files` to `query_graph` when the intended entry points are already known.
90
92
  - **Coverage**: `coverage_map` reads an existing report. `unavailable` means no supported report was
91
93
  found, not 0% coverage; do not rank testing risk from that state.
92
- - **Audit completeness**: read `checks.osv.status` and `checks.malware.status`. For OSV, `OK` is
94
+ - **Local hot paths**: `hot_path_review` ranks parser-derived local syntax cost and reports graph
95
+ fan-in/fan-out plus test evidence separately. `actualCoverage: NOT_AVAILABLE` is not zero coverage,
96
+ and the score is not profiler data or an interprocedural Big-O proof. Inspect its line evidence and
97
+ measure runtime before scheduling a performance rewrite.
98
+ - **Audit completeness**: read `dependencyReport.status` plus its unused/missing counts, then
99
+ `checks.osv.status` and `checks.malware.status`. A dependency result from a partial graph is not a
100
+ repository-wide clean zero. For OSV, `OK` is
93
101
  complete for the recorded dependency fingerprint; `PARTIAL` is incomplete or stale,
94
102
  `NOT_CHECKED` has no repository-specific result, and `ERROR` means the local check failed. Treat
95
103
  the same non-`OK` states as incomplete for malware scanning. Refresh OSV only when the user has
@@ -118,8 +126,10 @@ Start from the task, not from the complete tool list:
118
126
  - **Orient in the configured repo**: `module_map` → `list_communities` → `god_nodes`. Hub ranking
119
127
  is production-only by default; use `include_classified:true` only when tests/generated/build
120
128
  surfaces are deliberately part of the question.
121
- - **Refactor safety for one symbol**: `get_dependents` → `coverage_map` (low coverage × many
129
+ - **Refactor safety for one symbol**: `inspect_symbol` → `get_dependents` → `coverage_map` (low coverage × many
122
130
  dependents ⇒ write tests first) → `read_source`.
131
+ - **Performance review**: `hot_path_review` → inspect its local evidence with `read_source` → use
132
+ `get_dependents` for change risk → confirm with the repository's profiler/benchmark before editing.
123
133
  - **Pre-PR review of your current changes**: `change_impact` (auto merge-base; includes uncommitted
124
134
  and untracked work, coverage attached, untested hotspots called out) → drill with `get_dependents`.
125
135
  - **Impact of a PR that is NOT checked out**: pass its changed-file list explicitly —
@@ -131,7 +141,8 @@ Start from the task, not from the complete tool list:
131
141
  orphan drift rather than raw edge counts.
132
142
  - **Health sweep**: for a branch/PR review prefer
133
143
  `run_audit base_ref=<merge-base> debt=new changed_files=[…]`; for repository maintenance use
134
- `run_audit debt=all`. Then call `find_dead_code`, `find_duplicates` and `coverage_map`. A changed-file-only audit is
144
+ `run_audit debt=all`. Then call `find_dead_code`, `find_duplicates`, `coverage_map` and
145
+ `hot_path_review`. A changed-file-only audit is
135
146
  scope, not proof of new debt. Inspect the source behind shortlisted findings before proposing edits.
136
147
  - **Dead-code and dead-method review**: call `find_dead_code` for a bounded production-code queue of
137
148
  files, functions, methods and symbols. Narrow with `path` or `kinds=["method"]`; keep the default
@@ -100,7 +100,16 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
100
100
  const nodes = graph.nodes || [], links = graph.links || [];
101
101
  const ep = (v) => (v && typeof v === "object" ? v.id : v);
102
102
  const inbound = new Set();
103
- for (const l of links) if (!isStructuralRelation(l.relation)) inbound.add(ep(l.target));
103
+ const inboundSources = new Map();
104
+ const nodesById = new Map(nodes.map((node) => [String(node.id), node]));
105
+ for (const l of links) if (!isStructuralRelation(l.relation)) {
106
+ const target = String(ep(l.target));
107
+ const source = String(ep(l.source));
108
+ inbound.add(target);
109
+ const values = inboundSources.get(target) || [];
110
+ values.push(source);
111
+ inboundSources.set(target, values);
112
+ }
104
113
 
105
114
  // whole-repo identifier frequency: a symbol whose name appears MORE than once total (its definition + at least
106
115
  // one use, same-file OR cross-file) is referenced. Errs toward "alive" (common-named symbols never flagged).
@@ -115,6 +124,16 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
115
124
  (symsByFile.get(n.source_file) || symsByFile.set(n.source_file, []).get(n.source_file)).push(n);
116
125
  }
117
126
 
127
+ const symbolNames = new Set([...symById.values()].map((node) => bareName(node.label)).filter(Boolean));
128
+ const occurrenceFiles = new Map();
129
+ for (const [file, text] of sources) for (const match of String(text || "").matchAll(IDENT_RE)) {
130
+ const name = match[0];
131
+ if (!symbolNames.has(name)) continue;
132
+ const files = occurrenceFiles.get(name) || new Set();
133
+ files.add(file);
134
+ occurrenceFiles.set(name, files);
135
+ }
136
+
118
137
  // decorated defs (@app.route/@app.event/@pytest.fixture…) are entered by the framework: trust the
119
138
  // builder's flag when present, else walk the source line(s) above the definition (graph-builder graphs).
120
139
  const lineCache = new Map();
@@ -151,6 +170,32 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
151
170
  }
152
171
  const deadSet = new Set(deadSymbols.map((s) => s.id));
153
172
 
173
+ // A production declaration consumed only by tests is live to the raw graph but dead to production.
174
+ // Keep it in a separate review class: this is useful evidence, never an automatic delete verdict.
175
+ const testOnlySymbols = [];
176
+ for (const n of symById.values()) {
177
+ if (deadSet.has(n.id) || isTestFile(n.source_file) || isDecorated(n)) continue;
178
+ const sourcesForSymbol = inboundSources.get(String(n.id)) || [];
179
+ const sourceFiles = sourcesForSymbol.map((id) => nodesById.get(id)?.source_file || (id.includes("#") ? id.split("#")[0] : id));
180
+ const hasTestInbound = sourceFiles.some((file) => isTestFile(file));
181
+ const hasProductionInbound = sourceFiles.some((file) => file && !isTestFile(file));
182
+ const name = bareName(n.label);
183
+ const occurrenceSet = occurrenceFiles.get(name) || new Set();
184
+ const externalOccurrences = [...occurrenceSet].filter((file) => file !== n.source_file);
185
+ const lexicalTestOnly = externalOccurrences.length > 0 && externalOccurrences.every((file) => isTestFile(file));
186
+ if (hasProductionInbound || (!hasTestInbound && !lexicalTestOnly)) continue;
187
+ testOnlySymbols.push({
188
+ id: n.id,
189
+ file: n.source_file,
190
+ label: n.label,
191
+ test: false,
192
+ reason: "referenced only from test/e2e code; no production consumer was found",
193
+ testConsumerFiles: [...new Set(sourceFiles.filter((file) => file && isTestFile(file)))].sort(),
194
+ evidence: hasTestInbound ? "graph" : "lexical",
195
+ publicApi: n.exported === true || ["public", "protected"].includes(String(n.visibility || "").toLowerCase()),
196
+ });
197
+ }
198
+
154
199
  const deadFiles = [];
155
200
  for (const n of nodes) {
156
201
  if (String(n.id).includes("#")) continue; // file nodes only
@@ -161,9 +206,10 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
161
206
 
162
207
  return {
163
208
  deadSymbols,
209
+ testOnlySymbols,
164
210
  deadFiles,
165
211
  referencedIds: [...symById.values()].filter(isReferenced).map((n) => n.id),
166
- stats: { symbols: symById.size, deadSymbols: deadSymbols.length, files: nodes.filter((n) => !String(n.id).includes("#")).length, deadFiles: deadFiles.length },
212
+ stats: { symbols: symById.size, deadSymbols: deadSymbols.length, testOnlySymbols: testOnlySymbols.length, files: nodes.filter((n) => !String(n.id).includes("#")).length, deadFiles: deadFiles.length },
167
213
  };
168
214
  }
169
215
 
@@ -45,6 +45,7 @@ function symbolCandidate(item, node, context) {
45
45
  const source = String(context.sources.get(file) || "");
46
46
  const pathInfo = context.classify(file, source);
47
47
  const publicSurface = isPublicSurface(node);
48
+ const testOnly = item.testOnly === true;
48
49
  const externalEntry = context.entrySet.has(file) || isFrameworkEntryFile(file);
49
50
  const framework = context.frameworkByFile.get(file) || null;
50
51
  const dynamicFile = context.dynamicTargets.has(file) || DYNAMIC_RE.test(source);
@@ -86,7 +87,7 @@ function symbolCandidate(item, node, context) {
86
87
  return {
87
88
  id: String(item.id),
88
89
  kind,
89
- classification: publicSurface ? `public-${kind}` : kind === "method" ? "internal-method" : kind === "function" ? "internal-function" : "unreferenced-symbol",
90
+ classification: testOnly ? `test-only-${kind}` : publicSurface ? `public-${kind}` : kind === "method" ? "internal-method" : kind === "function" ? "internal-function" : "unreferenced-symbol",
90
91
  file,
91
92
  line: lineOf(node),
92
93
  symbol: bareLabel(node?.label || item.label),
@@ -96,8 +97,8 @@ function symbolCandidate(item, node, context) {
96
97
  confidence,
97
98
  reason: item.reason,
98
99
  evidence: [
99
- { kind: "graph", fact: "No inbound non-structural graph edge targets this symbol." },
100
- { kind: "source-index", fact: "Its identifier has no second indexed occurrence that establishes a caller." },
100
+ ...(testOnly ? [{kind: item.evidence || "graph", fact: `Only test/e2e consumers were found${item.testConsumerFiles?.length ? `: ${item.testConsumerFiles.join(", ")}` : "."}`}]
101
+ : [{ kind: "graph", fact: "No inbound non-structural graph edge targets this symbol." }, { kind: "source-index", fact: "Its identifier has no second indexed occurrence that establishes a caller." }]),
101
102
  ...(exactNoReference ? [{kind: "exact-lsp", fact: "The active language server returned no in-workspace references for this exact declaration."}] : []),
102
103
  ],
103
104
  caveats,
@@ -105,7 +106,9 @@ function symbolCandidate(item, node, context) {
105
106
  externallyEnteredFile: externalEntry,
106
107
  pathClasses: pathInfo.classes || [],
107
108
  matchedPathRule: pathInfo.matchedRule || null,
108
- reviewAction: "Confirm with read_source, get_dependents, exact search, framework/config inspection and tests; never auto-delete.",
109
+ reviewAction: testOnly
110
+ ? "Confirm that no production/config/framework consumer exists; decide whether the declaration is intentional test support or removable with its tests. Never auto-delete."
111
+ : "Confirm with read_source, get_dependents, exact search, framework/config inspection and tests; never auto-delete.",
109
112
  };
110
113
  }
111
114
 
@@ -182,7 +185,7 @@ export function computeDeadCodeReview(graph, sources, options = {}) {
182
185
  const context = { sources, entrySet, dynamicTargets, frameworkByFile, classify, repoSignals, exactNoReferenceIds };
183
186
  const dead = computeDead(graph, sources, { entrySet });
184
187
  const nodesById = new Map((graph.nodes || []).map((node) => [String(node.id), node]));
185
- const rawSymbols = dead.deadSymbols
188
+ const rawSymbols = [...dead.deadSymbols, ...(dead.testOnlySymbols || []).map((item) => ({...item, testOnly: true}))]
186
189
  .map((item) => ({ item, node: nodesById.get(String(item.id)) }))
187
190
  .filter((entry) => entry.node)
188
191
  .map(({ item, node }) => symbolCandidate(item, node, context));
@@ -236,6 +239,7 @@ export function computeDeadCodeReview(graph, sources, options = {}) {
236
239
  indexedSymbols: dead.stats.symbols,
237
240
  indexedFiles: dead.stats.files,
238
241
  rawDeadSymbols: rawSymbols.length,
242
+ rawTestOnlySymbols: (dead.testOnlySymbols || []).length,
239
243
  rawDeadFiles: rawFiles.length,
240
244
  reviewCandidates: candidates.length,
241
245
  byConfidence: {
@@ -6,7 +6,7 @@ import { createPathClassifier, hasPathClass } from "../path-classification.js";
6
6
  import { listRepoFiles } from "./internal-audit.collect.js";
7
7
  import { stripNonCode, bodyEndLineCount, tokenize, fingerprints, extractLargeStrings } from "./duplicates.tokenize.js";
8
8
 
9
- const FLOOR_TOKENS = 30; // fragments below this never enter the index (UI slider min)
9
+ const FLOOR_TOKENS = 12; // absolute safety floor; normal scans still default to 30+ tokens
10
10
  const FLOOR_SIM = 0.5; // pairs below this are not reported (UI slider min)
11
11
  const MAX_BODY_LINES = 400;
12
12
  // File types with no code SYMBOLS to fragment (stylesheets, markup, docs, single-file components). The
@@ -93,7 +93,11 @@ function pairsForMode(frags, mode) {
93
93
  const i = Math.floor(key / 1000000), j = key % 1000000;
94
94
  const union = eff[i] + eff[j] - count;
95
95
  const sim = union > 0 ? count / union : 0;
96
- if (sim >= FLOOR_SIM && count >= MIN_SHARED_FP) pairs.push([i, j, Math.round(sim * 100)]);
96
+ const smallFragment = Math.min(frags[i].n, frags[j].n) < 30;
97
+ const enoughEvidence = smallFragment
98
+ ? sim >= 0.95 && count >= 2
99
+ : count >= MIN_SHARED_FP;
100
+ if (sim >= FLOOR_SIM && enoughEvidence) pairs.push([i, j, Math.round(sim * 100)]);
97
101
  }
98
102
  return pairs;
99
103
  }
@@ -125,6 +129,7 @@ function loadGraph(graphInput) {
125
129
 
126
130
  export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
127
131
  const includeStrings = !!opts.includeStrings;
132
+ const scanTokenFloor = Math.max(FLOOR_TOKENS, Math.min(400, Number(opts.minTokens) || 30));
128
133
  const graph = loadGraph(graphJsonPath);
129
134
  const byFile = symbolRanges(graph);
130
135
  // total symbol nodes in the graph — 0 means a file-only graph (built by an older builder before
@@ -169,7 +174,7 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
169
174
  const end = start + body.length - 1;
170
175
  if (end - start < 2) continue;
171
176
  const toks = tokenize(stripNonCode(body.join("\n"), py));
172
- if (toks.strict.length < FLOOR_TOKENS) continue;
177
+ if (toks.strict.length < scanTokenFloor) continue;
173
178
  frags.push({
174
179
  id: syms[i].id, label: syms[i].label, file, start, end,
175
180
  n: toks.strict.length,
@@ -184,7 +189,7 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
184
189
  const base = file.split("/").pop();
185
190
  const pushStr = (startLine, endLine, content) => {
186
191
  const toks = tokenize(content);
187
- if (toks.strict.length < FLOOR_TOKENS) return;
192
+ if (toks.strict.length < scanTokenFloor) return;
188
193
  frags.push({
189
194
  id: `${file}#str@${startLine}`, label: `${base}:${startLine} ~${endLine - startLine + 1}-line string`,
190
195
  file, start: startLine, end: endLine, n: toks.strict.length, ...classificationFields(pathInfo), kind: "string",
@@ -224,7 +229,7 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
224
229
  const end = Math.min(start + WINDOW_LINES - 1, lines.length);
225
230
  if (end - start < 2) continue;
226
231
  const toks = tokenize(stripNonCode(lines.slice(start - 1, end).join("\n"), false));
227
- if (toks.strict.length < FLOOR_TOKENS) continue;
232
+ if (toks.strict.length < scanTokenFloor) continue;
228
233
  frags.push({
229
234
  id: `${file}#win@${start}`, label: `${file.split("/").pop()}:${start}-${end}`, file, start, end,
230
235
  n: toks.strict.length, ...classificationFields(pathInfo),
@@ -250,6 +255,6 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
250
255
  },
251
256
  nameTwinsTruncated: !!nameTwins && nameTwins.length >= 200,
252
257
  },
253
- floors: { tokens: FLOOR_TOKENS, sim: FLOOR_SIM * 100 },
258
+ floors: { tokens: scanTokenFloor, absoluteTokens: FLOOR_TOKENS, sim: FLOOR_SIM * 100 },
254
259
  };
255
260
  }
@@ -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]);