weavatrix 0.1.3 → 0.2.0
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 +168 -63
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +108 -39
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +13 -6
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +163 -0
- package/src/analysis/dep-check.js +69 -147
- package/src/analysis/dep-rules.js +47 -31
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +18 -5
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +29 -28
- package/src/analysis/graph-analysis.edges.js +24 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +43 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +66 -13
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +27 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +111 -16
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/internal-builder.resolvers.js +109 -2
- package/src/graph/layout.js +21 -5
- package/src/graph/relations.js +4 -0
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +64 -21
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +106 -181
- package/src/mcp/graph-diff.mjs +217 -0
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +194 -8
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +114 -33
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +36 -68
- package/src/mcp/tools-health.mjs +354 -20
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +35 -11
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Spring MVC/WebFlux endpoint extraction. This stays source-only and deliberately resolves only
|
|
2
|
+
// literal annotation paths: inventing values for constants would make an architecture inventory look
|
|
3
|
+
// more complete than the evidence permits.
|
|
4
|
+
import { maskJavaNonCode } from "./java-source.js";
|
|
5
|
+
|
|
6
|
+
const SPRING_MAPPING = /@(?:org\.springframework\.web\.bind\.annotation\.)?(RequestMapping|GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b/g;
|
|
7
|
+
const REQUEST_METHOD = /\bRequestMethod\s*\.\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\b/g;
|
|
8
|
+
|
|
9
|
+
const lineAt = (text, index) => {
|
|
10
|
+
let line = 1;
|
|
11
|
+
for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++;
|
|
12
|
+
return line;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function skipTrivia(text, start) {
|
|
16
|
+
let i = start;
|
|
17
|
+
while (i < text.length) {
|
|
18
|
+
if (/\s/.test(text[i])) { i++; continue; }
|
|
19
|
+
if (text.startsWith("//", i)) {
|
|
20
|
+
const nl = text.indexOf("\n", i + 2);
|
|
21
|
+
i = nl < 0 ? text.length : nl + 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (text.startsWith("/*", i)) {
|
|
25
|
+
const end = text.indexOf("*/", i + 2);
|
|
26
|
+
i = end < 0 ? text.length : end + 2;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
return i;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function balancedEnd(text, start, open = "(", close = ")") {
|
|
35
|
+
if (text[start] !== open) return start;
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let quote = "";
|
|
38
|
+
let escaped = false;
|
|
39
|
+
for (let i = start; i < text.length; i++) {
|
|
40
|
+
const ch = text[i];
|
|
41
|
+
if (quote) {
|
|
42
|
+
if (escaped) escaped = false;
|
|
43
|
+
else if (ch === "\\") escaped = true;
|
|
44
|
+
else if (ch === quote) quote = "";
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
|
48
|
+
if (ch === open) depth++;
|
|
49
|
+
else if (ch === close && --depth === 0) return i + 1;
|
|
50
|
+
}
|
|
51
|
+
return text.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function annotationInvocation(text, nameEnd) {
|
|
55
|
+
const start = skipTrivia(text, nameEnd);
|
|
56
|
+
if (text[start] !== "(") return { args: "", end: start };
|
|
57
|
+
const end = balancedEnd(text, start);
|
|
58
|
+
return { args: text.slice(start + 1, Math.max(start + 1, end - 1)), end };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function skipAnnotations(text, start) {
|
|
62
|
+
let i = skipTrivia(text, start);
|
|
63
|
+
while (text[i] === "@") {
|
|
64
|
+
const name = /^@[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*/.exec(text.slice(i));
|
|
65
|
+
if (!name) break;
|
|
66
|
+
i += name[0].length;
|
|
67
|
+
i = annotationInvocation(text, i).end;
|
|
68
|
+
i = skipTrivia(text, i);
|
|
69
|
+
}
|
|
70
|
+
return i;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stringLiterals(value) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const re = /"((?:\\.|[^"\\])*)"/g;
|
|
76
|
+
let match;
|
|
77
|
+
while ((match = re.exec(String(value || "")))) {
|
|
78
|
+
out.push(match[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\"));
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function initializer(args, equalsAt) {
|
|
84
|
+
let i = skipTrivia(args, equalsAt + 1);
|
|
85
|
+
if (args[i] === "{") {
|
|
86
|
+
const end = balancedEnd(args, i, "{", "}");
|
|
87
|
+
return args.slice(i, end);
|
|
88
|
+
}
|
|
89
|
+
if (args[i] === '"') {
|
|
90
|
+
let escaped = false;
|
|
91
|
+
for (let end = i + 1; end < args.length; end++) {
|
|
92
|
+
if (escaped) escaped = false;
|
|
93
|
+
else if (args[end] === "\\") escaped = true;
|
|
94
|
+
else if (args[end] === '"') return args.slice(i, end + 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const comma = args.indexOf(",", i);
|
|
98
|
+
return args.slice(i, comma < 0 ? args.length : comma);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mappingPaths(args) {
|
|
102
|
+
const source = String(args || "");
|
|
103
|
+
const named = /\b(?:path|value)\s*=/g.exec(source);
|
|
104
|
+
if (named) return stringLiterals(initializer(source, source.indexOf("=", named.index)));
|
|
105
|
+
const leading = source.trimStart();
|
|
106
|
+
if (!leading) return [""];
|
|
107
|
+
if (leading[0] === '"' || leading[0] === "{") return stringLiterals(leading);
|
|
108
|
+
// `method=`, `produces=` and friends do not specify a path, so they map the class/method root.
|
|
109
|
+
if (/^(?:method|produces|consumes|headers|params|name)\s*=/.test(leading)) return [""];
|
|
110
|
+
return []; // unresolved positional constant: do not invent a root route
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function mappingMethods(name, args) {
|
|
114
|
+
if (name !== "RequestMapping") return [name.replace(/Mapping$/, "").toUpperCase()];
|
|
115
|
+
const methods = [];
|
|
116
|
+
let match;
|
|
117
|
+
REQUEST_METHOD.lastIndex = 0;
|
|
118
|
+
while ((match = REQUEST_METHOD.exec(args))) if (!methods.includes(match[1])) methods.push(match[1]);
|
|
119
|
+
return methods.length ? methods : ["ANY"];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function joinPaths(prefix, path) {
|
|
123
|
+
const left = String(prefix || "").trim().replace(/^\/+|\/+$/g, "");
|
|
124
|
+
const right = String(path || "").trim().replace(/^\/+|\/+$/g, "");
|
|
125
|
+
return `/${[left, right].filter(Boolean).join("/")}`.replace(/\/{2,}/g, "/");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function declarationAfter(text, start) {
|
|
129
|
+
const declarationStart = skipAnnotations(text, start);
|
|
130
|
+
const bounded = text.slice(declarationStart, declarationStart + 2_500);
|
|
131
|
+
const terminators = [bounded.indexOf("{"), bounded.indexOf(";")].filter((i) => i >= 0);
|
|
132
|
+
const end = terminators.length ? Math.min(...terminators) : bounded.length;
|
|
133
|
+
const head = bounded.slice(0, end);
|
|
134
|
+
const classMatch = /\b(class|interface|record|enum)\s+([A-Za-z_$][\w$]*)/.exec(head);
|
|
135
|
+
if (classMatch) {
|
|
136
|
+
const open = bounded.indexOf("{");
|
|
137
|
+
const bodyOpen = open < 0 ? -1 : declarationStart + open;
|
|
138
|
+
return { kind: "class", name: classMatch[2], bodyOpen, bodyClose: bodyOpen < 0 ? text.length : balancedEnd(text, bodyOpen, "{", "}") };
|
|
139
|
+
}
|
|
140
|
+
const methodMatch = /\b([A-Za-z_$][\w$]*)\s*\(/.exec(head);
|
|
141
|
+
return methodMatch ? { kind: "method", name: methodMatch[1] } : { kind: "unknown", name: "" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function extractSpringEndpoints(text, file) {
|
|
145
|
+
if (!/\.java$/i.test(file)) return [];
|
|
146
|
+
const code = maskJavaNonCode(text);
|
|
147
|
+
if (!/@(?:[\w$]+\.)*(?:Request|Get|Post|Put|Patch|Delete)Mapping\b/.test(code)) return [];
|
|
148
|
+
const mappings = [];
|
|
149
|
+
let match;
|
|
150
|
+
SPRING_MAPPING.lastIndex = 0;
|
|
151
|
+
while ((match = SPRING_MAPPING.exec(code))) {
|
|
152
|
+
const invocation = annotationInvocation(text, SPRING_MAPPING.lastIndex);
|
|
153
|
+
const declaration = declarationAfter(code, invocation.end);
|
|
154
|
+
mappings.push({
|
|
155
|
+
annotation: match[1],
|
|
156
|
+
args: invocation.args,
|
|
157
|
+
index: match.index,
|
|
158
|
+
line: lineAt(text, match.index),
|
|
159
|
+
declaration,
|
|
160
|
+
});
|
|
161
|
+
SPRING_MAPPING.lastIndex = Math.max(SPRING_MAPPING.lastIndex, invocation.end);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const classMappings = mappings
|
|
165
|
+
.filter((item) => item.declaration.kind === "class")
|
|
166
|
+
.map((item) => ({ ...item, paths: mappingPaths(item.args) }));
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const item of mappings) {
|
|
169
|
+
if (item.declaration.kind !== "method") continue;
|
|
170
|
+
const owner = classMappings
|
|
171
|
+
.filter((candidate) => candidate.declaration.bodyOpen >= 0
|
|
172
|
+
&& item.index > candidate.declaration.bodyOpen
|
|
173
|
+
&& item.index < candidate.declaration.bodyClose)
|
|
174
|
+
.sort((a, b) => b.declaration.bodyOpen - a.declaration.bodyOpen)[0];
|
|
175
|
+
if (owner && !owner.paths.length) continue; // unresolved class prefix makes the full route unknowable
|
|
176
|
+
const prefixes = owner?.paths?.length ? owner.paths : [""];
|
|
177
|
+
const paths = mappingPaths(item.args);
|
|
178
|
+
if (!paths.length) continue; // literal path was requested but could not be resolved
|
|
179
|
+
for (const method of mappingMethods(item.annotation, item.args)) {
|
|
180
|
+
for (const prefix of prefixes) for (const path of paths) {
|
|
181
|
+
out.push({ method, path: joinPaths(prefix, path), handler: item.declaration.name, file, line: item.line });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const RUST_ROUTE_METHOD = "get|post|put|patch|delete|head|options|trace|connect|any";
|
|
2
|
+
|
|
3
|
+
// Find a call's closing parenthesis without mistaking parens inside strings for syntax. This is deliberately
|
|
4
|
+
// small (not a Rust parser), but keeps route-handler expressions bounded instead of using a cross-statement
|
|
5
|
+
// regex that could turn an unrelated function call into a route.
|
|
6
|
+
function closingParen(text, openAt) {
|
|
7
|
+
let depth = 0, quote = "", escaped = false, blockComment = 0;
|
|
8
|
+
for (let i = openAt; i < text.length; i++) {
|
|
9
|
+
const ch = text[i];
|
|
10
|
+
if (quote) {
|
|
11
|
+
if (escaped) escaped = false;
|
|
12
|
+
else if (ch === "\\") escaped = true;
|
|
13
|
+
else if (ch === quote) quote = "";
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (blockComment) {
|
|
17
|
+
if (ch === "/" && text[i + 1] === "*") { blockComment++; i++; }
|
|
18
|
+
else if (ch === "*" && text[i + 1] === "/") { blockComment--; i++; }
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
22
|
+
const newline = text.indexOf("\n", i + 2);
|
|
23
|
+
if (newline < 0) return -1;
|
|
24
|
+
i = newline;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (ch === "/" && text[i + 1] === "*") { blockComment = 1; i++; continue; }
|
|
28
|
+
if (ch === '"') { quote = ch; continue; }
|
|
29
|
+
if (ch === "'") {
|
|
30
|
+
// Skip a Rust character literal, but do not mistake a lifetime (`'a`) for an unterminated string.
|
|
31
|
+
const char = /^'(?:\\.|[^\\'\r\n])'/.exec(text.slice(i));
|
|
32
|
+
if (char) { i += char[0].length - 1; continue; }
|
|
33
|
+
}
|
|
34
|
+
if (ch === "(") depth++;
|
|
35
|
+
else if (ch === ")" && --depth === 0) return i;
|
|
36
|
+
}
|
|
37
|
+
return -1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Parse axum's MethodRouter expression, including direct method chains:
|
|
41
|
+
// get(list).post(create) / axum::routing::get(show).delete(remove)
|
|
42
|
+
// Calls inside a handler closure are not scanned and therefore cannot become fake endpoint methods.
|
|
43
|
+
function axumRoutes(expr) {
|
|
44
|
+
const found = [];
|
|
45
|
+
let rest = String(expr || ""), offset = 0;
|
|
46
|
+
const firstRe = new RegExp(`^\\s*(?:(?:axum\\s*::\\s*)?routing\\s*::\\s*)?(${RUST_ROUTE_METHOD})\\s*\\(`, "i");
|
|
47
|
+
let match = firstRe.exec(rest);
|
|
48
|
+
if (!match) return found;
|
|
49
|
+
|
|
50
|
+
while (match) {
|
|
51
|
+
const openAt = offset + match.index + match[0].lastIndexOf("(");
|
|
52
|
+
const closeAt = closingParen(expr, openAt);
|
|
53
|
+
if (closeAt < 0) break;
|
|
54
|
+
found.push({ method: match[1], handler: expr.slice(openAt + 1, closeAt), index: openAt });
|
|
55
|
+
offset = closeAt + 1;
|
|
56
|
+
rest = expr.slice(offset);
|
|
57
|
+
match = new RegExp(`^\\s*\\.\\s*(${RUST_ROUTE_METHOD})\\s*\\(`, "i").exec(rest);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Actix's builder form is structurally distinct from axum and from HTTP clients:
|
|
63
|
+
// web::get().to(handler) / actix_web::web::post().to(handler)
|
|
64
|
+
function actixRoutes(expr) {
|
|
65
|
+
const found = [];
|
|
66
|
+
const re = new RegExp(`(?:actix_web\\s*::\\s*)?web\\s*::\\s*(${RUST_ROUTE_METHOD})\\s*\\(\\s*\\)\\s*\\.\\s*to\\s*\\(`, "gi");
|
|
67
|
+
let match;
|
|
68
|
+
while ((match = re.exec(expr))) {
|
|
69
|
+
const openAt = re.lastIndex - 1;
|
|
70
|
+
const closeAt = closingParen(expr, openAt);
|
|
71
|
+
if (closeAt < 0) continue;
|
|
72
|
+
found.push({ method: match[1], handler: expr.slice(openAt + 1, closeAt), index: match.index });
|
|
73
|
+
re.lastIndex = closeAt + 1;
|
|
74
|
+
}
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function extractRustEndpoints(text, add) {
|
|
79
|
+
let match;
|
|
80
|
+
|
|
81
|
+
// Axum and actix both expose a path-first `.route(path, ...)` builder. Requiring either an axum
|
|
82
|
+
// MethodRouter or actix's web::<method>().to(...) avoids treating arbitrary Rust APIs named `route` as HTTP.
|
|
83
|
+
const routeRe = /\.\s*route\s*\(\s*"(\/(?:\\.|[^"\\])*)"\s*,/g;
|
|
84
|
+
while ((match = routeRe.exec(text))) {
|
|
85
|
+
const openAt = text.indexOf("(", match.index);
|
|
86
|
+
const closeAt = closingParen(text, openAt);
|
|
87
|
+
if (closeAt < 0) continue;
|
|
88
|
+
const exprStart = routeRe.lastIndex;
|
|
89
|
+
const expr = text.slice(exprStart, closeAt);
|
|
90
|
+
const routes = [...axumRoutes(expr), ...actixRoutes(expr)];
|
|
91
|
+
for (const route of routes) add(route.method, match[1], route.handler, exprStart + route.index);
|
|
92
|
+
routeRe.lastIndex = closeAt + 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// actix `web::resource(path)` associates several method routes with one path.
|
|
96
|
+
const resourceRe = /(?:actix_web\s*::\s*)?web\s*::\s*resource\s*\(\s*"(\/(?:\\.|[^"\\])*)"\s*\)/g;
|
|
97
|
+
while ((match = resourceRe.exec(text))) {
|
|
98
|
+
const tail = text.slice(resourceRe.lastIndex);
|
|
99
|
+
const nextResource = tail.search(/(?:actix_web\s*::\s*)?web\s*::\s*resource\s*\(/);
|
|
100
|
+
const semicolon = text.indexOf(";", resourceRe.lastIndex);
|
|
101
|
+
let end = Math.min(text.length, resourceRe.lastIndex + 4000);
|
|
102
|
+
if (nextResource >= 0) end = Math.min(end, resourceRe.lastIndex + nextResource);
|
|
103
|
+
if (semicolon >= 0) end = Math.min(end, semicolon);
|
|
104
|
+
const chain = text.slice(resourceRe.lastIndex, end);
|
|
105
|
+
for (const route of actixRoutes(chain)) add(route.method, match[1], route.handler, resourceRe.lastIndex + route.index);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Actix attribute macros. The route macro can declare more than one method for the same handler.
|
|
109
|
+
const macroRe = new RegExp(`#\\s*\\[\\s*(?:(?:actix_web|actix)\\s*::\\s*)?(${RUST_ROUTE_METHOD})\\s*\\(\\s*"(\\/(?:\\\\.|[^"\\\\])*)"(?:\\s*,[^\\]]*)?\\s*\\)\\s*\\]`, "gi");
|
|
110
|
+
while ((match = macroRe.exec(text))) {
|
|
111
|
+
const after = text.slice(macroRe.lastIndex, macroRe.lastIndex + 600);
|
|
112
|
+
const fn = /\b(?:pub(?:\s*\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)/.exec(after);
|
|
113
|
+
add(match[1], match[2], fn ? fn[1] : "", match.index);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const multiMacroRe = /#\s*\[\s*(?:(?:actix_web|actix)\s*::\s*)?route\s*\(\s*"(\/(?:\\.|[^"\\])*)"([\s\S]*?)\)\s*\]/gi;
|
|
117
|
+
while ((match = multiMacroRe.exec(text))) {
|
|
118
|
+
const after = text.slice(multiMacroRe.lastIndex, multiMacroRe.lastIndex + 600);
|
|
119
|
+
const fn = /\b(?:pub(?:\s*\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)/.exec(after);
|
|
120
|
+
const methodRe = /\bmethod\s*=\s*"(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)"/gi;
|
|
121
|
+
let method;
|
|
122
|
+
while ((method = methodRe.exec(match[2]))) add(method[1], match[1], fn ? fn[1] : "", match.index);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// endpoints.js — detect the repo's OWN HTTP API surface (routes it EXPOSES), so the Health tab can
|
|
2
2
|
// surface endpoints the way infra towers surface the services a repo TALKS TO. Each endpoint carries a
|
|
3
3
|
// best-effort handler NAME so the UI can join it to that method's health (O(n) complexity, criticality,
|
|
4
|
-
// coverage). Covers the common shapes across JS/TS, Python and
|
|
4
|
+
// coverage). Covers the common shapes across JS/TS, Python, Go and Rust:
|
|
5
5
|
// • object routes (Bun.serve / custom): "/path": { GET: handler, POST: fn } | "/path": handler
|
|
6
6
|
// • method-call routes (Express/Fastify/Koa/Hono/gin/echo): app.get("/path", handler)
|
|
7
7
|
// • decorators (FastAPI/Flask/NestJS): @app.get("/path") / @Get("/path")
|
|
8
8
|
// • Go net/http: mux.HandleFunc("/path", handler)
|
|
9
|
+
// • Rust axum/actix-web: .route("/path", get(handler)) / #[get("/path")]
|
|
9
10
|
import { safeRead } from "../util.js";
|
|
10
11
|
import { createRepoBoundary } from "../repo-path.js";
|
|
12
|
+
import { extractRustEndpoints } from "./endpoints-rust.js";
|
|
13
|
+
import { extractSpringEndpoints } from "./endpoints-java.js";
|
|
11
14
|
|
|
12
15
|
const MAX_FILES = 3000;
|
|
13
|
-
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "ALL", "ANY"]);
|
|
16
|
+
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT", "ALL", "ANY"]);
|
|
14
17
|
// UNAMBIGUOUS HTTP CLIENTS (make requests) vs servers (define routes) — reject `axios.get("/x")`-style client
|
|
15
18
|
// calls in frontend code. Ambiguous names (api/client/service — could be a server router) are NOT listed;
|
|
16
19
|
// they're filtered by the handler requirement instead (a client call has no handler / a config object arg).
|
|
@@ -31,7 +34,9 @@ function lineAt(text, index) {
|
|
|
31
34
|
function handlerName(expr) {
|
|
32
35
|
const s = String(expr || "").trim();
|
|
33
36
|
if (!s) return "";
|
|
34
|
-
if (/=>/.test(s) || /^\s*(async\s+)?function\b/.test(s)) return "";
|
|
37
|
+
if (/=>/.test(s) || /^\s*(async\s+)?function\b/.test(s) || /^\s*(?:async\s+)?(?:move\s+)?\|[^|]*\|/.test(s)) return "";
|
|
38
|
+
const turbofish = /(?:^|::)([A-Za-z_][\w]*)\s*::<[\s\S]*>\s*$/.exec(s);
|
|
39
|
+
if (turbofish) return turbofish[1];
|
|
35
40
|
const ids = s.match(/[A-Za-z_$][\w$]*/g);
|
|
36
41
|
if (!ids) return "";
|
|
37
42
|
const SKIP = new Set(["async", "function", "await", "req", "res", "ctx", "request", "response", "next", "return"]);
|
|
@@ -68,6 +73,8 @@ export function nextRoutePath(file) {
|
|
|
68
73
|
export function extractEndpointsFromText(text, file) {
|
|
69
74
|
const out = [];
|
|
70
75
|
const py = /\.py$/i.test(file);
|
|
76
|
+
const rust = /\.rs$/i.test(file);
|
|
77
|
+
const java = /\.java$/i.test(file);
|
|
71
78
|
const add = (method, path, expr, idx) => {
|
|
72
79
|
const p = cleanPath(path);
|
|
73
80
|
if (!looksLikePath(p)) return;
|
|
@@ -101,6 +108,12 @@ export function extractEndpointsFromText(text, file) {
|
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
if (rust) extractRustEndpoints(text, add);
|
|
112
|
+
if (java) {
|
|
113
|
+
out.push(...extractSpringEndpoints(text, file));
|
|
114
|
+
return out; // generic JS-style method calls would turn Java HTTP clients into fake server routes
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
// ---- object routes: "/path": { GET: fn, POST: fn2 } or "/path": handler --------------------
|
|
105
118
|
// find each "…": { or "…": expr, where the key looks like a path
|
|
106
119
|
const objKeyRe = /(["'`])(\/[^"'`]*)\1\s*:\s*(\{)?/g;
|
|
@@ -171,11 +184,11 @@ export function detectEndpoints(repoPath, codeFiles) {
|
|
|
171
184
|
const boundary = createRepoBoundary(repoPath);
|
|
172
185
|
for (const f of files) {
|
|
173
186
|
const rel = f.path || f;
|
|
174
|
-
if (!/\.(js|ts|tsx|jsx|cjs|mjs|py|go)$/i.test(rel)) continue;
|
|
187
|
+
if (!/\.(js|ts|tsx|jsx|cjs|mjs|py|go|rs|java)$/i.test(rel)) continue;
|
|
175
188
|
const resolved = boundary.resolve(rel);
|
|
176
189
|
if (!resolved.ok) continue;
|
|
177
190
|
const text = safeRead(resolved.path);
|
|
178
|
-
if (!text || (!nextRoutePath(rel) && !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)/i.test(text))) continue;
|
|
191
|
+
if (!text || (!nextRoutePath(rel) && !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)|@(?:[\w$]+\.)*(?:Request|Get|Post|Put|Patch|Delete)Mapping\b/i.test(text))) continue;
|
|
179
192
|
for (const e of extractEndpointsFromText(text, rel.replace(/\\/g, "/"))) {
|
|
180
193
|
const key = `${e.method} ${normParamKey(e.path)}`;
|
|
181
194
|
const prev = byKey.get(key);
|
package/src/analysis/findings.js
CHANGED
|
@@ -8,8 +8,14 @@ export const FINDING_CATEGORIES = ["unused", "structure", "vulnerability", "malw
|
|
|
8
8
|
|
|
9
9
|
// Stable id: survives re-runs so the UI can persist expand/dismiss state per finding.
|
|
10
10
|
export function makeFinding(f) {
|
|
11
|
+
const cycleIdentity = Array.isArray(f.cycleMembers)
|
|
12
|
+
? [...new Set(f.cycleMembers.map(String))].sort().join("\0")
|
|
13
|
+
: "";
|
|
11
14
|
const id = createHash("sha1")
|
|
12
|
-
.update([
|
|
15
|
+
.update([
|
|
16
|
+
f.category, f.rule, f.file || "", f.manifest || "", f.scope || "",
|
|
17
|
+
f.package || "", f.symbol || "", cycleIdentity, f.title || "",
|
|
18
|
+
].join("|"))
|
|
13
19
|
.digest("hex")
|
|
14
20
|
.slice(0, 16);
|
|
15
21
|
return {
|
|
@@ -18,6 +24,7 @@ export function makeFinding(f) {
|
|
|
18
24
|
confidence: "medium",
|
|
19
25
|
title: "",
|
|
20
26
|
detail: "",
|
|
27
|
+
reason: "",
|
|
21
28
|
file: "",
|
|
22
29
|
line: 0,
|
|
23
30
|
symbol: "",
|