weavatrix 0.3.11 → 0.3.12

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.
@@ -0,0 +1,44 @@
1
+ # Weavatrix 0.3.12
2
+
3
+ 0.3.12 makes the read-only core the provably-pure base of a layered stack. The
4
+ core stays exactly what it was for its users — the same 34 offline analysis tools
5
+ — but it now exposes a small read-only analysis surface for the packages built on
6
+ top of it, and machine gates prove it can neither modify code nor reach anything
7
+ external. Rust cross-crate call resolution and a `query_graph` seeding fix round
8
+ out the release.
9
+
10
+ ## The read-only base of the stack
11
+
12
+ Weavatrix now anchors a stack of growing responsibility:
13
+ `weavatrix-online ⊃ weavatrix-refactor ⊃ weavatrix`. You install one package and
14
+ get everything below it. This release adds only what the base owes the layers
15
+ above; it registers no new tools and changes no existing tool.
16
+
17
+ - New `weavatrix/analysis-kit` export: a narrow, read-only surface (graph
18
+ identity, cycle detection, architecture verification, the bundled tree-sitter
19
+ grammars, the read-only TypeScript language-server client, dead-code signals)
20
+ that upper-layer packages compose. Nothing here writes, applies, or describes
21
+ an edit.
22
+ - Two release gates make the base's guarantees machine-checked, alongside the
23
+ existing ADR 0001 no-network gate: the core source contains **no edit-plan,
24
+ applier, or refactoring-builder marker**, and **no literal URL** at all. A
25
+ vulnerability finding references the advisory id rather than an external link;
26
+ the URL-matching malware heuristics are matchers over scanned code, not links
27
+ the core follows. Installing the base alone can neither modify your code nor
28
+ reach the network.
29
+
30
+ ## Rust cross-crate call resolution
31
+
32
+ Rust call edges now resolve across crates, inline-test coverage is reported
33
+ honestly, and same-crate call noise is reduced — cross-crate `references` edges
34
+ land where a caller reaches a definition in another crate.
35
+
36
+ ## query_graph seeding
37
+
38
+ A `query_graph` question that reduced entirely to stop words (a bare
39
+ `architecture`), or inferred a language absent from the repository (`contract`
40
+ inferring Solidity where there are no `.sol` files), previously returned a
41
+ misleading "No nodes matched". Both now fall back only when they would otherwise
42
+ return nothing: the stop filter relaxes when every token is a stop word, and a
43
+ requested language that matches zero nodes is dropped. Queries that already
44
+ worked are unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "type": "module",
5
5
  "description": "Local repository intelligence MCP for AI coding agents: an always-fresh architecture graph with production-first evidence — blast radius, dead code, endpoints, clones, history, Health and architecture safeguards, with honestly labeled precision.",
6
6
  "author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
@@ -24,6 +24,7 @@
24
24
  "./mcp-runtime": "./src/mcp-runtime.mjs",
25
25
  "./extension-api": "./src/mcp/extension-api.mjs",
26
26
  "./extension/local-services": "./src/extension/local-services.mjs",
27
+ "./analysis-kit": "./src/analysis-kit.mjs",
27
28
  "./package.json": "./package.json"
28
29
  },
29
30
  "files": [
@@ -45,6 +46,7 @@
45
46
  "docs/releases/v0.3.9.md",
46
47
  "docs/releases/v0.3.10.md",
47
48
  "docs/releases/v0.3.11.md",
49
+ "docs/releases/v0.3.12.md",
48
50
  "docs/releases/v0.2.19.md",
49
51
  "README.md",
50
52
  "SECURITY.md",
@@ -19,6 +19,13 @@ export function parseCargoToml(text) {
19
19
  for (const raw of source.split(/\r?\n/)) {
20
20
  const line = raw.replace(/(^|\s)#.*$/, "").trim();
21
21
  if (!line) continue;
22
+ // Array-of-tables headers ([[bin]], [[example]], [[test]], [[bench]]) are neither the [package]
23
+ // table nor a dependency table. Recognise them so their `name =`/`path =` keys are not mis-read:
24
+ // a [[bin]] renamed to a crate ([[bin]] name = "foo") would otherwise overwrite the package name and
25
+ // make every foo:: import look like a self-reference, and a [[bin]] after [dependencies] would leak
26
+ // phantom "name"/"path" dependencies.
27
+ const arrayHeader = /^\[\[([^\]]+)]]$/.exec(line);
28
+ if (arrayHeader) { section = arrayHeader[1].trim(); continue; }
22
29
  const header = /^\[([^\]]+)]$/.exec(line);
23
30
  if (header) { section = header[1].trim(); workspace ||= section === "workspace"; continue; }
24
31
  const kv = /^([A-Za-z0-9_-]+)\s*=\s*(.+)$/.exec(line);
@@ -111,7 +111,7 @@ function symbolRanges(graph) {
111
111
  if (!line) continue;
112
112
  let arr = byFile.get(n.source_file);
113
113
  if (!arr) byFile.set(n.source_file, (arr = []));
114
- arr.push({ id: n.id, label: n.label || n.id, line, symbolKind: n.symbol_kind || "" });
114
+ arr.push({ id: n.id, label: n.label || n.id, line, symbolKind: n.symbol_kind || "", testSurface: n.test_surface === true });
115
115
  }
116
116
  for (const arr of byFile.values()) arr.sort((a, b) => a.line - b.line);
117
117
  return byFile;
@@ -184,6 +184,10 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
184
184
  id: syms[i].id, label: syms[i].label, file, start, end,
185
185
  n: toks.strict.length, declarative,
186
186
  ...classificationFields(pathInfo),
187
+ // Inline test surfaces (Rust `#[cfg(test)] mod tests`, `#[test]` fns) live in production files, so a
188
+ // path check alone misses them; the graph already flags the symbol. OR it in so skip-tests suppresses
189
+ // them by default and two #[test] fns are never reported as a production clone.
190
+ test: hasPathClass(pathInfo, "test", "e2e") || syms[i].testSurface === true,
187
191
  fp: { strict: fingerprints(toks.strict), renamed: fingerprints(toks.renamed) },
188
192
  });
189
193
  }
@@ -36,7 +36,7 @@ function coverageForRange(record, startLine, endLine) {
36
36
  return total ? covered / total : Number.isFinite(record.pct) ? record.pct : null
37
37
  }
38
38
 
39
- function graphRiskBySymbol(graph, symbolIds) {
39
+ function graphRiskBySymbol(graph, symbolIds, testCallerIds) {
40
40
  const state = new Map([...symbolIds].map((id) => [id, {
41
41
  incoming: 0, outgoing: 0, callers: new Set(), callees: new Set(),
42
42
  }]))
@@ -50,7 +50,10 @@ function graphRiskBySymbol(graph, symbolIds) {
50
50
  item.outgoing++
51
51
  item.callees.add(target)
52
52
  }
53
- if (state.has(target)) {
53
+ // A test caller (inline #[cfg(test)] symbol or a test-path file) must not inflate a production
54
+ // symbol's fan-in — the same reason test symbols are excluded as candidates. Skipped only for the
55
+ // incoming/coupling side; unless the caller opted tests in (testCallerIds is then null).
56
+ if (state.has(target) && !(testCallerIds && testCallerIds.has(source))) {
54
57
  const item = state.get(target)
55
58
  item.incoming++
56
59
  item.callers.add(source)
@@ -135,7 +138,26 @@ export function computeHotPathReview(graph, options = {}) {
135
138
  eligible.push({node, complexity, file, classification: test ? 'test' : classified ? 'classified' : 'production'})
136
139
  }
137
140
 
138
- const riskById = graphRiskBySymbol(graph, new Set(eligible.map(({node}) => String(node.id))))
141
+ // Test callers (inline test_surface symbols and test-path files) that must not count toward a
142
+ // production symbol's fan-in. Built over ALL nodes because a caller may sit outside the review scope.
143
+ const testCallerIds = options.includeTests === true ? null : (() => {
144
+ const ids = new Set()
145
+ const classifyCache = new Map()
146
+ for (const node of graph?.nodes || []) {
147
+ const id = String(node?.id || '')
148
+ if (!id.includes('#')) continue
149
+ let isTest = node?.test_surface === true
150
+ const file = normalize(node?.source_file)
151
+ if (!isTest && file) {
152
+ let info = classifyCache.get(file)
153
+ if (info === undefined) classifyCache.set(file, (info = classifier.explain(file)))
154
+ isTest = hasPathClass(info, 'test', 'e2e')
155
+ }
156
+ if (isTest) ids.add(id)
157
+ }
158
+ return ids
159
+ })()
160
+ const riskById = graphRiskBySymbol(graph, new Set(eligible.map(({node}) => String(node.id))), testCallerIds)
139
161
  const candidates = []
140
162
  for (const entry of eligible) {
141
163
  const {node, complexity, file} = entry
@@ -74,7 +74,7 @@ export async function runSupplyChainChecks(repoPath, {
74
74
  version: hit.pkg.version,
75
75
  reachability,
76
76
  evidence: [
77
- { file: hit.adv.url, line: 0, snippet: `installed via ${hit.pkg.source}${hit.pkg.dev ? " (dev)" : ""}` },
77
+ { file: hit.adv.id, line: 0, snippet: `installed via ${hit.pkg.source}${hit.pkg.dev ? " (dev)" : ""}` },
78
78
  ...reachability.evidence.slice(0, 5).map((item) => ({ file: item.file, line: item.line, snippet: `${item.typeOnly ? "type-only " : ""}${item.kind}` })),
79
79
  ],
80
80
  source: "osv",
@@ -107,7 +107,10 @@ export function parsePipfileDeps(text) {
107
107
  let present = false;
108
108
  for (const raw of String(text || "").split(/\r?\n/)) {
109
109
  const line = raw.replace(/(^|\s)#.*$/, "").trim();
110
- const sec = line.match(/^\[([^\]]+)\]$/);
110
+ // Match single [section] and array-of-tables [[section]] headers alike. Without the [[...]] case a
111
+ // `[[source]]` table following [packages] would leave section === "packages" and its name/url/verify_ssl
112
+ // keys would leak as phantom dependencies (the same blind spot fixed in the Cargo.toml parser).
113
+ const sec = line.match(/^\[+([^\]]+)\]+$/);
111
114
  if (sec) { section = sec[1].trim().toLowerCase(); continue; }
112
115
  if (section !== "packages" && section !== "dev-packages") continue;
113
116
  const kv = line.match(/^["']?([A-Za-z0-9][\w.-]*)["']?\s*=/);
@@ -37,6 +37,13 @@ export function computeStaticTestReachability(graph, {
37
37
  if (!classificationCache.has(normalized)) classificationCache.set(normalized, classifier.explain(normalized, { content: "" }));
38
38
  return classificationCache.get(normalized);
39
39
  };
40
+ // Files that carry inline test surfaces (Rust `#[cfg(test)]` modules / `#[test]` fns live inside the
41
+ // production file, so path classification alone never sees them). The graph flags these per symbol.
42
+ // These files are NOT seeded as whole-file BFS sources: at file granularity we cannot tell an inline
43
+ // test's out-edge from a same-file production call, so seeding the file would propagate its PRODUCTION
44
+ // call chains as if a test reached them. They are self-covered at distance 0 instead (an honest,
45
+ // conservative signal: "this file has its own tests"), never over-claiming reach to other files.
46
+ const testSurfaceFiles = new Set();
40
47
  const isTest = (file) => hasPathClass(classify(file), "test", "e2e");
41
48
  const isProduct = (file) => {
42
49
  const info = classify(file);
@@ -50,6 +57,7 @@ export function computeStaticTestReachability(graph, {
50
57
  if (!file) continue;
51
58
  idToFile.set(String(node.id), file);
52
59
  files.add(file);
60
+ if (node?.test_surface === true) testSurfaceFiles.add(file);
53
61
  }
54
62
 
55
63
  // Collapse symbol/file edges to directed file dependencies. Multiple symbol edges retain the best
@@ -73,6 +81,13 @@ export function computeStaticTestReachability(graph, {
73
81
  const productFiles = [...files].filter(isProduct).filter((file) => pathInScope(file, scope)).sort((a, b) => a.localeCompare(b));
74
82
  const productSet = new Set(productFiles);
75
83
  const nearest = new Map();
84
+ // A production file that carries its own inline tests is self-covered at distance 0: its tests exercise
85
+ // its own production symbols. This is the honest floor that fixes a false "0 reachable" for repos whose
86
+ // tests are all inline (Rust); it never asserts reach to OTHER files, so it cannot over-claim.
87
+ const inlineCovered = productFiles.filter((file) => testSurfaceFiles.has(file));
88
+ for (const file of inlineCovered) {
89
+ nearest.set(file, [{ test: file, distance: 0, score: 3, confidence: confidenceLabel(3), path: [file] }]);
90
+ }
76
91
  const queue = tests.map((test) => ({ file: test, test, distance: 0, score: 3, path: [test] }));
77
92
  const seen = new Map(tests.map((test) => [`${test}\0${test}`, 0]));
78
93
  let cursor = 0;
@@ -115,8 +130,10 @@ export function computeStaticTestReachability(graph, {
115
130
  return {
116
131
  kind: "staticTestReachability",
117
132
  actualCoverage: "NOT_AVAILABLE",
118
- testFiles: tests.length,
119
- totalTestFiles: allTests.length,
133
+ // Inline-test files self-cover rather than seed the traversal, but they ARE test-bearing files, so
134
+ // the reported counts include them — otherwise a Rust repo would read "0 test files" beside real reach.
135
+ testFiles: tests.length + inlineCovered.length,
136
+ totalTestFiles: allTests.length + inlineCovered.length,
120
137
  productFiles: productFiles.length,
121
138
  reachableFiles: reachable.length,
122
139
  unreachableFiles: unreachable.length,
@@ -0,0 +1,17 @@
1
+ // Read-only analysis primitives the weavatrix-refactor package composes into refactoring
2
+ // plan producers. Everything exported here is PURE READ: graph identity helpers, cycle
3
+ // detection, architecture verification, the bundled tree-sitter grammars, the read-only
4
+ // TypeScript language-server client, and dead-code risk signals. None of it writes, applies,
5
+ // or references applying a change — the core catalog and the release gate keep the core
6
+ // provably free of any source-write or edit-plan path. The refactoring engines live in
7
+ // weavatrix-refactor and import this surface.
8
+
9
+ export {graphEndpointId, fileOfId} from './graph/node-id.js'
10
+ export {Parser, Query, EXT_LANG, ensureParser} from './graph/internal-builder.langs.js'
11
+ export {querySymbolPrecision} from './precision/symbol-query.js'
12
+ export {createTypeScriptLspClient} from './precision/typescript-provider/client.js'
13
+ export {verifyArchitecture} from './analysis/architecture/contract-verification.js'
14
+ export {normalizeArchitectureContract} from './analysis/architecture-contract.js'
15
+ export {buildFileImportGraph, findSccs} from './analysis/structure/dependency-graph.js'
16
+ export {isFrameworkEntryFile} from './analysis/dead-check.js'
17
+ export {hasDynamicCode, REFLECTION_CODE_RE} from './analysis/dead-code-review/policy.js'
@@ -0,0 +1,63 @@
1
+ // Rust scoped-call resolution (`Path::method()`), split from lang-rust.js. Resolved with the full
2
+ // qualifier in hand so an external associated function (`OpenOptions::new()`, `Vec::from()`) never binds
3
+ // its bare final name to an unrelated same-named function in the directory. Only heads with real evidence
4
+ // produce an edge: crate/self/super anchors, `use`-bound names (including sibling-crate modules),
5
+ // fully-qualified sibling crates, and local types whose exact impl method is indexed. A std/external
6
+ // type head is left unresolved on purpose.
7
+
8
+ // Symbol kinds that can own an associated function via `Type::method`.
9
+ const TYPE_KINDS = new Set(["struct", "enum", "trait", "type", "union"]);
10
+
11
+ // `pathParts` and `inlineAncestors` stay owned by lang-rust.js (pass 1 uses them too) and are passed in,
12
+ // so this module never imports back from lang-rust.js.
13
+ export function rustScopedCalls(ctx, { pathParts, inlineAncestors }) {
14
+ const { grammar, tree, fileRel, caps, enclosing, links, nodeById, importedLocals,
15
+ dirSymbols, resolveNamedSymbol, resolveRustMethod, resolveRustPath, resolveRustCratePath } = ctx;
16
+ const dir = fileRel.includes("/") ? fileRel.slice(0, fileRel.lastIndexOf("/")) : "";
17
+ const localSyms = dirSymbols?.get(dir);
18
+ const emitCall = (node, target) => {
19
+ const caller = enclosing(fileRel, node.startPosition.row + 1);
20
+ if (caller && target && target !== caller.id) {
21
+ links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED", line: node.startPosition.row + 1 });
22
+ }
23
+ };
24
+ for (const cap of caps(grammar, `(call_expression function: (scoped_identifier) @path)`, tree.rootNode)) {
25
+ const node = cap.node;
26
+ const segments = pathParts(node);
27
+ if (segments.length < 2) continue;
28
+ const head = segments[0];
29
+ const method = segments.at(-1);
30
+
31
+ if (["crate", "self", "super"].includes(head)) {
32
+ const resolved = resolveRustPath(fileRel, segments, { inlineModules: inlineAncestors(node), unqualified: false });
33
+ if (resolved) emitCall(node, resolveNamedSymbol(resolved.targetFile, method, "value"));
34
+ continue;
35
+ }
36
+
37
+ const bound = importedLocals?.get(fileRel)?.get(head);
38
+ if (bound?.targetFile) {
39
+ emitCall(node, resolveNamedSymbol(bound.targetFile, method, "value"));
40
+ continue;
41
+ }
42
+
43
+ const crateResolved = resolveRustCratePath ? resolveRustCratePath(head, segments.slice(1, -1)) : null;
44
+ if (crateResolved) {
45
+ emitCall(node, resolveNamedSymbol(crateResolved.targetFile, method, "value"));
46
+ continue;
47
+ }
48
+
49
+ // Local type's associated function: resolve the exact impl member. Nothing else (enum-variant
50
+ // construction, external types) has an indexed target, so it produces no edge.
51
+ const headId = localSyms?.get(head);
52
+ if (headId && TYPE_KINDS.has(nodeById.get(headId)?.symbol_kind)) {
53
+ emitCall(node, resolveRustMethod(dir, head, method));
54
+ continue;
55
+ }
56
+
57
+ // Head is a locally-declared outlined module used without `use` (`mod utils; utils::init()`), the
58
+ // dominant flat-file idiom. resolveRustPath matches only a REAL repo module file, so an external type
59
+ // head (OpenOptions, Vec) resolves to null and still produces no edge — no dir-scope mis-binding.
60
+ const modResolved = resolveRustPath?.(fileRel, segments.slice(0, -1), { inlineModules: inlineAncestors(node), unqualified: true });
61
+ if (modResolved) emitCall(node, resolveNamedSymbol(modResolved.targetFile, method, "value"));
62
+ }
63
+ }
@@ -3,6 +3,8 @@
3
3
  // File dependencies follow Rust's module tree: outlined `mod`, `use` trees, public re-exports, and anchored
4
4
  // `crate/self/super` paths resolve to repo-local .rs or */mod.rs files. External crates deliberately stay out
5
5
  // of this adapter; Cargo dependency analysis owns them.
6
+ import { rustScopedCalls } from "./lang-rust-calls.js";
7
+
6
8
  const SYMS_CORE = `
7
9
  (function_item name: (identifier) @function)
8
10
  (struct_item name: (type_identifier) @struct)
@@ -141,17 +143,19 @@ export default {
141
143
  grammars: ["rust"],
142
144
  exts: { ".rs": "rust" },
143
145
  isWeb: false,
146
+ // Bare `foo()` and method `x.foo()` calls resolve by name in the generic pass-2 loop. Scoped
147
+ // `Path::foo()` calls are handled in pass2() below with the full qualifier in hand, so an external
148
+ // `OpenOptions::new()` cannot bind its bare `new` to an unrelated same-named function in the folder.
144
149
  calls: `
145
150
  (call_expression function: (identifier) @callee)
146
- (call_expression function: (field_expression field: (field_identifier) @callee))
147
- (call_expression function: (scoped_identifier name: (identifier) @callee))`,
151
+ (call_expression function: (field_expression field: (field_identifier) @callee))`,
148
152
  heritage: [
149
153
  `(impl_item trait: (type_identifier) @super)`,
150
154
  `(impl_item trait: (generic_type type: (type_identifier) @super))`,
151
155
  ],
152
156
 
153
157
  pass1(ctx) {
154
- const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, addExternalImport, imports, resolveRustMod, resolveRustPath, links, nameToId } = ctx;
158
+ const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, addExternalImport, imports, resolveRustMod, resolveRustPath, resolveRustCratePath, links, nameToId } = ctx;
155
159
  const owned = [];
156
160
  for (const src of [SYMS_CORE, ...SYMS_OPTIONAL]) {
157
161
  for (const cap of caps(grammar, src, tree.rootNode)) {
@@ -224,9 +228,16 @@ export default {
224
228
  // A `use` alias or imported item name shadows any same-named crate for the rest of this file. The
225
229
  // crate's OWN root name (e.g. `use anyhow::{self}`) is excluded so it still counts as a dependency.
226
230
  if (leaf.local && leaf.local !== "_" && cleanSegment(leaf.local) !== cleanSegment(leaf.segments[0])) localBindings.add(cleanSegment(leaf.local));
227
- const resolved = resolveRustPath(fileRel, leaf.segments, { inlineModules: ancestors, unqualified: true });
231
+ const crate = cleanSegment(leaf.segments[0]);
232
+ let resolved = resolveRustPath(fileRel, leaf.segments, { inlineModules: ancestors, unqualified: true });
233
+ // A head that is not a local module may be a sibling workspace crate (`use radiochron::wlan`):
234
+ // resolve it INTO that crate so its items bind here and cross-crate calls resolve in pass 2. It is
235
+ // still recorded as a dependency import so the sibling crate is not flagged an unused Cargo dep.
236
+ if (!resolved && resolveRustCratePath && isExternalCrate(crate, localBindings)) {
237
+ resolved = resolveRustCratePath(crate, leaf.segments.slice(1));
238
+ if (resolved) addExternalImport({ spec: leaf.segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-use", line: use.startPosition.row + 1 });
239
+ }
228
240
  if (!resolved) {
229
- const crate = cleanSegment(leaf.segments[0]);
230
241
  if (isExternalCrate(crate, localBindings)) {
231
242
  addExternalImport({ spec: leaf.segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-use", line: use.startPosition.row + 1 });
232
243
  }
@@ -250,7 +261,16 @@ export default {
250
261
  const segments = pathParts(node);
251
262
  if (!["crate", "self", "super"].includes(segments[0])) {
252
263
  const crate = cleanSegment(segments[0]);
253
- if (isExternalCrate(crate, localBindings)) {
264
+ // A fully-qualified sibling-crate path (`radiochron::events::recent`) is a real cross-crate
265
+ // dependency: emit the file edge AND record the dependency import so the sibling crate still
266
+ // counts as used, rather than being logged as an external crates.io crate.
267
+ const crateResolved = resolveRustCratePath && isExternalCrate(crate, localBindings)
268
+ ? resolveRustCratePath(crate, segments.slice(1))
269
+ : null;
270
+ if (crateResolved) {
271
+ emit(crateResolved.targetFile, { line: node.startPosition.row + 1, specifier: node.text });
272
+ addExternalImport({ spec: segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-path", line: node.startPosition.row + 1 });
273
+ } else if (isExternalCrate(crate, localBindings)) {
254
274
  addExternalImport({ spec: segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-path", line: node.startPosition.row + 1 });
255
275
  }
256
276
  continue;
@@ -271,4 +291,9 @@ export default {
271
291
  }
272
292
  }
273
293
  },
294
+
295
+ // Scoped `Path::method()` calls are resolved in lang-rust-calls.js with the full qualifier in hand.
296
+ pass2(ctx) {
297
+ rustScopedCalls(ctx, { pathParts, inlineAncestors });
298
+ },
274
299
  };
@@ -1,5 +1,8 @@
1
1
  export function createPass2Resolution({symIdsByFileName, nodeById, importedLocals, symByFileName}) {
2
2
  const dirSymbols = new Map(), dirMethods = new Map(), dirMethodsByName = new Map(), dirTypes = new Map()
3
+ // Rust associated functions / methods indexed by their owning type, per directory: `Type::method`
4
+ // resolves to the exact impl member instead of any same-named function in the directory.
5
+ const rustMethods = new Map() // dir -> Map(typeName -> Map(methodName -> id))
3
6
  // .sol shares dir scope because Solidity's project namespace is flat: `import "./Base.sol"` names no
4
7
  // symbols yet pulls every declaration into scope, so same-dir name resolution is the honest static proxy.
5
8
  const sharesDirScope = (file) => file.endsWith('.go') || file.endsWith('.cs') || file.endsWith('.rs') || file.endsWith('.sol')
@@ -24,6 +27,13 @@ export function createPass2Resolution({symIdsByFileName, nodeById, importedLocal
24
27
  continue
25
28
  }
26
29
  if (!symbols.has(name)) symbols.set(name, id)
30
+ if (file.endsWith('.rs') && node?.member_of) {
31
+ let byType = rustMethods.get(dir)
32
+ if (!byType) rustMethods.set(dir, (byType = new Map()))
33
+ let methods = byType.get(node.member_of)
34
+ if (!methods) byType.set(node.member_of, (methods = new Map()))
35
+ if (!methods.has(name)) methods.set(name, id)
36
+ }
27
37
  if (file.endsWith('.go') && node?.symbol_space === 'type') {
28
38
  let types = dirTypes.get(dir)
29
39
  if (!types) dirTypes.set(dir, (types = new Map()))
@@ -55,6 +65,7 @@ export function createPass2Resolution({symIdsByFileName, nodeById, importedLocal
55
65
  if (!imported?.targetFile) return null
56
66
  return resolveNamedSymbol(imported.originFile || imported.targetFile, imported.originName || imported.imported, 'value')
57
67
  }
68
+ const resolveRustMethod = (dir, typeName, methodName) => rustMethods.get(dir)?.get(typeName)?.get(methodName) || null
58
69
  const javaTypeKinds = new Set(['class', 'interface', 'enum', 'record', 'annotation'])
59
70
  const resolveJavaType = (name, file) => {
60
71
  const imported = importedLocals.get(file)?.get(name)
@@ -66,5 +77,5 @@ export function createPass2Resolution({symIdsByFileName, nodeById, importedLocal
66
77
  const target = symByFileName.get(file)?.get(name)
67
78
  return target && javaTypeKinds.has(nodeById.get(target)?.symbol_kind) ? target : null
68
79
  }
69
- return {dirSymbols, dirMethods, dirMethodsByName, dirTypes, resolveNamedSymbol, resolveCall, resolveJavaType}
80
+ return {dirSymbols, dirMethods, dirMethodsByName, dirTypes, resolveNamedSymbol, resolveCall, resolveJavaType, resolveRustMethod}
70
81
  }
@@ -240,7 +240,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
240
240
  // not files, so the folder map is the only reliable cross-file resolver) ----
241
241
  const {reExportOccurrences} = runInternalGraphPass2({
242
242
  files, rel, langs, caps, field, links, nodeById, perFileSymbols, symByFileName,
243
- symIdsByFileName, importedLocals, jsExports,
243
+ symIdsByFileName, importedLocals, jsExports, resolvers,
244
244
  });
245
245
  // HTML class/id usage → the CSS file(s) defining that selector: file-level reference edges (deduped per pair).
246
246
  const htmlRefSeen = new Set();
@@ -8,10 +8,11 @@ import {createGoReceiverResolution} from './builder/go-receiver-resolution.js'
8
8
 
9
9
  export function runInternalGraphPass2({
10
10
  files, rel, langs, caps, field, links, nodeById, perFileSymbols, symByFileName,
11
- symIdsByFileName, importedLocals, jsExports,
11
+ symIdsByFileName, importedLocals, jsExports, resolvers,
12
12
  }) {
13
13
  const resolution = createPass2Resolution({symIdsByFileName, nodeById, importedLocals, symByFileName})
14
- const {dirSymbols, resolveNamedSymbol, resolveCall, resolveJavaType} = resolution
14
+ const {dirSymbols, resolveNamedSymbol, resolveCall, resolveJavaType, resolveRustMethod} = resolution
15
+ const {resolveRustPath, resolveRustCratePath} = resolvers || {}
15
16
  const {resolveNamespaceMember, reExportOccurrences} = resolveJsBarrels({
16
17
  jsExports, importedLocals, links,
17
18
  resolveSymbol: (file, name, typeOnly) => resolveNamedSymbol(file, name, typeOnly ? 'type' : 'value'),
@@ -46,6 +47,7 @@ export function runInternalGraphPass2({
46
47
  lang.pass2({
47
48
  grammar, tree, fileRel: file, code, caps, field, enclosing, links, nodeById,
48
49
  perFileSymbols, symByFileName, symIdsByFileName, importedLocals, resolveCall, resolveJavaType,
50
+ dirSymbols, resolveNamedSymbol, resolveRustMethod, resolveRustPath, resolveRustCratePath,
49
51
  })
50
52
  } catch { /* one language-specific resolver never sinks the graph */ }
51
53
  if (lang.selectorCall) for (const capture of caps(grammar, lang.selectorCall, tree.rootNode)) {
@@ -5,6 +5,7 @@
5
5
  import { readFileSync } from "node:fs";
6
6
  import { join, dirname } from "node:path";
7
7
  import { parseGoMod } from "../analysis/manifests.js";
8
+ import { parseCargoToml, cargoName } from "../analysis/cargo-manifests.js";
8
9
  import { createRepoBoundary } from "../repo-path.js";
9
10
  import { listRepoFiles } from "../analysis/internal-audit/repo-files.js";
10
11
  import { createRustResolvers } from "./resolvers/rust.js";
@@ -61,6 +62,40 @@ export function buildResolvers(repoDir, fileSet) {
61
62
 
62
63
  const { resolveRustMod, resolveRustPath } = createRustResolvers(fileSet);
63
64
 
65
+ // Workspace crate map: normalized package name → its crate source root file (lib.rs/main.rs). A path or
66
+ // `use` that starts with a sibling crate's name (`use radiochron::wlan`) then resolves INTO that crate's
67
+ // files instead of being logged as an external crates.io dependency — so cross-crate imports and calls
68
+ // become real graph edges rather than a false "unused dependency" plus missing coupling.
69
+ // Cargo.toml (like go.mod) is a manifest, not a parsed source file, so it is read via listRepoFiles
70
+ // rather than the source fileSet. The crate root .rs it points at IS in fileSet (it is parsed).
71
+ const rustCrates = new Map();
72
+ const ambiguousCrates = new Set();
73
+ for (const manifest of listRepoFiles(repoDir).filter((file) => /(^|\/)Cargo\.toml$/i.test(file))) {
74
+ let parsed;
75
+ try { parsed = parseCargoToml(readLocal(manifest)); } catch { continue; }
76
+ if (!parsed.packageName) continue;
77
+ const dir = manifest.includes("/") ? manifest.slice(0, manifest.lastIndexOf("/")) : "";
78
+ const rootFile = ["src/lib.rs", "src/main.rs", "lib.rs", "main.rs"]
79
+ .map((candidate) => [dir, candidate].filter(Boolean).join("/"))
80
+ .find((path) => fileSet.has(path));
81
+ if (!rootFile) continue;
82
+ const key = cargoName(parsed.packageName);
83
+ // A repo can hold two crates with the same package name (independent workspaces, vendored copies).
84
+ // Cross-crate resolution has no way to pick the right one from a bare `name::path`, so treat the name
85
+ // as ambiguous and resolve nothing for it — better a missing edge than a confident wrong one.
86
+ if (rustCrates.has(key) && rustCrates.get(key) !== rootFile) ambiguousCrates.add(key);
87
+ else rustCrates.set(key, rootFile);
88
+ }
89
+ // Resolve `<crate>::<segments...>` against the named sibling crate's root, reusing the crate-anchored
90
+ // module resolver. Returns {targetFile, remaining} or null when the head is not an unambiguous workspace crate.
91
+ const resolveRustCratePath = (crateName, segments = []) => {
92
+ const key = cargoName(crateName);
93
+ if (ambiguousCrates.has(key)) return null;
94
+ const rootFile = rustCrates.get(key);
95
+ if (!rootFile) return null;
96
+ return resolveRustPath(rootFile, ["crate", ...segments], { unqualified: false });
97
+ };
98
+
64
99
  // Path aliases (tsconfig compilerOptions.paths + vite/webpack alias) are scoped to their config folder.
65
100
  // Without nearest-config resolution, a monorepo's root `@/*` can hijack the same alias in web/.
66
101
  const aliasContexts = new Map();
@@ -247,5 +282,5 @@ export function buildResolvers(repoDir, fileSet) {
247
282
  return fileSet.has(cand) ? cand : null;
248
283
  };
249
284
 
250
- return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveSolidityImport, resolveHref, selectorIndex, htmlUsages, goModule, goModules, goRequires };
285
+ return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveRustCratePath, resolveJavaImport, resolveSolidityImport, resolveHref, selectorIndex, htmlUsages, goModule, goModules, goRequires };
251
286
  }
@@ -89,13 +89,16 @@ function toolExecutionSignal(node, source, words, stem) {
89
89
  return score
90
90
  }
91
91
 
92
- function queryConcepts(query) {
92
+ // relaxStop is the last-resort fallback: when EVERY token is a stop word (e.g. a bare
93
+ // "architecture" query), keep them as concepts so the query still seeds instead of
94
+ // misreporting "No nodes matched" for a concept the repository clearly contains.
95
+ function queryConcepts(query, {relaxStop = false} = {}) {
93
96
  const tokens = wordsOf(query)
94
97
  const toolExecution = tokens.some((token) => TOOL_EXECUTION_TRIGGERS.has(token))
95
98
  const explanatoryWork = tokens.some((token) => token === 'how' || token === 'explain')
96
99
  const seen = new Set(), concepts = []
97
100
  for (const raw of tokens) {
98
- if (raw.length < 2 || QUERY_STOP.has(raw)) continue
101
+ if (raw.length < 2 || (!relaxStop && QUERY_STOP.has(raw))) continue
99
102
  if (explanatoryWork && (raw === 'work' || raw === 'works' || raw === 'working')) continue
100
103
  if (toolExecution && TOOL_EXECUTION_TERMS.includes(raw)) {
101
104
  if (seen.has('tool-execution')) continue
@@ -116,7 +119,7 @@ function exactIdentifierSeeds(g, query, limit, {repoRoot = null} = {}) {
116
119
  const identifiers = [...new Set((String(query || '').match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
117
120
  .filter((item) => /(?:[a-z0-9][A-Z]|_)/.test(item)).map((item) => item.toLowerCase()))]
118
121
  if (!identifiers.length) return []
119
- const requestedClasses = requestedPathClasses(query), languageExtensions = requestedLanguages(query)
122
+ const requestedClasses = requestedPathClasses(query), languageExtensions = effectiveLanguages(g, query)
120
123
  const classifier = createPathClassifier(repoRoot), classificationCache = new Map(), matches = []
121
124
  for (const identifier of identifiers) {
122
125
  const candidates = g.nodes.filter((node) => String(node.label || '').replace(/\(\)$/, '').toLowerCase() === identifier
@@ -158,12 +161,22 @@ function conceptScore(g, node, concept, queryContext) {
158
161
  return Math.max(0, score)
159
162
  }
160
163
 
164
+ // A requested language that matches ZERO nodes in this graph is over-eager inference (e.g.
165
+ // "contract" inferring Solidity in a repo with no .sol files); drop the filter rather than
166
+ // eliminate every candidate and return nothing.
167
+ function effectiveLanguages(g, query) {
168
+ const languageExtensions = requestedLanguages(query)
169
+ if (languageExtensions.size && !g.nodes.some((node) => matchesLanguage(node, languageExtensions))) return new Set()
170
+ return languageExtensions
171
+ }
172
+
161
173
  export function findSeeds(g, query, limit = 8, {repoRoot = null} = {}) {
162
174
  const exact = exactIdentifierSeeds(g, query, limit, {repoRoot})
163
175
  if (exact.length) return exact
164
- const concepts = queryConcepts(query)
176
+ let concepts = queryConcepts(query)
177
+ if (!concepts.length) concepts = queryConcepts(query, {relaxStop: true})
165
178
  if (!concepts.length || limit <= 0) return []
166
- const requestedClasses = requestedPathClasses(query), languageExtensions = requestedLanguages(query)
179
+ const requestedClasses = requestedPathClasses(query), languageExtensions = effectiveLanguages(g, query)
167
180
  const queryContext = {runtimeIntent: concepts.some((concept) => concept.id === 'bootstrap' || concept.id === 'tool-execution'), maintenanceIntent: wordsOf(query).some((word) => ['script', 'scripts', 'build', 'release', 'publish', 'packaging'].includes(word)), languageExtensions}
168
181
  const classifier = createPathClassifier(repoRoot), classificationCache = new Map()
169
182
  const rows = g.nodes.filter((node) => matchesLanguage(node, languageExtensions) && isQueryEligible(node, requestedClasses, classificationCache, classifier)).map((node) => {
@@ -37,6 +37,7 @@ export async function createTypeScriptLspClient({repoRoot, timeoutMs = 10_000} =
37
37
  textDocument: {
38
38
  definition: {linkSupport: true},
39
39
  references: {},
40
+ rename: {},
40
41
  publishDiagnostics: {relatedInformation: false},
41
42
  },
42
43
  },
@@ -68,6 +69,12 @@ export async function createTypeScriptLspClient({repoRoot, timeoutMs = 10_000} =
68
69
  references(relPath, position, includeDeclaration = true, referenceTimeoutMs = timeoutMs) {
69
70
  return client.references({filePath: relPath, position, includeDeclaration, timeoutMs: referenceTimeoutMs})
70
71
  },
72
+ // Generic read-only JSON-RPC passthrough plus URI normalization, so consumers outside
73
+ // the core (weavatrix-refactor) can issue their own read-only LSP requests. The client
74
+ // still refuses workspace/applyEdit, so nothing here can apply an edit.
75
+ request(method, params, options) { return client.request(method, params, options) },
76
+ toUri(relPath) { return client.normalizer.toUri(relPath) },
77
+ fromUri(uri) { return client.normalizer.fromUri(uri) },
71
78
  definition(relPath, position) { return client.definition({filePath: relPath, position}) },
72
79
  closeDocument(relPath) { return client.closeDocument(relPath) },
73
80
  async close(shutdownTimeoutMs = timeoutMs) {
@@ -76,7 +76,6 @@ export function normalizeOsvAdvisory(record, ecosystem, name) {
76
76
  kind: String(record.id || '').startsWith('MAL-') ? 'malicious' : 'vuln',
77
77
  severity: severityOf(record),
78
78
  summary: String(record.summary || record.details || '').slice(0, 300),
79
- url: `https://osv.dev/vulnerability/${record.id}`,
80
79
  modified: record.modified || '',
81
80
  aliases: (record.aliases || []).slice(0, 6),
82
81
  fixedIn: [...new Set(fixed)].slice(0, 4),
@@ -111,7 +111,7 @@ export function installScriptSnippet(scripts = {}) {
111
111
  }
112
112
 
113
113
  // weak URL rules only: URL evidence in a license/prose file or in comment/doc-link context is
114
- // documentation, not behavior (LICENSE:5 "http://www.apache.org/licenses/" was the hot FP).
114
+ // documentation, not behavior (a license-header scheme reference on LICENSE line 5 was the hot FP).
115
115
  // Strong rules (miner/shell/exfil-url) are untouched and still scan those same files.
116
116
  export function isDocUrlNoise(file, signal) {
117
117
  if (!NOISY_URL_KEYS.has(signal?.key)) return false;
@@ -77,7 +77,7 @@ export const CONTENT_RULES = [
77
77
  severity: "medium", // raw-IP URLs have some legit uses (local tooling) — escalates via co-occurrence
78
78
  nearZeroFp: false,
79
79
  pattern: "https?://[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
80
- re: /https?:\/\/(?!127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/i, // /i: rg sweeps -i; URL schemes are case-insensitive (HTTP:// is a known evasion)
80
+ re: /https?:\/\/(?!127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/i, // /i: rg sweeps -i; schemes are case-insensitive (an uppercase scheme prefix is a known evasion)
81
81
  what: "hardcoded raw-IP URL (loopback/private/link-local ranges excluded)", // 169.254.169.254 = cloud IMDS (AWS/GCP/Azure SDK metadata) — benign, was a hot FP
82
82
  },
83
83
  {