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 +9 -4
- package/docs/releases/v0.3.12.md +44 -0
- package/docs/releases/v0.3.13.md +34 -0
- package/package.json +4 -1
- package/src/analysis/cargo-manifests.js +7 -0
- package/src/analysis/duplicates.compute.js +5 -1
- package/src/analysis/hot-path-review.js +25 -3
- package/src/analysis/internal-audit/supply-chain.js +1 -1
- package/src/analysis/manifests.js +4 -1
- package/src/analysis/static-test-reachability.js +19 -2
- package/src/analysis-kit.mjs +17 -0
- package/src/graph/builder/lang-rust-calls.js +63 -0
- package/src/graph/builder/lang-rust.js +31 -6
- package/src/graph/builder/pass2-resolution.js +12 -1
- package/src/graph/internal-builder.build.js +1 -1
- package/src/graph/internal-builder.pass2.js +4 -2
- package/src/graph/internal-builder.resolvers.js +36 -1
- package/src/mcp/catalog.mjs +4 -0
- package/src/mcp/company-contract-page.mjs +142 -0
- package/src/mcp/company-contract-reconcile.mjs +87 -0
- package/src/mcp/graph/context-seeds.mjs +18 -12
- package/src/mcp/graph/tools-query.mjs +33 -6
- package/src/mcp/tools-company.mjs +52 -76
- package/src/mcp/tools-verified-change.mjs +11 -4
- package/src/precision/typescript-provider/client.js +7 -0
- package/src/security/advisory-store.js +0 -1
- package/src/security/malware-heuristics.exclusions.js +1 -1
- package/src/security/registry-sig.rules.js +1 -1
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
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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,
|
|
247
|
+
for (const [neighbor, edge] of connectivityNeighbors(g, id)) {
|
|
232
248
|
if (previous.has(neighbor)) continue
|
|
233
249
|
previous.set(neighbor, id)
|
|
234
|
-
|
|
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
|
|
248
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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;
|
|
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
|
{
|