weavatrix 0.1.4 → 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.
Files changed (70) hide show
  1. package/README.md +138 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +92 -30
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +549 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +581 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +62 -19
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +51 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +144 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph.mjs +25 -14
  62. package/src/mcp/tools-health.mjs +336 -14
  63. package/src/mcp/tools-history.mjs +22 -0
  64. package/src/mcp/tools-impact-change.mjs +261 -0
  65. package/src/mcp/tools-impact.mjs +25 -6
  66. package/src/mcp-server.mjs +100 -17
  67. package/src/mcp-source-tools.mjs +11 -3
  68. package/src/path-classification.js +201 -0
  69. package/src/path-ignore.js +69 -0
  70. 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,7 @@ export function listRepoFiles(repoRoot) {
47
48
  }
48
49
  };
49
50
  walk(repoRoot);
50
- return files;
51
+ return filterWeavatrixIgnored(repoRoot, files);
51
52
  }
52
53
 
53
54
  const NON_RUNTIME_DIR_RE = /^(?:templates?|examples?|samples?|fixtures?|snippets?|__fixtures__)$/i;
@@ -3,13 +3,60 @@
3
3
  import { posix } from "node:path";
4
4
  import { ENTRY_FILE, isFrameworkEntryFile } from "./dead-check.js";
5
5
  import { TEST_FILE_RE } from "./internal-audit.collect.js";
6
+ import { maskJavaNonCode } from "./java-source.js";
6
7
 
7
8
  const isFileNode = (n) => !String(n.id).includes("#");
8
9
 
10
+ const springAnnotation = (name) => new RegExp(`@(?:[A-Za-z_$][\\w$]*\\s*\\.\\s*)*${name}\\b`);
11
+ const SPRING_CONVENTIONS = [
12
+ ["SpringBootApplication", "high", "Spring Boot application entry point is launched externally and starts component scanning"],
13
+ ["RestControllerAdvice", "high", "Spring registers @RestControllerAdvice through application-context scanning"],
14
+ ["ControllerAdvice", "high", "Spring registers @ControllerAdvice through application-context scanning"],
15
+ ["RestController", "high", "Spring registers @RestController through component scanning and routes requests to it"],
16
+ ["Controller", "high", "Spring registers @Controller through component scanning"],
17
+ ["Configuration", "high", "Spring discovers @Configuration and invokes its bean definitions externally"],
18
+ ["Service", "high", "Spring registers @Service through component scanning"],
19
+ ["Repository", "high", "Spring registers @Repository through component scanning or repository proxy creation"],
20
+ ["Component", "high", "Spring registers @Component through component scanning"],
21
+ ["ConfigurationProperties", "high", "Spring binds @ConfigurationProperties outside the static import graph"],
22
+ ["KafkaListener", "high", "Spring Kafka invokes @KafkaListener methods from the message container"],
23
+ ["Scheduled", "high", "Spring invokes @Scheduled methods from its task scheduler"],
24
+ ["EventListener", "high", "Spring invokes @EventListener methods from the application event bus"],
25
+ ];
26
+
27
+ // Framework-managed Java files are externally entered by the Spring container, not by an ordinary
28
+ // source import. Return bounded, explainable evidence so suppressing an orphan is never a silent guess.
29
+ export function springConventionEntries(sources, fileSet = null) {
30
+ const out = [];
31
+ for (const [rawFile, rawText] of sources || []) {
32
+ const file = String(rawFile || "").replace(/\\/g, "/");
33
+ if (!/\.java$/i.test(file) || (fileSet && !fileSet.has(file))) continue;
34
+ const text = maskJavaNonCode(rawText);
35
+ let evidence = null;
36
+ for (const [marker, confidence, reason] of SPRING_CONVENTIONS) {
37
+ if (springAnnotation(marker).test(text)) {
38
+ evidence = { file, framework: "spring", marker: `@${marker}`, confidence, reason };
39
+ break;
40
+ }
41
+ }
42
+ if (!evidence && /\binterface\s+[A-Za-z_$][\w$]*(?:\s*<[^>{}]+>)?\s+extends\s+[^;{]*(?:JpaRepository|MongoRepository|ReactiveCrudRepository|CrudRepository|PagingAndSortingRepository|Repository)\s*</s.test(text)) {
43
+ evidence = {
44
+ file,
45
+ framework: "spring-data",
46
+ marker: "repository interface",
47
+ confidence: "high",
48
+ reason: "Spring Data creates the repository implementation and proxy from the interface at runtime",
49
+ };
50
+ }
51
+ if (evidence) out.push(evidence);
52
+ }
53
+ return out.sort((a, b) => a.file.localeCompare(b.file));
54
+ }
55
+
9
56
  // Entry set for reachability: conventional entry names + package.json main/module/browser/bin/exports +
10
57
  // html pages (they root classic-script apps) + test files (the runner enters them) + root config files +
11
58
  // dynamic-import targets. Anything reachable from here is "used"; the rest corroborates unused-file.
12
- export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map() } = {}) {
59
+ export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map(), conventionEvidence = [] } = {}) {
13
60
  const entries = new Set();
14
61
  const scopes = Array.isArray(pkgOrScopes)
15
62
  ? pkgOrScopes
@@ -51,6 +98,10 @@ export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { dec
51
98
  const f = n.source_file;
52
99
  if (ENTRY_FILE.test(f) || isFrameworkEntryFile(f) || /\.d\.[cm]?ts$/i.test(f) || TEST_FILE_RE.test(f) || /\.html?$/i.test(f) || pe.has(f) || /(^|\/)[^/]*\.config\.[a-z]+$/i.test(f)) entries.add(f);
53
100
  }
101
+ for (const evidence of springConventionEntries(sources, fileSet)) {
102
+ entries.add(evidence.file);
103
+ conventionEvidence.push(evidence);
104
+ }
54
105
  for (const raw of Array.isArray(declaredEntries) ? declaredEntries : [declaredEntries]) {
55
106
  const resolved = resolveEntry("", raw);
56
107
  if (resolved && fileSet.has(resolved)) entries.add(resolved);