weavatrix 0.3.11 → 0.3.13

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
@@ -12,9 +12,12 @@ questions. **34 network-free tools. No repository data leaves your machine.**
12
12
  - Source: [github.com/sergii-ziborov/weavatrix](https://github.com/sergii-ziborov/weavatrix)
13
13
  - npm: [`weavatrix`](https://www.npmjs.com/package/weavatrix) — `npx -y weavatrix <repoRoot>`
14
14
 
15
- This package is the complete offline engine under the MIT license and initiates no outbound HTTP.
16
- Every network capability lives in the separately versioned `weavatrix-online` superset, which depends
17
- on this core through a supported extension API. The split is documented in
15
+ This package is the complete offline engine under the MIT license: it reads and analyzes your code,
16
+ initiates no outbound HTTP, and never edits your source. It is the read-only base of a layered stack —
17
+ add [`weavatrix-refactor`](https://github.com/sergii-ziborov/weavatrix-refactor) (Apache-2.0) to apply
18
+ hash-verified, reversible edits, or [`weavatrix-online`](https://github.com/sergii-ziborov/weavatrix-online)
19
+ for authorized Cloud or self-hosted sync. Each is an optional superset that depends on this core through
20
+ a supported extension API; the offline/online split is documented in
18
21
  [docs/adr/0001-v0.3-offline-online-split.md](docs/adr/0001-v0.3-offline-online-split.md).
19
22
 
20
23
  ## Install
@@ -125,7 +128,9 @@ The 34 methods project the same reusable graph into the smallest view a task nee
125
128
  - **build** — `rebuild_graph` (reports the structural delta, keeps the prior state as `graph.prev.json`).
126
129
  - **retarget** *(in `offline`, absent from `pinned`)* — `open_repo`, `list_known_repos`.
127
130
  - **crossrepo** *(in `offline`, absent from `pinned`)* — `trace_api_contract` (joins routes to client
128
- call-sites across registered local graphs; reads no source).
131
+ call-sites across registered local graphs; reads no source). Results are compact and paginated by
132
+ default (`page_size`, opaque `cursor`, and bounded `per_item_limit` samples); `response_detail="full"`
133
+ is an explicit opt-in and remains paginated.
129
134
 
130
135
  Every finding is review evidence, never an auto-delete verdict: `find_dead_code` /
131
136
  `run_audit category=unused` always return `REVIEW_REQUIRED` with `autoDelete:false`. Typecheck, tests
@@ -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.
@@ -0,0 +1,34 @@
1
+ # Weavatrix 0.3.13
2
+
3
+ 0.3.13 fixes two misleading verification and graph-path signals found while
4
+ dogfooding a real duplicate-removal refactor. It also bounds cross-repository
5
+ contract responses with revision-bound pagination.
6
+
7
+ ## Honest refactor verification
8
+
9
+ - `verified_change` no longer reports an enabled duplicate ratchet as
10
+ `SKIPPED: duplicate ratchet disabled` during the planning phase. An enabled
11
+ plan now reports `PLANNED`, `enabled: true`, and states that the ratchet runs
12
+ during `phase="verify"`. An explicit `duplicate_ratchet: false` remains the
13
+ only disabled state.
14
+ - `shortest_path` remains an undirected connectivity query, but every rendered
15
+ hop now preserves the stored graph-edge direction. Reverse traversal renders
16
+ `<--imports--` or `<--calls--` instead of inventing a forward dependency.
17
+ Repeated and generic file names are widened in both the header and hop lines.
18
+
19
+ ## Bounded cross-repository contract evidence
20
+
21
+ - `trace_api_contract` response schema v4 returns compact, paginated evidence
22
+ by default. `page_size`, `per_item_limit`, and a revision/filter-bound opaque
23
+ `cursor` keep responses bounded; `response_detail="full"` remains an explicit
24
+ opt-in and is still paginated.
25
+ - Invalid, stale, or cross-query cursors fail closed with `INVALID_CURSOR`.
26
+ - Repository graph reconciliation now reuses a persisted fresh graph before
27
+ invoking a rebuild, while stale or failed refreshes retain their explicit
28
+ completeness status.
29
+
30
+ ## Layer compatibility
31
+
32
+ The read-only core keeps its 34-tool surface and `weavatrix.edit-plan.v1`
33
+ contract unchanged. `weavatrix-refactor` 0.1.1 and `weavatrix-online` 0.2.0
34
+ are the paired upper-layer releases for this core version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
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,8 @@
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",
50
+ "docs/releases/v0.3.13.md",
48
51
  "docs/releases/v0.2.19.md",
49
52
  "README.md",
50
53
  "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
  }
@@ -111,6 +111,10 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
111
111
  max_matches: {type: 'integer', minimum: 1, maximum: 5000, default: 1000},
112
112
  max_affected_files: {type: 'integer', minimum: 1, maximum: 500, default: 100},
113
113
  top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10},
114
+ response_detail: {type: 'string', enum: ['compact', 'full'], default: 'compact', description: 'Compact returns bounded samples and counts. Full is explicit opt-in and still paginated.'},
115
+ page_size: {type: 'integer', minimum: 1, maximum: 50, default: 10, description: 'Maximum HTTP/transport/uncertain evidence items returned on this page.'},
116
+ per_item_limit: {type: 'integer', minimum: 1, maximum: 25, default: 5, description: 'Compact-mode sample limit for callsites, affected files, screens and modules per item.'},
117
+ cursor: {type: 'string', maxLength: 256, description: 'Opaque nextCursor from the previous page; bound to repository revisions and filters.'},
114
118
  },
115
119
  required: ['backend', 'clients'],
116
120
  },
@@ -0,0 +1,142 @@
1
+ import {createHash} from 'node:crypto'
2
+
3
+ const DEFAULT_PAGE_SIZE = 10
4
+ const MAX_PAGE_SIZE = 50
5
+ const DEFAULT_RELATED_LIMIT = 5
6
+ const MAX_RELATED_LIMIT = 25
7
+
8
+ export class ContractCursorError extends Error {
9
+ constructor(message) {
10
+ super(message)
11
+ this.name = 'ContractCursorError'
12
+ this.code = 'INVALID_CURSOR'
13
+ }
14
+ }
15
+
16
+ const boundedInteger = (value, fallback, minimum, maximum) => {
17
+ const parsed = Number(value)
18
+ return Number.isInteger(parsed) ? Math.max(minimum, Math.min(maximum, parsed)) : fallback
19
+ }
20
+
21
+ const pageFingerprint = (parts) => createHash('sha256')
22
+ .update(JSON.stringify(parts))
23
+ .digest('hex')
24
+ .slice(0, 24)
25
+
26
+ const encodeCursor = (fingerprint, offset) => Buffer
27
+ .from(JSON.stringify({v: 1, fingerprint, offset}), 'utf8')
28
+ .toString('base64url')
29
+
30
+ function decodeCursor(raw, fingerprint, total) {
31
+ if (!raw) return 0
32
+ try {
33
+ const parsed = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
34
+ if (parsed?.v !== 1 || parsed.fingerprint !== fingerprint) {
35
+ throw new ContractCursorError('cursor does not belong to this repository revision and filter set')
36
+ }
37
+ if (!Number.isInteger(parsed.offset) || parsed.offset < 0 || parsed.offset > total) {
38
+ throw new ContractCursorError('cursor offset is outside the available evidence')
39
+ }
40
+ return parsed.offset
41
+ } catch (error) {
42
+ if (error instanceof ContractCursorError) throw error
43
+ throw new ContractCursorError('cursor is malformed')
44
+ }
45
+ }
46
+
47
+ const sample = (items, limit) => ({
48
+ total: Array.isArray(items) ? items.length : 0,
49
+ items: Array.isArray(items) ? items.slice(0, limit) : [],
50
+ truncated: Array.isArray(items) && items.length > limit,
51
+ })
52
+
53
+ function compactEndpoint(endpoint, relatedLimit) {
54
+ const affected = endpoint?.affected || {}
55
+ return {
56
+ kind: 'http-endpoint',
57
+ backend: endpoint.backend,
58
+ method: endpoint.method,
59
+ path: endpoint.path,
60
+ normalizedPath: endpoint.normalizedPath,
61
+ handler: endpoint.handler,
62
+ handlerNodeId: endpoint.handlerNodeId,
63
+ handlerResolution: endpoint.handlerResolution,
64
+ file: endpoint.file,
65
+ line: endpoint.line,
66
+ callsites: sample(endpoint.callsites, relatedLimit),
67
+ methodMismatches: endpoint.methodMismatches,
68
+ methodMismatchSites: sample(endpoint.methodMismatchSites, relatedLimit),
69
+ liveness: endpoint.liveness,
70
+ affected: {
71
+ complete: affected.complete === true,
72
+ files: sample(affected.files, relatedLimit),
73
+ screens: sample(affected.screens, relatedLimit),
74
+ modules: sample(affected.modules, relatedLimit),
75
+ traversalTruncated: affected.truncated || {},
76
+ },
77
+ }
78
+ }
79
+
80
+ function compactTransport(contract, relatedLimit) {
81
+ const affected = contract?.affected || {}
82
+ return {
83
+ kind: 'transport-contract',
84
+ transport: contract.transport,
85
+ side: contract.side,
86
+ service: contract.service,
87
+ operation: contract.operation,
88
+ name: contract.name,
89
+ file: contract.file,
90
+ line: contract.line,
91
+ detector: contract.detector,
92
+ liveness: contract.liveness,
93
+ runtimeObserved: contract.runtimeObserved === true,
94
+ callsites: sample(contract.callsites, relatedLimit),
95
+ affected: {
96
+ complete: affected.complete === true,
97
+ files: sample(affected.files, relatedLimit),
98
+ screens: sample(affected.screens, relatedLimit),
99
+ modules: sample(affected.modules, relatedLimit),
100
+ traversalTruncated: affected.truncated || {},
101
+ },
102
+ }
103
+ }
104
+
105
+ function evidenceItems(analysis, transportAnalysis) {
106
+ return [
107
+ ...(analysis.endpoints || []).map((value) => ({kind: 'http-endpoint', value})),
108
+ ...(transportAnalysis.contracts || []).map((value) => ({kind: 'transport-contract', value})),
109
+ ...(analysis.uncertain || []).map((value) => ({kind: 'http-uncertain', value})),
110
+ ...(transportAnalysis.uncertain || []).map((value) => ({kind: 'transport-uncertain', value})),
111
+ ]
112
+ }
113
+
114
+ function projectItem(item, detail, relatedLimit) {
115
+ if (detail === 'full') return {kind: item.kind, ...item.value}
116
+ if (item.kind === 'http-endpoint') return compactEndpoint(item.value, relatedLimit)
117
+ if (item.kind === 'transport-contract') return compactTransport(item.value, relatedLimit)
118
+ return {kind: item.kind, ...item.value}
119
+ }
120
+
121
+ export function paginateContractEvidence({analysis, transportAnalysis, args = {}, fingerprintParts = []}) {
122
+ const detail = args.response_detail === 'full' ? 'full' : 'compact'
123
+ const pageSize = boundedInteger(args.page_size, DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE)
124
+ const relatedLimit = boundedInteger(args.per_item_limit, DEFAULT_RELATED_LIMIT, 1, MAX_RELATED_LIMIT)
125
+ const all = evidenceItems(analysis, transportAnalysis)
126
+ const fingerprint = pageFingerprint(fingerprintParts)
127
+ const offset = decodeCursor(args.cursor, fingerprint, all.length)
128
+ const selected = all.slice(offset, offset + pageSize)
129
+ const nextOffset = offset + selected.length
130
+ const nextCursor = nextOffset < all.length ? encodeCursor(fingerprint, nextOffset) : null
131
+ return {
132
+ detail,
133
+ offset,
134
+ pageSize,
135
+ perItemLimit: detail === 'compact' ? relatedLimit : null,
136
+ totalItems: all.length,
137
+ returnedItems: selected.length,
138
+ hasMore: nextCursor !== null,
139
+ nextCursor,
140
+ items: selected.map((item) => projectItem(item, detail, relatedLimit)),
141
+ }
142
+ }
@@ -0,0 +1,87 @@
1
+ import {join} from 'node:path'
2
+ import {buildGraphForRepo} from '../build-graph.js'
3
+ import {persistedFreshnessMatches, repositoryFreshnessProbe} from '../graph/freshness-probe.js'
4
+ import {loadGraph} from './graph-context.mjs'
5
+
6
+ export const publicRecord = (record, alias) => ({repositoryId: record.repositoryId, label: record.label, alias})
7
+
8
+ function safeRefreshReason(record, error) {
9
+ let message = String(error instanceof Error ? error.message : error || 'graph refresh failed')
10
+ for (const path of [record.repoPath, record.graphDir]) {
11
+ if (!path) continue
12
+ message = message.split(String(path)).join(record.label || 'repository')
13
+ message = message.split(String(path).replace(/\\/g, '/')).join(record.label || 'repository')
14
+ }
15
+ return message.replace(/[\r\n]+/g, ' ').trim().slice(0, 400) || 'graph refresh failed'
16
+ }
17
+
18
+ export async function reconcileGraph(record, alias, role, graphHome) {
19
+ let registeredGraph = null
20
+ try { registeredGraph = loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}) } catch { /* refresh may repair it */ }
21
+ const buildMode = ['full', 'no-tests', 'tests-only'].includes(registeredGraph?.graphBuildMode)
22
+ ? registeredGraph.graphBuildMode
23
+ : 'full'
24
+ const precision = registeredGraph?.graphPrecisionMode === 'off' ? 'off' : 'lsp'
25
+ const freshnessProbe = repositoryFreshnessProbe(record.repoPath)
26
+ if (registeredGraph && freshnessProbe && persistedFreshnessMatches(registeredGraph, freshnessProbe, buildMode)) {
27
+ return {
28
+ record,
29
+ alias,
30
+ graph: registeredGraph,
31
+ publicStatus: {
32
+ role,
33
+ repository: publicRecord(record, alias),
34
+ buildMode,
35
+ status: 'CURRENT',
36
+ refresh: {kind: 'none', reason: 'persisted-freshness-match', changedFileCount: 0},
37
+ },
38
+ }
39
+ }
40
+ let build
41
+ try {
42
+ build = await buildGraphForRepo(record.repoPath, {
43
+ mode: buildMode,
44
+ precision,
45
+ scope: '',
46
+ outDir: record.graphDir,
47
+ graphHome,
48
+ })
49
+ } catch (error) {
50
+ build = {ok: false, error: safeRefreshReason(record, error)}
51
+ }
52
+ const refreshKind = build?.refresh?.kind || null
53
+ const changedFiles = Array.isArray(build?.refresh?.changedFiles) ? build.refresh.changedFiles : []
54
+ const publicStatus = {
55
+ role,
56
+ repository: publicRecord(record, alias),
57
+ buildMode,
58
+ status: build?.ok ? (refreshKind === 'none' ? 'CURRENT' : 'REFRESHED') : 'STALE_FALLBACK',
59
+ refresh: build?.ok ? {
60
+ kind: refreshKind || 'full',
61
+ reason: String(build?.refresh?.reason || 'graph-rebuilt'),
62
+ changedFileCount: changedFiles.length,
63
+ } : null,
64
+ }
65
+ if (build?.ok) {
66
+ try {
67
+ return {record, alias, graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}), publicStatus}
68
+ } catch (error) {
69
+ const reason = `refreshed graph could not be loaded: ${safeRefreshReason(record, error)}`
70
+ return {record, alias, graph: null, reason, publicStatus: {...publicStatus, status: 'FAILED', reason}}
71
+ }
72
+ }
73
+
74
+ const reason = `graph refresh failed: ${safeRefreshReason(record, build?.error)}`
75
+ try {
76
+ return {
77
+ record,
78
+ alias,
79
+ graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}),
80
+ reason: `${reason}; stale registered graph used`,
81
+ publicStatus: {...publicStatus, reason: `${reason}; stale registered graph used`},
82
+ }
83
+ } catch (error) {
84
+ const loadReason = `${reason}; registered graph could not be loaded: ${safeRefreshReason(record, error)}`
85
+ return {record, alias, graph: null, reason: loadReason, publicStatus: {...publicStatus, status: 'FAILED', reason: loadReason}}
86
+ }
87
+ }
@@ -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) => {
@@ -192,10 +205,3 @@ export function resolveSeedFiles(g, requested, limit = 12) {
192
205
  }
193
206
  return {seeds, missing}
194
207
  }
195
-
196
- export function undirectedNeighbors(g, id) {
197
- const seen = new Map()
198
- for (const e of g.out.get(id) || []) if (e.barrelProxy !== true) seen.set(e.id, e.relation)
199
- for (const e of g.inn.get(id) || []) if (e.barrelProxy !== true && !seen.has(e.id)) seen.set(e.id, e.relation)
200
- return seen
201
- }
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  isSymbol, degreeOf, labelOf, resolveNode, findSeeds, resolveSeedFiles,
3
- undirectedNeighbors, requestedPathClasses,
3
+ requestedPathClasses,
4
4
  } from '../graph-context.mjs'
5
5
  import {createPathClassifier, hasPathClass} from '../../path-classification.js'
6
6
 
@@ -52,6 +52,22 @@ const makeEdgeLabel = (g, shownIds) => {
52
52
  }
53
53
  }
54
54
 
55
+ // shortest_path is intentionally a connectivity query, so it may walk an edge in either
56
+ // direction. Keep the stored direction on every hop so the rendered path never turns a reverse
57
+ // traversal into a false caller/importer direction.
58
+ const connectivityNeighbors = (g, id) => {
59
+ const seen = new Map()
60
+ for (const edge of g.out.get(id) || []) {
61
+ if (edge.barrelProxy !== true) seen.set(String(edge.id), {relation: edge.relation, direction: 'forward'})
62
+ }
63
+ for (const edge of g.inn.get(id) || []) {
64
+ if (edge.barrelProxy !== true && !seen.has(String(edge.id))) {
65
+ seen.set(String(edge.id), {relation: edge.relation, direction: 'backward'})
66
+ }
67
+ }
68
+ return seen
69
+ }
70
+
55
71
  const relationSet = (relationFilter, legacyFilter) => {
56
72
  const raw = relationFilter ?? legacyFilter
57
73
  const values = Array.isArray(raw) ? raw : (raw == null ? [] : String(raw).split(','))
@@ -221,17 +237,17 @@ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
221
237
  if (sourceId === targetId) return `Source and target are the same node: ${sourceNode.label ?? sourceId}.`
222
238
  const limit = Math.max(1, Math.min(20, Number(max_hops) || 8))
223
239
  const previous = new Map([[sourceId, null]])
224
- const relationTo = new Map()
240
+ const edgeTo = new Map()
225
241
  let frontier = [sourceId]
226
242
  let hops = 0
227
243
  let found = false
228
244
  while (frontier.length && hops < limit && !found) {
229
245
  const next = []
230
246
  for (const id of frontier) {
231
- for (const [neighbor, relation] of undirectedNeighbors(g, id)) {
247
+ for (const [neighbor, edge] of connectivityNeighbors(g, id)) {
232
248
  if (previous.has(neighbor)) continue
233
249
  previous.set(neighbor, id)
234
- relationTo.set(neighbor, relation)
250
+ edgeTo.set(neighbor, edge)
235
251
  if (neighbor === targetId) { found = true; break }
236
252
  next.push(neighbor)
237
253
  }
@@ -244,6 +260,17 @@ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
244
260
  const path = []
245
261
  for (let current = targetId; current != null; current = previous.get(current)) path.unshift(current)
246
262
  const edgeLabel = makeEdgeLabel(g, path)
247
- const lines = path.map((id, index) => index === 0 ? ` ${edgeLabel(id)}` : ` --${relationTo.get(id) || 'rel'}--> ${edgeLabel(id)}`)
248
- return [`Shortest path (${path.length - 1} hops): ${sourceNode.label ?? sourceId} → ${targetNode.label ?? targetId}`, ...lines].join('\n')
263
+ const hopCount = path.length - 1
264
+ const lines = path.map((id, index) => {
265
+ if (index === 0) return ` ${edgeLabel(id)}`
266
+ const edge = edgeTo.get(id) || {}
267
+ return edge.direction === 'backward'
268
+ ? ` <--${edge.relation || 'rel'}-- ${edgeLabel(id)}`
269
+ : ` --${edge.relation || 'rel'}--> ${edgeLabel(id)}`
270
+ })
271
+ return [
272
+ `Shortest path (${hopCount} ${hopCount === 1 ? 'hop' : 'hops'}; undirected connectivity) from ${edgeLabel(sourceId)} to ${edgeLabel(targetId)}`,
273
+ 'Edge arrows preserve stored graph direction; traversal may move with or against them.',
274
+ ...lines,
275
+ ].join('\n')
249
276
  }
@@ -1,17 +1,16 @@
1
1
  // Cross-repository HTTP contract intelligence. Repository paths are resolved only through the
2
2
  // local global registry; callers can select an opaque repository UUID or an unambiguous label but
3
3
  // can never pass an arbitrary filesystem path through this tool.
4
- import {join} from 'node:path'
5
4
  import {analyzeHttpContracts} from '../analysis/http-contracts.js'
6
5
  import {analyzeTransportContracts} from '../analysis/transport-contracts.js'
7
- import {buildGraphForRepo} from '../build-graph.js'
8
6
  import {graphHomeDir} from '../graph/layout.js'
9
7
  import {liveRepositoryRecords} from '../graph/repo-registry.js'
10
- import {loadGraph} from './graph-context.mjs'
8
+ import {ContractCursorError, paginateContractEvidence} from './company-contract-page.mjs'
9
+ import {publicRecord, reconcileGraph} from './company-contract-reconcile.mjs'
11
10
  import {toolResult} from './tool-result.mjs'
12
11
  import {contractVerdict, contractVerdictLine} from './company-contract-verdict.mjs'
13
12
 
14
- const CROSS_REPO_HTTP_CONTRACT_V = 3
13
+ const CROSS_REPO_HTTP_CONTRACT_V = 4
15
14
  const selectorText = (value) => String(value ?? '').trim()
16
15
 
17
16
  function selectRecord(records, selector) {
@@ -35,76 +34,6 @@ function safeAlias(record, records) {
35
34
  return duplicates.length > 1 ? `${base}-${String(record.repositoryId).slice(0, 8)}` : base
36
35
  }
37
36
 
38
- function publicRecord(record, alias) {
39
- return {repositoryId: record.repositoryId, label: record.label, alias}
40
- }
41
-
42
- function safeRefreshReason(record, error) {
43
- let message = String(error instanceof Error ? error.message : error || 'graph refresh failed')
44
- for (const path of [record.repoPath, record.graphDir]) {
45
- if (!path) continue
46
- message = message.split(String(path)).join(record.label || 'repository')
47
- message = message.split(String(path).replace(/\\/g, '/')).join(record.label || 'repository')
48
- }
49
- return message.replace(/[\r\n]+/g, ' ').trim().slice(0, 400) || 'graph refresh failed'
50
- }
51
-
52
- async function reconcileGraph(record, alias, role, graphHome) {
53
- let registeredGraph = null
54
- try { registeredGraph = loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}) } catch { /* refresh may repair it */ }
55
- const buildMode = ['full', 'no-tests', 'tests-only'].includes(registeredGraph?.graphBuildMode)
56
- ? registeredGraph.graphBuildMode
57
- : 'full'
58
- const precision = registeredGraph?.graphPrecisionMode === 'off' ? 'off' : 'lsp'
59
- let build
60
- try {
61
- build = await buildGraphForRepo(record.repoPath, {
62
- mode: buildMode,
63
- precision,
64
- scope: '',
65
- outDir: record.graphDir,
66
- graphHome,
67
- })
68
- } catch (error) {
69
- build = {ok: false, error: safeRefreshReason(record, error)}
70
- }
71
- const refreshKind = build?.refresh?.kind || null
72
- const changedFiles = Array.isArray(build?.refresh?.changedFiles) ? build.refresh.changedFiles : []
73
- const publicStatus = {
74
- role,
75
- repository: publicRecord(record, alias),
76
- buildMode,
77
- status: build?.ok ? (refreshKind === 'none' ? 'CURRENT' : 'REFRESHED') : 'STALE_FALLBACK',
78
- refresh: build?.ok ? {
79
- kind: refreshKind || 'full',
80
- reason: String(build?.refresh?.reason || 'graph-rebuilt'),
81
- changedFileCount: changedFiles.length,
82
- } : null,
83
- }
84
- if (build?.ok) {
85
- try {
86
- return {record, alias, graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}), publicStatus}
87
- } catch (error) {
88
- const reason = `refreshed graph could not be loaded: ${safeRefreshReason(record, error)}`
89
- return {record, alias, graph: null, reason, publicStatus: {...publicStatus, status: 'FAILED', reason}}
90
- }
91
- }
92
-
93
- const reason = `graph refresh failed: ${safeRefreshReason(record, build?.error)}`
94
- try {
95
- return {
96
- record,
97
- alias,
98
- graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}),
99
- reason: `${reason}; stale registered graph used`,
100
- publicStatus: {...publicStatus, reason: `${reason}; stale registered graph used`},
101
- }
102
- } catch (error) {
103
- const loadReason = `${reason}; registered graph could not be loaded: ${safeRefreshReason(record, error)}`
104
- return {record, alias, graph: null, reason: loadReason, publicStatus: {...publicStatus, status: 'FAILED', reason: loadReason}}
105
- }
106
- }
107
-
108
37
  export async function tTraceApiContract(_g, args = {}, ctx = {}) {
109
38
  const graphHome = ctx.graphHome || graphHomeDir()
110
39
  const records = liveRepositoryRecords(graphHome)
@@ -246,6 +175,39 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
246
175
  ? 'Completeness: complete within the declared repository graphs, supported static models and fresh revision-bound runtime capture.'
247
176
  : `Completeness: partial — ${completeness.reasons.join('; ')}.`,
248
177
  ]
178
+ let evidencePage
179
+ try {
180
+ evidencePage = paginateContractEvidence({
181
+ analysis,
182
+ transportAnalysis,
183
+ args,
184
+ fingerprintParts: [
185
+ backend.repositoryId,
186
+ backendGraph.graph?.graphRevision || null,
187
+ ...clientGraphs.flatMap((item) => [item.record.repositoryId, item.graph?.graphRevision || null]),
188
+ selectedTransport,
189
+ args.method || null,
190
+ args.path || null,
191
+ Array.isArray(args.changed_files) ? [...args.changed_files].sort() : [],
192
+ args.include_tests === true,
193
+ ],
194
+ })
195
+ } catch (error) {
196
+ if (!(error instanceof ContractCursorError)) throw error
197
+ return toolResult(
198
+ `VERDICT INVALID_CURSOR — ${error.message}. Restart from the first page without cursor.`,
199
+ {status: 'INVALID_CURSOR', code: error.code, reason: error.message},
200
+ {page: {status: 'INVALID_CURSOR'}},
201
+ )
202
+ }
203
+ const transportSummary = {
204
+ transportContractsV: transportAnalysis.transportContractsV,
205
+ transport: transportAnalysis.transport,
206
+ status: transportAnalysis.status,
207
+ completeness: transportAnalysis.completeness,
208
+ totals: transportAnalysis.totals,
209
+ runtimeEvidence: transportAnalysis.runtimeEvidence,
210
+ }
249
211
  const result = {
250
212
  crossRepoHttpContractV: CROSS_REPO_HTTP_CONTRACT_V,
251
213
  verdict,
@@ -253,14 +215,28 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
253
215
  backend: publicRecord(backend, backendAlias),
254
216
  clients: clients.map(({record, alias}) => publicRecord(record, alias)),
255
217
  },
256
- ...analysis,
257
- transportContracts: transportAnalysis,
218
+ httpContractsV: analysis.httpContractsV,
219
+ filters: analysis.filters,
220
+ limits: analysis.limits,
221
+ totals: analysis.totals,
222
+ wrapperDiscovery: analysis.wrapperDiscovery,
223
+ transportContracts: transportSummary,
224
+ evidencePage,
258
225
  status: completeness.status,
259
226
  graphReconciliation,
260
227
  completeness,
261
228
  }
262
229
  return toolResult(lines.join('\n'), result, {
263
230
  completeness,
231
+ page: {
232
+ detail: evidencePage.detail,
233
+ offset: evidencePage.offset,
234
+ pageSize: evidencePage.pageSize,
235
+ totalItems: evidencePage.totalItems,
236
+ returnedItems: evidencePage.returnedItems,
237
+ hasMore: evidencePage.hasMore,
238
+ nextCursor: evidencePage.nextCursor,
239
+ },
264
240
  warnings: completeness.complete ? [] : [{code: 'CROSS_REPO_ANALYSIS_PARTIAL', message: completeness.reasons.join('; ')}],
265
241
  })
266
242
  } catch (error) {
@@ -148,10 +148,17 @@ export async function tVerifiedChange(g, args = {}, ctx = {}, tools = {}, permis
148
148
  const baseRef = String(args.base_ref || 'HEAD').trim()
149
149
  const graph = phase === 'verify' ? await baselineProof(ctx, baseRef, currentGraph) : {state: 'PLANNED', baseline: baseRef}
150
150
 
151
- let duplicates = {state: 'SKIPPED', reason: 'duplicate ratchet disabled'}
152
- if (phase === 'verify' && args.duplicate_ratchet !== false) duplicates = permissions.health
153
- ? await compareDuplicateGroups({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, currentGraph, baseRef, changedFiles, args: {mode: 'renamed', min_similarity: 80, min_tokens: 50}})
154
- : {state: 'UNKNOWN', reason: 'health capability is not enabled'}
151
+ const duplicateRatchetEnabled = args.duplicate_ratchet !== false
152
+ let duplicates
153
+ if (!duplicateRatchetEnabled) {
154
+ duplicates = {state: 'SKIPPED', enabled: false, reason: 'duplicate ratchet disabled'}
155
+ } else if (phase !== 'verify') {
156
+ duplicates = {state: 'PLANNED', enabled: true, reason: 'duplicate ratchet runs during verify phase'}
157
+ } else {
158
+ duplicates = permissions.health
159
+ ? {enabled: true, ...await compareDuplicateGroups({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, currentGraph, baseRef, changedFiles, args: {mode: 'renamed', min_similarity: 80, min_tokens: 50}})}
160
+ : {state: 'UNKNOWN', enabled: true, reason: 'health capability is not enabled'}
161
+ }
155
162
 
156
163
  let api = {state: 'SKIPPED', reason: 'no api_contract scope was requested'}
157
164
  if (args.api_contract) {
@@ -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
  {