weavatrix 0.3.10 → 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.
package/README.md CHANGED
@@ -186,10 +186,18 @@ realpath escapes. Report suspected vulnerabilities privately as described in [SE
186
186
 
187
187
  ## Languages
188
188
 
189
- JavaScript · TypeScript · TSX · Python · Go · Java · C# · Rust · HTML · CSS — parsed with
189
+ JavaScript · TypeScript · TSX · Python · Go · Java · C# · Rust · Solidity · HTML · CSS — parsed with
190
190
  [web-tree-sitter](https://github.com/tree-sitter/tree-sitter) WASM grammars; no Python install and no
191
191
  native compilation.
192
192
 
193
+ SQL is indexed without a grammar: `.sql` files contribute tables, views, columns, functions, indexes
194
+ and triggers as first-class graph symbols, and SQL found in string literals of any other language
195
+ links the enclosing function to the table it queries. That makes schema objects visible to
196
+ `change_impact`/`get_dependents` (who touches this table?) and lets the dead-code check flag columns
197
+ no statement references — conservatively: verdicts require literal-SQL evidence in the repo, and
198
+ `SELECT *`-consumed tables never have their columns judged by name (ORM-generated SQL stays invisible
199
+ and is therefore never judged either).
200
+
193
201
  Test surfaces are classified per file (path conventions plus `.weavatrix.json` overrides) and, for
194
202
  Rust, per symbol: `#[cfg(test)]` modules and `#[test]`/`#[bench]` items inside production `.rs` files
195
203
  carry a node-level `test_surface` flag, so dead-code, query, hot-path and hub tools treat them as
@@ -204,9 +212,9 @@ npm run benchmark # full TS/JS/Python/Go/Java/Rust + MCP lifecycle gate
204
212
  npm run benchmark:real # locally available real repos vs source-free 0.2.1 baselines
205
213
  ```
206
214
 
207
- Maintained JavaScript/TypeScript under `src`, `bin`, `scripts`, `test` (and HTML/CSS/JS under `site`)
208
- has a hard 300-line physical ceiling enforced by the release suite; larger concerns split into
209
- owner-focused modules behind slim facades.
215
+ Maintained JavaScript/TypeScript under `src`, `bin`, `scripts`, `test` has a hard 300-line physical
216
+ ceiling enforced by the release suite; larger concerns split into owner-focused modules behind slim
217
+ facades. The weavatrix.com landing site lives in its own repository (`weavatrix-site`).
210
218
 
211
219
  ## Release history
212
220
 
@@ -0,0 +1,55 @@
1
+ # Weavatrix 0.3.11
2
+
3
+ 0.3.11 teaches the graph two new surfaces. SQL schemas become first-class graph
4
+ citizens with honest, evidence-gated dead-code verdicts, and Solidity joins the
5
+ parsed languages at zero dependency cost. The weavatrix.com site moved to its
6
+ own repository, so the engine repo now ships engine only.
7
+
8
+ ## SQL: the graph reaches the database schema
9
+
10
+ No SQL grammar ships in the pinned tree-sitter-wasms, so `.sql` indexing runs
11
+ on a dependency-free statement scanner (a new `textOnly` language-module class)
12
+ built for the graph's needs — symbols and references, not SQL semantics.
13
+ String literals and comments are blanked before structural scanning, so quoted
14
+ text can never fabricate a reference.
15
+
16
+ - `CREATE TABLE`/`VIEW`/`FUNCTION`/`PROCEDURE`/`INDEX`/`TRIGGER` become graph
17
+ symbols; table columns (including `ALTER TABLE ADD COLUMN`) are member
18
+ symbols with `member_of`, and tables carry their columns as `field_types`.
19
+ - References resolve inside `.sql` files (a view's `SELECT`, an index's `ON`
20
+ table, a trigger's `EXECUTE FUNCTION`, foreign-key `REFERENCES`) — and, the
21
+ real point, from application code: literal SQL found in string literals of
22
+ any indexed language links the enclosing function to the table it queries.
23
+ `change_impact` and `get_dependents` on a table now answer "which code
24
+ touches this?"
25
+ - Dead-code verdicts stay honest. Schema objects are judged only when the
26
+ repository demonstrably uses literal SQL the scanner can read — an
27
+ ORM-driven repo produces silence, not guesses. Columns of a
28
+ `SELECT *`-consumed table are never judged by name, and indexes/triggers
29
+ (DB-engine surface) are never judged at all. A flagged column reports
30
+ "no SQL statement in the indexed sources references it".
31
+
32
+ ## Solidity
33
+
34
+ The tree-sitter-solidity grammar already shipped inside the pinned
35
+ tree-sitter-wasms package — 0.3.11 registers it (and pins its SHA-256 in the
36
+ parser-artifact allowlist), so Solidity support adds zero new dependencies.
37
+ Contracts, interfaces, libraries, functions, constructors, modifiers, events,
38
+ errors, structs, enums and state variables are extracted with visibility and
39
+ membership; `is` inheritance, modifier invocations, `emit`, `new Contract()`
40
+ and `using X for Y` all feed the call/heritage resolvers. Imports resolve
41
+ through relative paths, Foundry remappings (`remappings.txt` / `foundry.toml`)
42
+ and root-anchored specs; npm-style specs (`@openzeppelin/...`) are recorded as
43
+ external dependencies. `.sol` files share directory scope like Go/C#/Rust,
44
+ which makes Solidity's plain `import "./Base.sol"` — a statement that names no
45
+ symbols — resolvable for siblings without guessing.
46
+
47
+ `extractorSchemaV` 6 → 7; cached graphs rebuild once, automatically.
48
+
49
+ ## Repository
50
+
51
+ The weavatrix.com landing site (pages, Cloudflare Worker config, deploy
52
+ workflow and its asset checks) moved byte-identically to the separate
53
+ `weavatrix-site` repository, removing ~1.2 MB of page assets from the engine
54
+ repo. The MCPB icon the site used to own is vendored at `mcpb/icon.png`; the
55
+ published npm package contents are unchanged.
@@ -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.10",
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": [
@@ -44,6 +45,8 @@
44
45
  "docs/releases/v0.3.8.md",
45
46
  "docs/releases/v0.3.9.md",
46
47
  "docs/releases/v0.3.10.md",
48
+ "docs/releases/v0.3.11.md",
49
+ "docs/releases/v0.3.12.md",
47
50
  "docs/releases/v0.2.19.md",
48
51
  "README.md",
49
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);
@@ -6,6 +6,7 @@
6
6
  // and is fully testable with no filesystem. It only needs {nodes, links} + source text. See [[graph-builder-internalization]].
7
7
  import { posix } from "node:path";
8
8
  import { isStructuralRelation } from "../graph/relations.js";
9
+ import { createSqlDeadVerdict } from "../graph/builder/lang-sql.js";
9
10
  import { createPathClassifier, hasPathClass } from "../path-classification.js";
10
11
 
11
12
  const IDENT_RE = /[A-Za-z_$][\w$]*/g;
@@ -198,12 +199,11 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
198
199
  return isDecorated(n); // framework-registered via decorator
199
200
  };
200
201
 
202
+ const sqlVerdict = createSqlDeadVerdict({ nodes, links, ep, bareName }); // gated SQL verdicts — ORM blindness stays silent
201
203
  const deadSymbols = [];
202
204
  for (const n of symById.values()) {
203
- if (isReferenced(n)) continue;
204
- // test_surface: extractor-proven test-only symbols (Rust #[cfg(test)]) live in production paths.
205
- const test = isTestFile(n.source_file) || n.test_surface === true;
206
- deadSymbols.push({ id: n.id, file: n.source_file, label: n.label, test, reason: "no inbound edge and name unreferenced outside its file" });
205
+ if (isReferenced(n) || sqlVerdict.veto(n)) continue;
206
+ deadSymbols.push({ id: n.id, file: n.source_file, label: n.label, test: isTestFile(n.source_file) || n.test_surface === true, reason: sqlVerdict.reason(n) || "no inbound edge and name unreferenced outside its file" });
207
207
  }
208
208
  const deadSet = new Set(deadSymbols.map((s) => s.id));
209
209
 
@@ -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
  };
@@ -0,0 +1,126 @@
1
+ // Solidity extractor. Symbols: contract/interface/library (space "both" so `is Base` heritage and
2
+ // `new Vault()` both resolve through the value-space resolver), functions/constructors/modifiers/
3
+ // events/errors/structs/enums/state vars (members carry memberOf + visibility). Imports: relative
4
+ // paths, Foundry remappings (remappings.txt / foundry.toml), and root-anchored specs resolve to repo
5
+ // files; everything else (npm-style @openzeppelin/…, absent lib/ submodules) is RECORDED as an
6
+ // external import for dependency analysis. Calls: bare `f()`, modifier invocations, `emit Event(…)`,
7
+ // `new Contract(…)`, and `using Lib for T` all feed the calls resolver. Same-dir symbols share scope
8
+ // (see sharesDirScope): Solidity's flat project namespace makes plain `import "./Base.sol"` — which
9
+ // names no symbols — resolvable for siblings without guessing; cross-dir plain imports still produce
10
+ // the file-level import edge, so blast radius stays correct even where symbol edges are unknowable.
11
+ import { specToPkg } from "./spec-pkg.js";
12
+
13
+ const CONTAINERS = new Set(["contract_declaration", "interface_declaration", "library_declaration"]);
14
+ const KIND_BY_CONTAINER = {
15
+ contract_declaration: "contract",
16
+ interface_declaration: "interface",
17
+ library_declaration: "library",
18
+ };
19
+ // external/public functions are the deployed ABI — callable by any transaction, not just repo code.
20
+ const VISIBILITY = { external: "public", public: "public", internal: "protected", private: "private" };
21
+
22
+ function enclosingContainer(node) {
23
+ for (let parent = node?.parent; parent; parent = parent.parent) {
24
+ if (CONTAINERS.has(parent.type)) return parent;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ const identifierOf = (node) => node?.namedChildren?.find((child) => child.type === "identifier") || null;
30
+ const childOfType = (node, type) => node?.namedChildren?.find((child) => child.type === type) || null;
31
+ const parameterCount = (node) => (node?.namedChildren || []).filter((child) => child.type === "parameter").length;
32
+
33
+ export default {
34
+ family: "solidity",
35
+ grammars: ["solidity"],
36
+ exts: { ".sol": "solidity" },
37
+ isWeb: false,
38
+ calls: `[(call_expression (identifier) @callee) (modifier_invocation (identifier) @callee) (emit_statement (identifier) @callee) (new_expression (type_name (user_defined_type (identifier) @callee))) (using_directive (type_alias (identifier) @callee))]`,
39
+ heritage: [`(inheritance_specifier (user_defined_type (identifier) @base))`],
40
+
41
+ pass1(ctx) {
42
+ const { grammar, tree, fileRel, caps, addSym, addImportEdge, addExternalImport, imports, links, nameToId, resolveSolidityImport } = ctx;
43
+
44
+ // ---- containers (contract/interface/library) ----
45
+ for (const cap of caps(grammar, `[(contract_declaration (identifier) @c) (interface_declaration (identifier) @c) (library_declaration (identifier) @c)]`, tree.rootNode)) {
46
+ addSym(cap.node.text, cap.node.startPosition.row + 1, false, {
47
+ sourceNode: cap.node.parent,
48
+ selectionNode: cap.node,
49
+ symbolKind: KIND_BY_CONTAINER[cap.node.parent.type],
50
+ symbolSpace: "both",
51
+ exported: true,
52
+ moduleDeclaration: true,
53
+ });
54
+ }
55
+
56
+ // ---- functions (free + members), constructors, modifiers ----
57
+ const ownedMembers = [];
58
+ const addMember = (name, node, selection, extra) => {
59
+ const container = enclosingContainer(node);
60
+ const ownerName = container ? identifierOf(container)?.text || null : null;
61
+ const id = addSym(name, (selection || node).startPosition.row + 1, extra.callable !== false, {
62
+ sourceNode: node,
63
+ selectionNode: selection || undefined,
64
+ ...(ownerName ? { memberOf: ownerName } : { exported: true, moduleDeclaration: true }),
65
+ ...extra,
66
+ });
67
+ if (ownerName && id) ownedMembers.push({ ownerName, id });
68
+ return id;
69
+ };
70
+ for (const cap of caps(grammar, `(function_definition (identifier) @fn)`, tree.rootNode)) {
71
+ const definition = cap.node.parent;
72
+ const visibility = VISIBILITY[childOfType(definition, "visibility")?.text || ""];
73
+ addMember(cap.node.text, definition, cap.node, {
74
+ symbolKind: enclosingContainer(definition) ? "method" : "function",
75
+ ...(visibility ? { visibility } : {}),
76
+ parameterCount: parameterCount(definition),
77
+ });
78
+ }
79
+ for (const cap of caps(grammar, `(constructor_definition) @ctor`, tree.rootNode))
80
+ addMember("constructor", cap.node, null, { symbolKind: "constructor", parameterCount: parameterCount(cap.node) });
81
+ for (const cap of caps(grammar, `(modifier_definition (identifier) @m)`, tree.rootNode))
82
+ addMember(cap.node.text, cap.node.parent, cap.node, { symbolKind: "modifier", parameterCount: parameterCount(cap.node.parent) });
83
+
84
+ // ---- data/type declarations ----
85
+ for (const cap of caps(grammar, `[(event_definition (identifier) @n) (error_declaration (identifier) @n)]`, tree.rootNode))
86
+ addMember(cap.node.text, cap.node.parent, cap.node, { callable: false, symbolKind: cap.node.parent.type === "event_definition" ? "event" : "error" });
87
+ for (const cap of caps(grammar, `[(struct_declaration (identifier) @n) (enum_declaration (identifier) @n)]`, tree.rootNode))
88
+ addMember(cap.node.text, cap.node.parent, cap.node, { callable: false, symbolKind: cap.node.parent.type === "struct_declaration" ? "struct" : "enum", symbolSpace: "both" });
89
+ // capture the DECLARATION and take its first identifier child (the name): an initializer such as
90
+ // `uint x = OTHER_CONST;` can place a second bare identifier under the same declaration node.
91
+ for (const cap of caps(grammar, `(constant_variable_declaration) @d`, tree.rootNode)) {
92
+ const name = identifierOf(cap.node);
93
+ if (name) addMember(name.text, cap.node, name, { callable: false, symbolKind: "constant" });
94
+ }
95
+ for (const cap of caps(grammar, `(state_variable_declaration) @d`, tree.rootNode)) {
96
+ const name = identifierOf(cap.node);
97
+ const visibility = VISIBILITY[childOfType(cap.node, "visibility")?.text || ""] || "protected"; // Solidity default: internal
98
+ if (name) addMember(name.text, cap.node, name, { callable: false, symbolKind: "variable", visibility });
99
+ }
100
+ for (const member of ownedMembers) {
101
+ const ownerId = nameToId.get(member.ownerName);
102
+ if (ownerId) links.push({ source: ownerId, target: member.id, relation: "contains", confidence: "EXTRACTED" });
103
+ }
104
+
105
+ // ---- imports ----
106
+ for (const cap of caps(grammar, `(import_directive) @imp`, tree.rootNode)) {
107
+ const specNode = childOfType(cap.node, "string");
108
+ if (!specNode) continue;
109
+ const spec = specNode.text.replace(/^["']|["']$/g, "");
110
+ const line = specNode.startPosition.row + 1;
111
+ const target = resolveSolidityImport(fileRel, spec);
112
+ if (target) {
113
+ addImportEdge(target, { specifier: spec, line });
114
+ // `import {A, B} from "./x.sol"` — bind each named symbol. A lone identifier may instead be a
115
+ // `* as Alias` namespace name; binding it the same way is harmless (no same-named symbol → no edge).
116
+ for (const named of cap.node.namedChildren.filter((child) => child.type === "identifier"))
117
+ imports.set(named.text, { imported: named.text, targetFile: target });
118
+ continue;
119
+ }
120
+ if (spec.startsWith(".")) { addExternalImport({ spec, kind: "sol-import", line, unresolved: true }); continue; }
121
+ const r = specToPkg(spec);
122
+ if (r) addExternalImport({ spec, pkg: r.pkg, builtin: false, kind: "sol-import", line });
123
+ else addExternalImport({ spec, kind: "sol-import", line, unresolved: true });
124
+ }
125
+ },
126
+ };