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,581 @@
|
|
|
1
|
+
// Cross-repository HTTP contract evidence: join routes exposed by backend repositories to literal or
|
|
2
|
+
// bounded-template HTTP calls in client repositories. Results contain metadata only (path/method and
|
|
3
|
+
// file:line), never source snippets or URL expressions.
|
|
4
|
+
import { folderModuleOf } from "./graph-analysis.edges.js";
|
|
5
|
+
import { detectEndpoints } from "./endpoints.js";
|
|
6
|
+
import { isStructuralRelation } from "../graph/relations.js";
|
|
7
|
+
import { createPathClassifier, hasPathClass } from "../path-classification.js";
|
|
8
|
+
import { isWeavatrixIgnored, loadWeavatrixIgnore } from "../path-ignore.js";
|
|
9
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
10
|
+
import { safeRead } from "../util.js";
|
|
11
|
+
|
|
12
|
+
export const HTTP_CONTRACTS_V = 1;
|
|
13
|
+
|
|
14
|
+
const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
15
|
+
const DEFAULT_CLIENT_NAMES = Object.freeze(["axios", "http", "https", "$http", "httpClient", "apiClient", "restClient"]);
|
|
16
|
+
const DEFAULTS = Object.freeze({
|
|
17
|
+
maxBackendFiles: 3_000,
|
|
18
|
+
maxClientFiles: 3_000,
|
|
19
|
+
maxEndpoints: 250,
|
|
20
|
+
maxCallsPerClient: 2_000,
|
|
21
|
+
maxMatches: 1_000,
|
|
22
|
+
maxCallsitesPerEndpoint: 100,
|
|
23
|
+
maxUncertain: 200,
|
|
24
|
+
maxImpactDepth: 2,
|
|
25
|
+
maxAffectedFiles: 100,
|
|
26
|
+
maxScreens: 50,
|
|
27
|
+
maxModules: 50,
|
|
28
|
+
});
|
|
29
|
+
const HARD = Object.freeze({
|
|
30
|
+
maxBackendFiles: 3_000,
|
|
31
|
+
maxClientFiles: 10_000,
|
|
32
|
+
maxEndpoints: 500,
|
|
33
|
+
maxCallsPerClient: 5_000,
|
|
34
|
+
maxMatches: 5_000,
|
|
35
|
+
maxCallsitesPerEndpoint: 500,
|
|
36
|
+
maxUncertain: 1_000,
|
|
37
|
+
maxImpactDepth: 5,
|
|
38
|
+
maxAffectedFiles: 500,
|
|
39
|
+
maxScreens: 200,
|
|
40
|
+
maxModules: 200,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const endpointId = (value) => value && typeof value === "object" ? value.id : value;
|
|
44
|
+
const normalizeFile = (value) => {
|
|
45
|
+
const raw = String(value || "").replace(/\\/g, "/");
|
|
46
|
+
if (!raw || raw.startsWith("/") || /^[a-z]:\//i.test(raw) || /[\x00-\x1f\x7f]/.test(raw)) return "";
|
|
47
|
+
const normalized = raw.replace(/^\.\//, "");
|
|
48
|
+
return normalized.split("/").some((part) => !part || part === "." || part === "..") ? "" : normalized;
|
|
49
|
+
};
|
|
50
|
+
const boundedInteger = (value, fallback, min, max) => {
|
|
51
|
+
const number = Number(value);
|
|
52
|
+
return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback;
|
|
53
|
+
};
|
|
54
|
+
const safeName = (value, fallback) => {
|
|
55
|
+
const text = String(value || "").trim();
|
|
56
|
+
return /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(text) ? text : fallback;
|
|
57
|
+
};
|
|
58
|
+
const lineAt = (text, index) => {
|
|
59
|
+
let line = 1;
|
|
60
|
+
for (let cursor = 0; cursor < index; cursor += 1) if (text.charCodeAt(cursor) === 10) line += 1;
|
|
61
|
+
return line;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function normalizeLimits(input = {}) {
|
|
65
|
+
const result = {};
|
|
66
|
+
for (const key of Object.keys(DEFAULTS)) result[key] = boundedInteger(input[key], DEFAULTS[key], key === "maxImpactDepth" ? 0 : 1, HARD[key]);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeHttpContractPath(value) {
|
|
71
|
+
let path = String(value || "").trim();
|
|
72
|
+
if (!path || path.length > 2_048) return null;
|
|
73
|
+
try {
|
|
74
|
+
if (/^https?:\/\//i.test(path)) path = new URL(path).pathname;
|
|
75
|
+
else if (/^\/\//.test(path)) path = new URL(`http:${path}`).pathname;
|
|
76
|
+
} catch { return null; }
|
|
77
|
+
const queryAt = path.search(/[?#]/);
|
|
78
|
+
if (queryAt >= 0) path = path.slice(0, queryAt);
|
|
79
|
+
path = path.replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
80
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
81
|
+
path = path.replace(/\/\{[^/}]+\}/g, "/:param")
|
|
82
|
+
.replace(/\/:([A-Za-z_$][\w$-]*)(?:\?)?/g, "/:param")
|
|
83
|
+
.replace(/\/\*[^/]*/g, "/*")
|
|
84
|
+
.replace(/\[(?:\.\.\.)?[^\]]+\]/g, "/:param")
|
|
85
|
+
.replace(/\/+$/g, "") || "/";
|
|
86
|
+
if (/[\x00-\x1f\x7f]/.test(path) || path.includes("..")) return null;
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function maskNonCode(text) {
|
|
91
|
+
// Split by UTF-16 code unit so indices stay aligned with the original JS string even when a file
|
|
92
|
+
// contains astral Unicode before a callsite.
|
|
93
|
+
const chars = String(text || "").split("");
|
|
94
|
+
let index = 0;
|
|
95
|
+
while (index < chars.length) {
|
|
96
|
+
const char = chars[index];
|
|
97
|
+
const next = chars[index + 1];
|
|
98
|
+
if (char === "/" && next === "/") {
|
|
99
|
+
chars[index++] = " "; chars[index++] = " ";
|
|
100
|
+
while (index < chars.length && chars[index] !== "\n") chars[index++] = " ";
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (char === "/" && next === "*") {
|
|
104
|
+
chars[index++] = " "; chars[index++] = " ";
|
|
105
|
+
while (index < chars.length) {
|
|
106
|
+
if (chars[index] === "*" && chars[index + 1] === "/") { chars[index++] = " "; chars[index++] = " "; break; }
|
|
107
|
+
if (chars[index] !== "\n") chars[index] = " ";
|
|
108
|
+
index += 1;
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
113
|
+
const quote = char;
|
|
114
|
+
chars[index++] = " ";
|
|
115
|
+
while (index < chars.length) {
|
|
116
|
+
if (chars[index] === "\\") {
|
|
117
|
+
chars[index++] = " ";
|
|
118
|
+
if (index < chars.length && chars[index] !== "\n") chars[index] = " ";
|
|
119
|
+
index += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const closes = chars[index] === quote;
|
|
123
|
+
if (chars[index] !== "\n") chars[index] = " ";
|
|
124
|
+
index += 1;
|
|
125
|
+
if (closes) break;
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
index += 1;
|
|
130
|
+
}
|
|
131
|
+
return chars.join("");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function quotedArgument(text, start, quote) {
|
|
135
|
+
let value = "";
|
|
136
|
+
for (let index = start + 1; index < text.length; index += 1) {
|
|
137
|
+
const char = text[index];
|
|
138
|
+
if (char === "\\") {
|
|
139
|
+
if (index + 1 >= text.length) break;
|
|
140
|
+
value += text[index + 1];
|
|
141
|
+
index += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (char === quote) return { value, endIndex: index + 1 };
|
|
145
|
+
if (char === "\n" || char === "\r") break;
|
|
146
|
+
value += char;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function templateArgument(text, start) {
|
|
152
|
+
let value = "";
|
|
153
|
+
let dynamicSegments = 0;
|
|
154
|
+
let unknownPrefix = false;
|
|
155
|
+
let partialDynamic = false;
|
|
156
|
+
for (let index = start + 1; index < text.length; index += 1) {
|
|
157
|
+
const char = text[index];
|
|
158
|
+
if (char === "\\") {
|
|
159
|
+
if (index + 1 >= text.length) break;
|
|
160
|
+
value += text[index + 1];
|
|
161
|
+
index += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (char === "`") return { value, endIndex: index + 1, dynamicSegments, unknownPrefix, partialDynamic };
|
|
165
|
+
if (char !== "$" || text[index + 1] !== "{") {
|
|
166
|
+
value += char;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
let cursor = index + 2;
|
|
170
|
+
let depth = 1;
|
|
171
|
+
let quote = null;
|
|
172
|
+
while (cursor < text.length && depth > 0) {
|
|
173
|
+
const token = text[cursor];
|
|
174
|
+
if (quote) {
|
|
175
|
+
if (token === "\\") cursor += 2;
|
|
176
|
+
else { if (token === quote) quote = null; cursor += 1; }
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (token === "'" || token === '"' || token === "`") { quote = token; cursor += 1; continue; }
|
|
180
|
+
if (token === "{") depth += 1;
|
|
181
|
+
else if (token === "}") depth -= 1;
|
|
182
|
+
cursor += 1;
|
|
183
|
+
}
|
|
184
|
+
if (depth !== 0) return null;
|
|
185
|
+
const next = text[cursor];
|
|
186
|
+
const leftBoundary = value === "" || value.endsWith("/");
|
|
187
|
+
const rightBoundary = !next || next === "/" || next === "?" || next === "#" || next === "`";
|
|
188
|
+
dynamicSegments += 1;
|
|
189
|
+
if (value === "" && next === "/") unknownPrefix = true;
|
|
190
|
+
else if (leftBoundary && rightBoundary) value += ":param";
|
|
191
|
+
else { value += ":dynamic"; partialDynamic = true; }
|
|
192
|
+
index = cursor - 1;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseUrlArgument(text, openParen) {
|
|
198
|
+
let start = openParen + 1;
|
|
199
|
+
while (/\s/.test(text[start] || "")) start += 1;
|
|
200
|
+
const quote = text[start];
|
|
201
|
+
if (quote === "'" || quote === '"') {
|
|
202
|
+
const parsed = quotedArgument(text, start, quote);
|
|
203
|
+
if (!parsed) return { path: null, endIndex: start, kind: "dynamic", dynamic: true, reason: "unterminated URL literal" };
|
|
204
|
+
return { path: normalizeHttpContractPath(parsed.value), endIndex: parsed.endIndex, kind: "literal", dynamic: false, unknownPrefix: false, partialDynamic: false, reason: null };
|
|
205
|
+
}
|
|
206
|
+
if (quote === "`") {
|
|
207
|
+
const parsed = templateArgument(text, start);
|
|
208
|
+
if (!parsed) return { path: null, endIndex: start, kind: "dynamic", dynamic: true, reason: "unterminated URL template" };
|
|
209
|
+
return {
|
|
210
|
+
path: normalizeHttpContractPath(parsed.value),
|
|
211
|
+
endIndex: parsed.endIndex,
|
|
212
|
+
kind: parsed.dynamicSegments ? "template" : "literal",
|
|
213
|
+
dynamic: parsed.dynamicSegments > 0,
|
|
214
|
+
unknownPrefix: parsed.unknownPrefix,
|
|
215
|
+
partialDynamic: parsed.partialDynamic,
|
|
216
|
+
reason: parsed.partialDynamic ? "URL template contains a partial dynamic segment" : null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { path: null, endIndex: start, kind: "dynamic", dynamic: true, unknownPrefix: false, partialDynamic: true, reason: "URL argument is not a string or template literal" };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function fetchMethod(text, argumentEnd) {
|
|
223
|
+
const tail = text.slice(argumentEnd, argumentEnd + 500);
|
|
224
|
+
if (!/^\s*,/.test(tail)) return { method: "GET", uncertain: false };
|
|
225
|
+
const config = tail.replace(/^\s*,\s*/, "");
|
|
226
|
+
if (!config.startsWith("{") || /^\{\s*\.\.\./.test(config)) return { method: "UNKNOWN", uncertain: true };
|
|
227
|
+
const literal = /\bmethod\s*:\s*(["'`])(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\1/i.exec(tail);
|
|
228
|
+
if (literal) return { method: literal[2].toUpperCase(), uncertain: false };
|
|
229
|
+
if (/\bmethod\s*:/.test(tail)) return { method: "UNKNOWN", uncertain: true };
|
|
230
|
+
return { method: "GET", uncertain: false };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizedClientNames(values) {
|
|
234
|
+
return new Set([...DEFAULT_CLIENT_NAMES, ...(Array.isArray(values) ? values : [])]
|
|
235
|
+
.map((value) => String(value || "").trim().toLowerCase())
|
|
236
|
+
.filter((value) => /^[a-z_$][\w$]*$/i.test(value)));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function extractHttpClientCallsFromText(text, file, options = {}) {
|
|
240
|
+
const source = String(text || "");
|
|
241
|
+
const mask = maskNonCode(source);
|
|
242
|
+
const allowed = normalizedClientNames(options.clientNames);
|
|
243
|
+
const maxCalls = boundedInteger(options.maxCalls, DEFAULTS.maxCallsPerClient, 1, HARD.maxCallsPerClient);
|
|
244
|
+
const calls = [];
|
|
245
|
+
let truncated = false;
|
|
246
|
+
const add = (clientName, method, openParen, fetch = false) => {
|
|
247
|
+
if (calls.length >= maxCalls) { truncated = true; return; }
|
|
248
|
+
const parsed = parseUrlArgument(source, openParen);
|
|
249
|
+
const fetchInfo = fetch ? fetchMethod(source, parsed.endIndex) : { method: method.toUpperCase(), uncertain: false };
|
|
250
|
+
calls.push({
|
|
251
|
+
file: normalizeFile(file),
|
|
252
|
+
line: lineAt(source, openParen),
|
|
253
|
+
client: clientName,
|
|
254
|
+
method: fetchInfo.method,
|
|
255
|
+
path: parsed.path,
|
|
256
|
+
kind: parsed.kind,
|
|
257
|
+
dynamic: parsed.dynamic,
|
|
258
|
+
unknownPrefix: Boolean(parsed.unknownPrefix),
|
|
259
|
+
partialDynamic: Boolean(parsed.partialDynamic || fetchInfo.uncertain),
|
|
260
|
+
reason: fetchInfo.uncertain ? "HTTP method is dynamic" : parsed.reason,
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const member = /(^|[^\w$])([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*(get|post|put|patch|delete|head|options)\s*\(/gim;
|
|
265
|
+
let match;
|
|
266
|
+
while ((match = member.exec(mask))) {
|
|
267
|
+
if (!allowed.has(match[2].toLowerCase())) continue;
|
|
268
|
+
add(match[2], match[3], member.lastIndex - 1, false);
|
|
269
|
+
}
|
|
270
|
+
const fetchCall = /(^|[^\w$])fetch\s*\(/gim;
|
|
271
|
+
while ((match = fetchCall.exec(mask))) add("fetch", "GET", fetchCall.lastIndex - 1, true);
|
|
272
|
+
calls.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.method.localeCompare(right.method) || String(left.path).localeCompare(String(right.path)));
|
|
273
|
+
return { calls, truncated };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function filesFromGraph(graph) {
|
|
277
|
+
return [...new Set((graph?.nodes || []).map((node) => normalizeFile(node?.source_file)).filter(Boolean))].sort();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function detectHttpClientCalls(repoRoot, codeFiles, options = {}) {
|
|
281
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
282
|
+
if (!boundary.root) return { calls: [], truncated: false, filesScanned: 0 };
|
|
283
|
+
const maxFiles = boundedInteger(options.maxFiles, DEFAULTS.maxClientFiles, 1, HARD.maxClientFiles);
|
|
284
|
+
const maxCalls = boundedInteger(options.maxCalls, DEFAULTS.maxCallsPerClient, 1, HARD.maxCallsPerClient);
|
|
285
|
+
const ignoreRules = loadWeavatrixIgnore(boundary.root);
|
|
286
|
+
const classifier = createPathClassifier(boundary.root);
|
|
287
|
+
const candidates = [...new Set((codeFiles || []).map((entry) => normalizeFile(entry?.path || entry)).filter(Boolean))].sort();
|
|
288
|
+
let truncated = candidates.length > maxFiles;
|
|
289
|
+
let filesScanned = 0;
|
|
290
|
+
const calls = [];
|
|
291
|
+
for (const file of candidates.slice(0, maxFiles)) {
|
|
292
|
+
if (!/\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file) || isWeavatrixIgnored(file, ignoreRules)) continue;
|
|
293
|
+
const classification = classifier.explain(file, { content: "" });
|
|
294
|
+
if (classification.excluded || (!options.includeTests && hasPathClass(classification, "test", "e2e"))) continue;
|
|
295
|
+
const resolved = boundary.resolve(file);
|
|
296
|
+
if (!resolved.ok) continue;
|
|
297
|
+
const text = safeRead(resolved.path);
|
|
298
|
+
if (!text) continue;
|
|
299
|
+
filesScanned += 1;
|
|
300
|
+
const remaining = maxCalls - calls.length;
|
|
301
|
+
if (remaining <= 0) { truncated = true; break; }
|
|
302
|
+
const extracted = extractHttpClientCallsFromText(text, file, { clientNames: options.clientNames, maxCalls: remaining });
|
|
303
|
+
calls.push(...extracted.calls);
|
|
304
|
+
if (extracted.truncated) truncated = true;
|
|
305
|
+
}
|
|
306
|
+
calls.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.method.localeCompare(right.method) || String(left.path).localeCompare(String(right.path)));
|
|
307
|
+
return { calls, truncated, filesScanned };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function pathSegments(path) {
|
|
311
|
+
return String(path || "").split("/").filter(Boolean);
|
|
312
|
+
}
|
|
313
|
+
const parameter = (segment) => segment === ":param";
|
|
314
|
+
const wildcard = (segment) => segment === "*";
|
|
315
|
+
|
|
316
|
+
function routeShapeMatches(endpointSegments, callSegments) {
|
|
317
|
+
const catchAll = wildcard(endpointSegments.at(-1));
|
|
318
|
+
if (catchAll ? callSegments.length < endpointSegments.length - 1 : endpointSegments.length !== callSegments.length) return false;
|
|
319
|
+
const compared = catchAll ? endpointSegments.length - 1 : endpointSegments.length;
|
|
320
|
+
for (let index = 0; index < compared; index += 1) {
|
|
321
|
+
const expected = endpointSegments[index];
|
|
322
|
+
const actual = callSegments[index];
|
|
323
|
+
if (parameter(expected)) continue;
|
|
324
|
+
if (parameter(actual) || wildcard(actual) || expected !== actual) return false;
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function suffixShapeMatch(endpointSegments, callSegments) {
|
|
330
|
+
if (endpointSegments.length === callSegments.length || Math.min(endpointSegments.length, callSegments.length) < 2) return false;
|
|
331
|
+
if (endpointSegments.length < callSegments.length) {
|
|
332
|
+
return routeShapeMatches(endpointSegments, callSegments.slice(callSegments.length - endpointSegments.length));
|
|
333
|
+
}
|
|
334
|
+
return routeShapeMatches(endpointSegments.slice(endpointSegments.length - callSegments.length), callSegments);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function methodMatches(endpointMethod, callMethod) {
|
|
338
|
+
return endpointMethod === "ANY" || endpointMethod === "ALL" || endpointMethod === callMethod;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function matchHttpContract(endpoint, call) {
|
|
342
|
+
if (!call?.path || !methodMatches(String(endpoint?.method || "").toUpperCase(), call.method)) return null;
|
|
343
|
+
const expected = pathSegments(normalizeHttpContractPath(endpoint.path));
|
|
344
|
+
const actual = pathSegments(call.path);
|
|
345
|
+
if (routeShapeMatches(expected, actual)) {
|
|
346
|
+
if (call.unknownPrefix || call.partialDynamic) return { kind: "exact-dynamic", confidence: "medium", score: 0.78, reason: "method and normalized route shape match, but the client URL retains a dynamic component" };
|
|
347
|
+
const concreteParameter = expected.some((segment, index) => parameter(segment) && !parameter(actual[index]));
|
|
348
|
+
return {
|
|
349
|
+
kind: "exact",
|
|
350
|
+
confidence: "high",
|
|
351
|
+
score: concreteParameter ? 0.96 : 1,
|
|
352
|
+
reason: concreteParameter ? "method matches and a backend parameter accepts the concrete client segment" : "method and normalized route shape match exactly",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (suffixShapeMatch(expected, actual)) {
|
|
356
|
+
const dynamic = call.dynamic || call.unknownPrefix || call.partialDynamic;
|
|
357
|
+
return {
|
|
358
|
+
kind: "suffix",
|
|
359
|
+
confidence: dynamic ? "low" : "medium",
|
|
360
|
+
score: dynamic ? 0.55 : 0.72,
|
|
361
|
+
reason: dynamic ? "method matches and at least two trailing route segments match; the client prefix is dynamic" : "method matches and at least two trailing route segments match after a client/backend base-path difference",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function reverseImports(graph = {}) {
|
|
368
|
+
const byId = new Map();
|
|
369
|
+
const files = new Set();
|
|
370
|
+
for (const node of graph.nodes || []) {
|
|
371
|
+
const file = normalizeFile(node?.source_file);
|
|
372
|
+
if (!file) continue;
|
|
373
|
+
byId.set(String(node.id), file);
|
|
374
|
+
files.add(file);
|
|
375
|
+
}
|
|
376
|
+
const reverse = new Map([...files].map((file) => [file, new Set()]));
|
|
377
|
+
for (const link of graph.links || []) {
|
|
378
|
+
if (isStructuralRelation(link?.relation) || !["imports", "re_exports"].includes(link?.relation) || link?.typeOnly === true || link?.compileOnly === true || link?.barrelProxy === true) continue;
|
|
379
|
+
const importer = byId.get(String(endpointId(link.source)));
|
|
380
|
+
const imported = byId.get(String(endpointId(link.target)));
|
|
381
|
+
if (!importer || !imported || importer === imported) continue;
|
|
382
|
+
reverse.get(imported)?.add(importer);
|
|
383
|
+
}
|
|
384
|
+
return reverse;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isScreen(file) {
|
|
388
|
+
const path = normalizeFile(file);
|
|
389
|
+
const base = path.split("/").at(-1) || "";
|
|
390
|
+
return /(^|\/)(pages?|screens?|views?|routes?)(\/|$)/i.test(path)
|
|
391
|
+
|| /(^|\/)(?:page|layout)\.[cm]?[jt]sx?$/i.test(path)
|
|
392
|
+
|| /^(?:App|Root)\.[cm]?[jt]sx?$/i.test(base);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function affectedForEndpoint(callsites, clientContexts, limits) {
|
|
396
|
+
const collected = new Map();
|
|
397
|
+
let traversalTruncated = false;
|
|
398
|
+
for (const context of clientContexts) {
|
|
399
|
+
const seeds = callsites.filter((call) => call.clientRepo === context.id).map((call) => call.file).sort();
|
|
400
|
+
if (!seeds.length) continue;
|
|
401
|
+
const queue = [];
|
|
402
|
+
const distance = new Map();
|
|
403
|
+
for (const seed of seeds) if (!distance.has(seed)) { distance.set(seed, 0); queue.push(seed); }
|
|
404
|
+
while (queue.length) {
|
|
405
|
+
const file = queue.shift();
|
|
406
|
+
const depth = distance.get(file);
|
|
407
|
+
const key = `${context.id}\0${file}`;
|
|
408
|
+
const previous = collected.get(key);
|
|
409
|
+
if (!previous || depth < previous.distance) collected.set(key, { client: context.id, file, distance: depth });
|
|
410
|
+
if (depth >= limits.maxImpactDepth) continue;
|
|
411
|
+
for (const importer of [...(context.reverse.get(file) || [])].sort()) {
|
|
412
|
+
if (distance.has(importer)) continue;
|
|
413
|
+
if (distance.size >= limits.maxAffectedFiles * 2) { traversalTruncated = true; continue; }
|
|
414
|
+
distance.set(importer, depth + 1);
|
|
415
|
+
queue.push(importer);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const allFiles = [...collected.values()].sort((left, right) => left.distance - right.distance || left.client.localeCompare(right.client) || left.file.localeCompare(right.file));
|
|
420
|
+
const filesTruncated = allFiles.length > limits.maxAffectedFiles;
|
|
421
|
+
const files = allFiles.slice(0, limits.maxAffectedFiles);
|
|
422
|
+
const allScreens = files.filter((entry) => isScreen(entry.file));
|
|
423
|
+
const screensTruncated = allScreens.length > limits.maxScreens;
|
|
424
|
+
const screens = allScreens.slice(0, limits.maxScreens);
|
|
425
|
+
const moduleMap = new Map();
|
|
426
|
+
for (const entry of files) {
|
|
427
|
+
const module = folderModuleOf(entry.file);
|
|
428
|
+
const key = `${entry.client}\0${module}`;
|
|
429
|
+
const item = moduleMap.get(key) || { client: entry.client, module, files: 0, nearestDistance: entry.distance };
|
|
430
|
+
item.files += 1;
|
|
431
|
+
item.nearestDistance = Math.min(item.nearestDistance, entry.distance);
|
|
432
|
+
moduleMap.set(key, item);
|
|
433
|
+
}
|
|
434
|
+
const allModules = [...moduleMap.values()].sort((left, right) => left.nearestDistance - right.nearestDistance || right.files - left.files || left.client.localeCompare(right.client) || left.module.localeCompare(right.module));
|
|
435
|
+
const modulesTruncated = allModules.length > limits.maxModules;
|
|
436
|
+
return {
|
|
437
|
+
complete: !(traversalTruncated || filesTruncated || screensTruncated || modulesTruncated),
|
|
438
|
+
files,
|
|
439
|
+
screens,
|
|
440
|
+
modules: allModules.slice(0, limits.maxModules),
|
|
441
|
+
truncated: { traversal: traversalTruncated, files: filesTruncated, screens: screensTruncated, modules: modulesTruncated },
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function descriptorFiles(descriptor) {
|
|
446
|
+
return descriptor.codeFiles || filesFromGraph(descriptor.graph);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function endpointFilter(endpoint, backendId, options) {
|
|
450
|
+
if (options.method && endpoint.method !== options.method && endpoint.method !== "ANY" && endpoint.method !== "ALL") return false;
|
|
451
|
+
if (options.path) {
|
|
452
|
+
const requested = normalizeHttpContractPath(options.path);
|
|
453
|
+
if (!requested || !routeShapeMatches(pathSegments(normalizeHttpContractPath(endpoint.path)), pathSegments(requested))) return false;
|
|
454
|
+
}
|
|
455
|
+
if (options.changedFiles?.size) {
|
|
456
|
+
const file = normalizeFile(endpoint.file);
|
|
457
|
+
if (!options.changedFiles.has(file) && !options.changedFiles.has(`${backendId}::${file}`)) return false;
|
|
458
|
+
}
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// `backends` and `clients` are arrays of {id, repoRoot, codeFiles?, graph?}. Backends may optionally
|
|
463
|
+
// supply a precomputed `endpoints` array; otherwise the shared multi-language endpoint detector is used.
|
|
464
|
+
export function analyzeHttpContracts(input = {}) {
|
|
465
|
+
const limits = normalizeLimits(input);
|
|
466
|
+
const method = input.method ? String(input.method).toUpperCase() : null;
|
|
467
|
+
if (method && !METHODS.has(method)) throw new Error("method must be a concrete HTTP method");
|
|
468
|
+
const changedFiles = new Set((Array.isArray(input.changedFiles) ? input.changedFiles : []).map((file) => normalizeFile(file)).filter(Boolean));
|
|
469
|
+
const backendDescriptors = (Array.isArray(input.backends) ? input.backends : input.backend ? [input.backend] : []).slice(0, 20);
|
|
470
|
+
const clientDescriptors = (Array.isArray(input.clients) ? input.clients : input.client ? [input.client] : []).slice(0, 20);
|
|
471
|
+
const completeness = [];
|
|
472
|
+
const backends = [];
|
|
473
|
+
let endpointBudget = limits.maxEndpoints;
|
|
474
|
+
for (let index = 0; index < backendDescriptors.length; index += 1) {
|
|
475
|
+
const descriptor = backendDescriptors[index] || {};
|
|
476
|
+
const id = safeName(descriptor.id, `backend-${index + 1}`);
|
|
477
|
+
const candidates = descriptorFiles(descriptor).slice().sort();
|
|
478
|
+
if (candidates.length > limits.maxBackendFiles) completeness.push(`${id}: backend file cap reached`);
|
|
479
|
+
const detected = Array.isArray(descriptor.endpoints)
|
|
480
|
+
? descriptor.endpoints
|
|
481
|
+
: detectEndpoints(descriptor.repoRoot, candidates.slice(0, limits.maxBackendFiles));
|
|
482
|
+
const filtered = detected.filter((endpoint) => normalizeHttpContractPath(endpoint?.path) && normalizeFile(endpoint?.file) && endpointFilter(endpoint, id, { method, path: input.path, changedFiles })).sort((left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method) || String(left.file).localeCompare(String(right.file)) || Number(left.line) - Number(right.line));
|
|
483
|
+
if (filtered.length > endpointBudget) completeness.push(`${id}: endpoint cap reached`);
|
|
484
|
+
const accepted = filtered.slice(0, endpointBudget);
|
|
485
|
+
endpointBudget -= accepted.length;
|
|
486
|
+
backends.push({ id, endpoints: accepted });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const clients = [];
|
|
490
|
+
for (let index = 0; index < clientDescriptors.length; index += 1) {
|
|
491
|
+
const descriptor = clientDescriptors[index] || {};
|
|
492
|
+
const id = safeName(descriptor.id, `client-${index + 1}`);
|
|
493
|
+
const detected = detectHttpClientCalls(descriptor.repoRoot, descriptorFiles(descriptor), {
|
|
494
|
+
maxFiles: limits.maxClientFiles,
|
|
495
|
+
maxCalls: limits.maxCallsPerClient,
|
|
496
|
+
clientNames: descriptor.clientNames || input.clientNames,
|
|
497
|
+
includeTests: descriptor.includeTests ?? input.includeTests,
|
|
498
|
+
});
|
|
499
|
+
if (detected.truncated) completeness.push(`${id}: client scan cap reached`);
|
|
500
|
+
clients.push({ id, calls: detected.calls, reverse: reverseImports(descriptor.graph), filesScanned: detected.filesScanned });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const results = [];
|
|
504
|
+
let matches = 0;
|
|
505
|
+
let methodMismatches = 0;
|
|
506
|
+
let callsiteCapReached = false;
|
|
507
|
+
for (const backend of backends) {
|
|
508
|
+
for (const endpoint of backend.endpoints) {
|
|
509
|
+
const callsites = [];
|
|
510
|
+
for (const client of clients) {
|
|
511
|
+
for (const call of client.calls) {
|
|
512
|
+
if (call.path && !methodMatches(endpoint.method, call.method)) {
|
|
513
|
+
const expected = pathSegments(normalizeHttpContractPath(endpoint.path));
|
|
514
|
+
const actual = pathSegments(call.path);
|
|
515
|
+
if (routeShapeMatches(expected, actual) || suffixShapeMatch(expected, actual)) methodMismatches += 1;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const match = matchHttpContract(endpoint, call);
|
|
519
|
+
if (!match) continue;
|
|
520
|
+
if (matches >= limits.maxMatches || callsites.length >= limits.maxCallsitesPerEndpoint) {
|
|
521
|
+
callsiteCapReached = true;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
matches += 1;
|
|
525
|
+
callsites.push({
|
|
526
|
+
clientRepo: client.id,
|
|
527
|
+
file: call.file,
|
|
528
|
+
line: call.line,
|
|
529
|
+
method: call.method,
|
|
530
|
+
path: call.path,
|
|
531
|
+
dynamic: call.dynamic,
|
|
532
|
+
match,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
callsites.sort((left, right) => right.match.score - left.match.score || left.clientRepo.localeCompare(right.clientRepo) || left.file.localeCompare(right.file) || left.line - right.line);
|
|
537
|
+
results.push({
|
|
538
|
+
backend: backend.id,
|
|
539
|
+
method: endpoint.method,
|
|
540
|
+
path: endpoint.path,
|
|
541
|
+
normalizedPath: normalizeHttpContractPath(endpoint.path),
|
|
542
|
+
handler: /^[A-Za-z_$][\w$]{0,127}$/.test(String(endpoint.handler || "")) ? String(endpoint.handler) : null,
|
|
543
|
+
file: normalizeFile(endpoint.file) || null,
|
|
544
|
+
line: Number(endpoint.line) || null,
|
|
545
|
+
callsites,
|
|
546
|
+
affected: affectedForEndpoint(callsites, clients, limits),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (callsiteCapReached) completeness.push("match or per-endpoint callsite cap reached");
|
|
551
|
+
|
|
552
|
+
const uncertainAll = clients.flatMap((client) => client.calls.filter((call) => !call.path || call.unknownPrefix || call.partialDynamic || call.method === "UNKNOWN").map((call) => ({
|
|
553
|
+
clientRepo: client.id,
|
|
554
|
+
file: call.file,
|
|
555
|
+
line: call.line,
|
|
556
|
+
method: call.method,
|
|
557
|
+
reason: call.reason || "URL retains an unresolved dynamic component",
|
|
558
|
+
}))).sort((left, right) => left.clientRepo.localeCompare(right.clientRepo) || left.file.localeCompare(right.file) || left.line - right.line);
|
|
559
|
+
if (uncertainAll.length > limits.maxUncertain) completeness.push("uncertain callsite cap reached");
|
|
560
|
+
const affectedPartial = results.some((endpoint) => !endpoint.affected.complete);
|
|
561
|
+
if (affectedPartial) completeness.push("affected-file traversal cap reached");
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
httpContractsV: HTTP_CONTRACTS_V,
|
|
565
|
+
status: completeness.length ? "partial" : "complete",
|
|
566
|
+
filters: { method, path: input.path ? normalizeHttpContractPath(input.path) : null, changedFiles: [...changedFiles].sort() },
|
|
567
|
+
limits,
|
|
568
|
+
completeness: { complete: completeness.length === 0, reasons: [...new Set(completeness)] },
|
|
569
|
+
totals: {
|
|
570
|
+
backends: backends.length,
|
|
571
|
+
clients: clients.length,
|
|
572
|
+
endpoints: results.length,
|
|
573
|
+
clientCalls: clients.reduce((sum, client) => sum + client.calls.length, 0),
|
|
574
|
+
matches,
|
|
575
|
+
methodMismatches,
|
|
576
|
+
uncertainCalls: uncertainAll.length,
|
|
577
|
+
},
|
|
578
|
+
endpoints: results,
|
|
579
|
+
uncertain: uncertainAll.slice(0, limits.maxUncertain),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
@@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps } from "./manifests.js";
|
|
7
7
|
import { createRepoBoundary } from "../repo-path.js";
|
|
8
8
|
import { childProcessEnv } from "../child-env.js";
|
|
9
|
+
import { filterWeavatrixIgnored } from "../path-ignore.js";
|
|
9
10
|
|
|
10
11
|
export const readText = (p) => { try { return readFileSync(p, "utf8"); } catch { return null; } };
|
|
11
12
|
export const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
@@ -33,7 +34,7 @@ export function listRepoFiles(repoRoot) {
|
|
|
33
34
|
encoding: "utf8", windowsHide: true, timeout: 15_000, maxBuffer: 32 * 1024 * 1024,
|
|
34
35
|
env: childProcessEnv(),
|
|
35
36
|
});
|
|
36
|
-
if (r.status === 0) return String(r.stdout || "").split("\0").filter(Boolean).map((f) => f.replace(/\\/g, "/"));
|
|
37
|
+
if (r.status === 0) return filterWeavatrixIgnored(repoRoot, String(r.stdout || "").split("\0").filter(Boolean).map((f) => f.replace(/\\/g, "/")));
|
|
37
38
|
} catch { /* non-Git repo or git unavailable: use the bounded walker below */ }
|
|
38
39
|
|
|
39
40
|
const files = [];
|
|
@@ -47,7 +48,47 @@ export function listRepoFiles(repoRoot) {
|
|
|
47
48
|
}
|
|
48
49
|
};
|
|
49
50
|
walk(repoRoot);
|
|
50
|
-
return files;
|
|
51
|
+
return filterWeavatrixIgnored(repoRoot, files);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const NON_RUNTIME_DIR_RE = /^(?:templates?|examples?|samples?|fixtures?|snippets?|__fixtures__)$/i;
|
|
55
|
+
const NON_RUNTIME_README_RE = /(?:\b(?:reusable|copyable|reference)\b[\s\S]{0,160}\b(?:templates?|snippets?|examples?|samples?)\b|\b(?:these|contents?)\s+are\s+templates?\b)/i;
|
|
56
|
+
const normConfiguredRoot = (value) => {
|
|
57
|
+
const root = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
|
|
58
|
+
if (!root || root === "." || root.split("/").some((part) => part === "..")) return "";
|
|
59
|
+
return root;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Runtime health findings should not treat copy-paste catalogs as deployed applications. Conventional
|
|
63
|
+
// template/example directories are safe to infer; a top-level custom catalog is inferred only when its
|
|
64
|
+
// own README explicitly describes reusable templates/snippets. Projects can declare additional roots in
|
|
65
|
+
// `.weavatrix-deps.json` through `nonRuntimeRoots` / `templateRoots`.
|
|
66
|
+
export function collectNonRuntimeRoots(repoRoot, rules = {}) {
|
|
67
|
+
const files = listRepoFiles(repoRoot);
|
|
68
|
+
const roots = new Set();
|
|
69
|
+
const configured = [
|
|
70
|
+
rules.nonRuntimeRoots, rules.templateRoots,
|
|
71
|
+
rules.dependencies?.nonRuntimeRoots, rules.dependencies?.templateRoots,
|
|
72
|
+
].flatMap((value) => Array.isArray(value) ? value : typeof value === "string" ? [value] : []);
|
|
73
|
+
for (const value of configured) {
|
|
74
|
+
const root = normConfiguredRoot(value);
|
|
75
|
+
if (root) roots.add(root);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const parts = file.replace(/\\/g, "/").split("/");
|
|
80
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
81
|
+
if (NON_RUNTIME_DIR_RE.test(parts[i])) roots.add(parts.slice(0, i + 1).join("/"));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
if (!/^[^/]+\/README\.md$/i.test(file)) continue; // custom inference is deliberately top-level only
|
|
88
|
+
const text = readRepoText(boundary, file);
|
|
89
|
+
if (text != null && NON_RUNTIME_README_RE.test(text)) roots.add(normRoot(dirname(file)));
|
|
90
|
+
}
|
|
91
|
+
return [...roots].filter(Boolean).sort((a, b) => a.localeCompare(b));
|
|
51
92
|
}
|
|
52
93
|
|
|
53
94
|
export function collectSourceTexts(repoRoot, graph) {
|