weavatrix 0.3.11 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/releases/v0.3.12.md +44 -0
- package/package.json +3 -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/graph/context-seeds.mjs +18 -5
- 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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Weavatrix 0.3.12
|
|
2
|
+
|
|
3
|
+
0.3.12 makes the read-only core the provably-pure base of a layered stack. The
|
|
4
|
+
core stays exactly what it was for its users — the same 34 offline analysis tools
|
|
5
|
+
— but it now exposes a small read-only analysis surface for the packages built on
|
|
6
|
+
top of it, and machine gates prove it can neither modify code nor reach anything
|
|
7
|
+
external. Rust cross-crate call resolution and a `query_graph` seeding fix round
|
|
8
|
+
out the release.
|
|
9
|
+
|
|
10
|
+
## The read-only base of the stack
|
|
11
|
+
|
|
12
|
+
Weavatrix now anchors a stack of growing responsibility:
|
|
13
|
+
`weavatrix-online ⊃ weavatrix-refactor ⊃ weavatrix`. You install one package and
|
|
14
|
+
get everything below it. This release adds only what the base owes the layers
|
|
15
|
+
above; it registers no new tools and changes no existing tool.
|
|
16
|
+
|
|
17
|
+
- New `weavatrix/analysis-kit` export: a narrow, read-only surface (graph
|
|
18
|
+
identity, cycle detection, architecture verification, the bundled tree-sitter
|
|
19
|
+
grammars, the read-only TypeScript language-server client, dead-code signals)
|
|
20
|
+
that upper-layer packages compose. Nothing here writes, applies, or describes
|
|
21
|
+
an edit.
|
|
22
|
+
- Two release gates make the base's guarantees machine-checked, alongside the
|
|
23
|
+
existing ADR 0001 no-network gate: the core source contains **no edit-plan,
|
|
24
|
+
applier, or refactoring-builder marker**, and **no literal URL** at all. A
|
|
25
|
+
vulnerability finding references the advisory id rather than an external link;
|
|
26
|
+
the URL-matching malware heuristics are matchers over scanned code, not links
|
|
27
|
+
the core follows. Installing the base alone can neither modify your code nor
|
|
28
|
+
reach the network.
|
|
29
|
+
|
|
30
|
+
## Rust cross-crate call resolution
|
|
31
|
+
|
|
32
|
+
Rust call edges now resolve across crates, inline-test coverage is reported
|
|
33
|
+
honestly, and same-crate call noise is reduced — cross-crate `references` edges
|
|
34
|
+
land where a caller reaches a definition in another crate.
|
|
35
|
+
|
|
36
|
+
## query_graph seeding
|
|
37
|
+
|
|
38
|
+
A `query_graph` question that reduced entirely to stop words (a bare
|
|
39
|
+
`architecture`), or inferred a language absent from the repository (`contract`
|
|
40
|
+
inferring Solidity where there are no `.sol` files), previously returned a
|
|
41
|
+
misleading "No nodes matched". Both now fall back only when they would otherwise
|
|
42
|
+
return nothing: the stop filter relaxes when every token is a stop word, and a
|
|
43
|
+
requested language that matches zero nodes is dropped. Queries that already
|
|
44
|
+
worked are unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weavatrix",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local repository intelligence MCP for AI coding agents: an always-fresh architecture graph with production-first evidence — blast radius, dead code, endpoints, clones, history, Health and architecture safeguards, with honestly labeled precision.",
|
|
6
6
|
"author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"./mcp-runtime": "./src/mcp-runtime.mjs",
|
|
25
25
|
"./extension-api": "./src/mcp/extension-api.mjs",
|
|
26
26
|
"./extension/local-services": "./src/extension/local-services.mjs",
|
|
27
|
+
"./analysis-kit": "./src/analysis-kit.mjs",
|
|
27
28
|
"./package.json": "./package.json"
|
|
28
29
|
},
|
|
29
30
|
"files": [
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"docs/releases/v0.3.9.md",
|
|
46
47
|
"docs/releases/v0.3.10.md",
|
|
47
48
|
"docs/releases/v0.3.11.md",
|
|
49
|
+
"docs/releases/v0.3.12.md",
|
|
48
50
|
"docs/releases/v0.2.19.md",
|
|
49
51
|
"README.md",
|
|
50
52
|
"SECURITY.md",
|
|
@@ -19,6 +19,13 @@ export function parseCargoToml(text) {
|
|
|
19
19
|
for (const raw of source.split(/\r?\n/)) {
|
|
20
20
|
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
21
21
|
if (!line) continue;
|
|
22
|
+
// Array-of-tables headers ([[bin]], [[example]], [[test]], [[bench]]) are neither the [package]
|
|
23
|
+
// table nor a dependency table. Recognise them so their `name =`/`path =` keys are not mis-read:
|
|
24
|
+
// a [[bin]] renamed to a crate ([[bin]] name = "foo") would otherwise overwrite the package name and
|
|
25
|
+
// make every foo:: import look like a self-reference, and a [[bin]] after [dependencies] would leak
|
|
26
|
+
// phantom "name"/"path" dependencies.
|
|
27
|
+
const arrayHeader = /^\[\[([^\]]+)]]$/.exec(line);
|
|
28
|
+
if (arrayHeader) { section = arrayHeader[1].trim(); continue; }
|
|
22
29
|
const header = /^\[([^\]]+)]$/.exec(line);
|
|
23
30
|
if (header) { section = header[1].trim(); workspace ||= section === "workspace"; continue; }
|
|
24
31
|
const kv = /^([A-Za-z0-9_-]+)\s*=\s*(.+)$/.exec(line);
|
|
@@ -111,7 +111,7 @@ function symbolRanges(graph) {
|
|
|
111
111
|
if (!line) continue;
|
|
112
112
|
let arr = byFile.get(n.source_file);
|
|
113
113
|
if (!arr) byFile.set(n.source_file, (arr = []));
|
|
114
|
-
arr.push({ id: n.id, label: n.label || n.id, line, symbolKind: n.symbol_kind || "" });
|
|
114
|
+
arr.push({ id: n.id, label: n.label || n.id, line, symbolKind: n.symbol_kind || "", testSurface: n.test_surface === true });
|
|
115
115
|
}
|
|
116
116
|
for (const arr of byFile.values()) arr.sort((a, b) => a.line - b.line);
|
|
117
117
|
return byFile;
|
|
@@ -184,6 +184,10 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
|
184
184
|
id: syms[i].id, label: syms[i].label, file, start, end,
|
|
185
185
|
n: toks.strict.length, declarative,
|
|
186
186
|
...classificationFields(pathInfo),
|
|
187
|
+
// Inline test surfaces (Rust `#[cfg(test)] mod tests`, `#[test]` fns) live in production files, so a
|
|
188
|
+
// path check alone misses them; the graph already flags the symbol. OR it in so skip-tests suppresses
|
|
189
|
+
// them by default and two #[test] fns are never reported as a production clone.
|
|
190
|
+
test: hasPathClass(pathInfo, "test", "e2e") || syms[i].testSurface === true,
|
|
187
191
|
fp: { strict: fingerprints(toks.strict), renamed: fingerprints(toks.renamed) },
|
|
188
192
|
});
|
|
189
193
|
}
|
|
@@ -36,7 +36,7 @@ function coverageForRange(record, startLine, endLine) {
|
|
|
36
36
|
return total ? covered / total : Number.isFinite(record.pct) ? record.pct : null
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function graphRiskBySymbol(graph, symbolIds) {
|
|
39
|
+
function graphRiskBySymbol(graph, symbolIds, testCallerIds) {
|
|
40
40
|
const state = new Map([...symbolIds].map((id) => [id, {
|
|
41
41
|
incoming: 0, outgoing: 0, callers: new Set(), callees: new Set(),
|
|
42
42
|
}]))
|
|
@@ -50,7 +50,10 @@ function graphRiskBySymbol(graph, symbolIds) {
|
|
|
50
50
|
item.outgoing++
|
|
51
51
|
item.callees.add(target)
|
|
52
52
|
}
|
|
53
|
-
|
|
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
|
}
|
|
@@ -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) => {
|
|
@@ -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
|
{
|