weavatrix 0.2.0 → 0.2.2
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 +104 -10
- package/package.json +5 -1
- package/skill/SKILL.md +34 -7
- package/src/analysis/change-classification.js +27 -2
- package/src/analysis/git-history.js +42 -0
- package/src/analysis/http-contract-wrappers.js +232 -0
- package/src/analysis/http-contracts.js +246 -17
- package/src/graph/edge-provenance.js +31 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +2 -2
- package/src/graph/internal-builder.build.js +4 -0
- package/src/graph/internal-builder.resolvers.js +18 -3
- package/src/mcp/catalog.mjs +50 -7
- package/src/mcp/graph-context.mjs +3 -0
- package/src/mcp/sync-payload.mjs +7 -0
- package/src/mcp/tool-result.mjs +9 -6
- package/src/mcp/tools-actions.mjs +4 -2
- package/src/mcp/tools-architecture.mjs +72 -16
- package/src/mcp/tools-company.mjs +11 -3
- package/src/mcp/tools-graph-hubs.mjs +22 -3
- package/src/mcp/tools-graph.mjs +4 -1
- package/src/mcp/tools-history.mjs +15 -9
- package/src/mcp/tools-impact-change.mjs +1 -1
- package/src/mcp/tools-impact.mjs +1 -0
- package/src/mcp-server.mjs +3 -1
- package/src/path-classification.js +5 -0
|
@@ -8,11 +8,17 @@ import { createPathClassifier, hasPathClass } from "../path-classification.js";
|
|
|
8
8
|
import { isWeavatrixIgnored, loadWeavatrixIgnore } from "../path-ignore.js";
|
|
9
9
|
import { createRepoBoundary } from "../repo-path.js";
|
|
10
10
|
import { safeRead } from "../util.js";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_HTTP_CLIENT_NAMES,
|
|
13
|
+
discoverHttpWrappers,
|
|
14
|
+
loadHttpContractConfig,
|
|
15
|
+
normalizeHttpClientNames,
|
|
16
|
+
normalizeHttpWrapperDescriptors,
|
|
17
|
+
} from "./http-contract-wrappers.js";
|
|
11
18
|
|
|
12
|
-
export const HTTP_CONTRACTS_V =
|
|
19
|
+
export const HTTP_CONTRACTS_V = 2;
|
|
13
20
|
|
|
14
21
|
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
22
|
const DEFAULTS = Object.freeze({
|
|
17
23
|
maxBackendFiles: 3_000,
|
|
18
24
|
maxClientFiles: 3_000,
|
|
@@ -148,7 +154,7 @@ function quotedArgument(text, start, quote) {
|
|
|
148
154
|
return null;
|
|
149
155
|
}
|
|
150
156
|
|
|
151
|
-
function templateArgument(text, start) {
|
|
157
|
+
function templateArgument(text, start, constants = null, requireStatic = false) {
|
|
152
158
|
let value = "";
|
|
153
159
|
let dynamicSegments = 0;
|
|
154
160
|
let unknownPrefix = false;
|
|
@@ -182,6 +188,13 @@ function templateArgument(text, start) {
|
|
|
182
188
|
cursor += 1;
|
|
183
189
|
}
|
|
184
190
|
if (depth !== 0) return null;
|
|
191
|
+
const expression = text.slice(index + 2, cursor - 1).trim();
|
|
192
|
+
if (/^[A-Za-z_$][\w$]*$/.test(expression) && constants?.has(expression)) {
|
|
193
|
+
value += constants.get(expression);
|
|
194
|
+
index = cursor - 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (requireStatic) return null;
|
|
185
198
|
const next = text[cursor];
|
|
186
199
|
const leftBoundary = value === "" || value.endsWith("/");
|
|
187
200
|
const rightBoundary = !next || next === "/" || next === "?" || next === "#" || next === "`";
|
|
@@ -194,8 +207,71 @@ function templateArgument(text, start) {
|
|
|
194
207
|
return null;
|
|
195
208
|
}
|
|
196
209
|
|
|
197
|
-
function
|
|
198
|
-
|
|
210
|
+
function extractStaticStringConstants(text) {
|
|
211
|
+
const source = String(text || "");
|
|
212
|
+
const mask = maskNonCode(source);
|
|
213
|
+
const declarations = [];
|
|
214
|
+
const declaration = /\bconst\s+([A-Za-z_$][\w$]*)\s*=/g;
|
|
215
|
+
let match;
|
|
216
|
+
while ((match = declaration.exec(mask)) && declarations.length < 500) {
|
|
217
|
+
let start = match.index + match[0].length;
|
|
218
|
+
while (/\s/.test(source[start] || "")) start += 1;
|
|
219
|
+
declarations.push({ name: match[1], start });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const constants = new Map();
|
|
223
|
+
for (const item of declarations) {
|
|
224
|
+
const quote = source[item.start];
|
|
225
|
+
if (quote !== "'" && quote !== '"') continue;
|
|
226
|
+
const parsed = quotedArgument(source, item.start, quote);
|
|
227
|
+
if (parsed && parsed.value.length <= 2_048) constants.set(item.name, parsed.value);
|
|
228
|
+
}
|
|
229
|
+
// Resolve bounded chains such as `const route = `${API_ROOT}/query`` without evaluating code.
|
|
230
|
+
for (let pass = 0; pass < Math.min(8, declarations.length); pass += 1) {
|
|
231
|
+
let changed = false;
|
|
232
|
+
for (const item of declarations) {
|
|
233
|
+
if (constants.has(item.name) || source[item.start] !== "`") continue;
|
|
234
|
+
const parsed = templateArgument(source, item.start, constants, true);
|
|
235
|
+
if (!parsed || parsed.value.length > 2_048) continue;
|
|
236
|
+
constants.set(item.name, parsed.value);
|
|
237
|
+
changed = true;
|
|
238
|
+
}
|
|
239
|
+
if (!changed) break;
|
|
240
|
+
}
|
|
241
|
+
return constants;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function argumentStart(text, openParen, target) {
|
|
245
|
+
let current = 0, round = 0, square = 0, curly = 0;
|
|
246
|
+
for (let index = openParen + 1; index < text.length; index += 1) {
|
|
247
|
+
const char = text[index];
|
|
248
|
+
if (current === target && !/\s|,/.test(char)) return index;
|
|
249
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
250
|
+
const quote = char;
|
|
251
|
+
index += 1;
|
|
252
|
+
while (index < text.length) {
|
|
253
|
+
if (text[index] === "\\") index += 2;
|
|
254
|
+
else if (text[index] === quote) break;
|
|
255
|
+
else index += 1;
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (char === "(" ) round += 1;
|
|
260
|
+
else if (char === "[" ) square += 1;
|
|
261
|
+
else if (char === "{" ) curly += 1;
|
|
262
|
+
else if (char === ")") {
|
|
263
|
+
if (round === 0 && square === 0 && curly === 0) return null;
|
|
264
|
+
round -= 1;
|
|
265
|
+
} else if (char === "]") square -= 1;
|
|
266
|
+
else if (char === "}") curly -= 1;
|
|
267
|
+
else if (char === "," && round === 0 && square === 0 && curly === 0) current += 1;
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseUrlArgument(text, openParen, constants, argument = 0) {
|
|
273
|
+
let start = argumentStart(text, openParen, argument);
|
|
274
|
+
if (start == null) return { path: null, endIndex: openParen, kind: "dynamic", dynamic: true, unknownPrefix: false, partialDynamic: true, reason: `URL argument ${argument} is missing` };
|
|
199
275
|
while (/\s/.test(text[start] || "")) start += 1;
|
|
200
276
|
const quote = text[start];
|
|
201
277
|
if (quote === "'" || quote === '"') {
|
|
@@ -204,7 +280,7 @@ function parseUrlArgument(text, openParen) {
|
|
|
204
280
|
return { path: normalizeHttpContractPath(parsed.value), endIndex: parsed.endIndex, kind: "literal", dynamic: false, unknownPrefix: false, partialDynamic: false, reason: null };
|
|
205
281
|
}
|
|
206
282
|
if (quote === "`") {
|
|
207
|
-
const parsed = templateArgument(text, start);
|
|
283
|
+
const parsed = templateArgument(text, start, constants);
|
|
208
284
|
if (!parsed) return { path: null, endIndex: start, kind: "dynamic", dynamic: true, reason: "unterminated URL template" };
|
|
209
285
|
return {
|
|
210
286
|
path: normalizeHttpContractPath(parsed.value),
|
|
@@ -231,21 +307,30 @@ function fetchMethod(text, argumentEnd) {
|
|
|
231
307
|
}
|
|
232
308
|
|
|
233
309
|
function normalizedClientNames(values) {
|
|
234
|
-
return new Set([...
|
|
310
|
+
return new Set([...DEFAULT_HTTP_CLIENT_NAMES, ...normalizeHttpClientNames(values)]
|
|
235
311
|
.map((value) => String(value || "").trim().toLowerCase())
|
|
236
312
|
.filter((value) => /^[a-z_$][\w$]*$/i.test(value)));
|
|
237
313
|
}
|
|
238
314
|
|
|
315
|
+
const escapeRegex = (value) => String(value).replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&");
|
|
316
|
+
|
|
239
317
|
export function extractHttpClientCallsFromText(text, file, options = {}) {
|
|
240
318
|
const source = String(text || "");
|
|
241
319
|
const mask = maskNonCode(source);
|
|
320
|
+
const constants = extractStaticStringConstants(source);
|
|
242
321
|
const allowed = normalizedClientNames(options.clientNames);
|
|
322
|
+
const wrappers = normalizeHttpWrapperDescriptors(options.wrappers, "input")
|
|
323
|
+
.concat((Array.isArray(options.normalizedWrappers) ? options.normalizedWrappers : []));
|
|
243
324
|
const maxCalls = boundedInteger(options.maxCalls, DEFAULTS.maxCallsPerClient, 1, HARD.maxCallsPerClient);
|
|
244
325
|
const calls = [];
|
|
326
|
+
const seen = new Set();
|
|
245
327
|
let truncated = false;
|
|
246
|
-
const add = (clientName, method, openParen, fetch = false) => {
|
|
328
|
+
const add = (clientName, method, openParen, fetch = false, urlArgument = 0, detector = "builtin", wrapper = null) => {
|
|
329
|
+
const key = `${openParen}\0${method}\0${urlArgument}`;
|
|
330
|
+
if (seen.has(key)) return;
|
|
331
|
+
seen.add(key);
|
|
247
332
|
if (calls.length >= maxCalls) { truncated = true; return; }
|
|
248
|
-
const parsed = parseUrlArgument(source, openParen);
|
|
333
|
+
const parsed = parseUrlArgument(source, openParen, constants, urlArgument);
|
|
249
334
|
const fetchInfo = fetch ? fetchMethod(source, parsed.endIndex) : { method: method.toUpperCase(), uncertain: false };
|
|
250
335
|
calls.push({
|
|
251
336
|
file: normalizeFile(file),
|
|
@@ -258,10 +343,12 @@ export function extractHttpClientCallsFromText(text, file, options = {}) {
|
|
|
258
343
|
unknownPrefix: Boolean(parsed.unknownPrefix),
|
|
259
344
|
partialDynamic: Boolean(parsed.partialDynamic || fetchInfo.uncertain),
|
|
260
345
|
reason: fetchInfo.uncertain ? "HTTP method is dynamic" : parsed.reason,
|
|
346
|
+
detector,
|
|
347
|
+
wrapper,
|
|
261
348
|
});
|
|
262
349
|
};
|
|
263
350
|
|
|
264
|
-
const member = /(^|[^\w$])([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*(get|post|put|patch|delete|head|options)\s*\(/gim;
|
|
351
|
+
const member = /(^|[^\w$])([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*(get|post|put|patch|delete|head|options)\s*(?:<[^>\n]{1,200}>)?\s*\(/gim;
|
|
265
352
|
let match;
|
|
266
353
|
while ((match = member.exec(mask))) {
|
|
267
354
|
if (!allowed.has(match[2].toLowerCase())) continue;
|
|
@@ -269,6 +356,24 @@ export function extractHttpClientCallsFromText(text, file, options = {}) {
|
|
|
269
356
|
}
|
|
270
357
|
const fetchCall = /(^|[^\w$])fetch\s*\(/gim;
|
|
271
358
|
while ((match = fetchCall.exec(mask))) add("fetch", "GET", fetchCall.lastIndex - 1, true);
|
|
359
|
+
for (const wrapper of wrappers) {
|
|
360
|
+
if (wrapper.allowedFiles instanceof Set && !wrapper.allowedFiles.has(normalizeFile(file))) continue;
|
|
361
|
+
if (wrapper.kind === "function") {
|
|
362
|
+
const bare = new RegExp(`(^|[^\\w$.?])${escapeRegex(wrapper.call)}\\s*(?:<[^>\\n]{1,200}>)?\\s*\\(`, "gim");
|
|
363
|
+
while ((match = bare.exec(mask))) {
|
|
364
|
+
const nameAt = bare.lastIndex - match[0].length + match[1].length;
|
|
365
|
+
if (/\bfunction\s*$/i.test(mask.slice(Math.max(0, nameAt - 30), nameAt))) continue;
|
|
366
|
+
add(wrapper.call, wrapper.method, bare.lastIndex - 1, false, wrapper.urlArgument, `${wrapper.source}-wrapper`, {
|
|
367
|
+
kind: wrapper.kind, call: wrapper.call, definitionFile: wrapper.definitionFile || null,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
} else if (wrapper.kind === "member") {
|
|
371
|
+
const memberCall = new RegExp(`(^|[^\\w$])${escapeRegex(wrapper.object)}\\s*(?:\\?\\.|\\.)\\s*${escapeRegex(wrapper.member)}\\s*(?:<[^>\\n]{1,200}>)?\\s*\\(`, "gim");
|
|
372
|
+
while ((match = memberCall.exec(mask))) add(`${wrapper.object}.${wrapper.member}`, wrapper.method, memberCall.lastIndex - 1, false, wrapper.urlArgument, `${wrapper.source}-wrapper`, {
|
|
373
|
+
kind: wrapper.kind, object: wrapper.object, member: wrapper.member, definitionFile: wrapper.definitionFile || null,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
272
377
|
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
378
|
return { calls, truncated };
|
|
274
379
|
}
|
|
@@ -279,7 +384,7 @@ function filesFromGraph(graph) {
|
|
|
279
384
|
|
|
280
385
|
export function detectHttpClientCalls(repoRoot, codeFiles, options = {}) {
|
|
281
386
|
const boundary = createRepoBoundary(repoRoot);
|
|
282
|
-
if (!boundary.root) return { calls: [], truncated: false, filesScanned: 0 };
|
|
387
|
+
if (!boundary.root) return { calls: [], truncated: false, filesScanned: 0, discovery: { enabled: false, configured: 0, discovered: 0, ambiguous: [] }, reasons: [] };
|
|
283
388
|
const maxFiles = boundedInteger(options.maxFiles, DEFAULTS.maxClientFiles, 1, HARD.maxClientFiles);
|
|
284
389
|
const maxCalls = boundedInteger(options.maxCalls, DEFAULTS.maxCallsPerClient, 1, HARD.maxCallsPerClient);
|
|
285
390
|
const ignoreRules = loadWeavatrixIgnore(boundary.root);
|
|
@@ -287,6 +392,7 @@ export function detectHttpClientCalls(repoRoot, codeFiles, options = {}) {
|
|
|
287
392
|
const candidates = [...new Set((codeFiles || []).map((entry) => normalizeFile(entry?.path || entry)).filter(Boolean))].sort();
|
|
288
393
|
let truncated = candidates.length > maxFiles;
|
|
289
394
|
let filesScanned = 0;
|
|
395
|
+
const sources = [];
|
|
290
396
|
const calls = [];
|
|
291
397
|
for (const file of candidates.slice(0, maxFiles)) {
|
|
292
398
|
if (!/\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file) || isWeavatrixIgnored(file, ignoreRules)) continue;
|
|
@@ -297,14 +403,53 @@ export function detectHttpClientCalls(repoRoot, codeFiles, options = {}) {
|
|
|
297
403
|
const text = safeRead(resolved.path);
|
|
298
404
|
if (!text) continue;
|
|
299
405
|
filesScanned += 1;
|
|
406
|
+
sources.push({ file, text });
|
|
407
|
+
}
|
|
408
|
+
const config = loadHttpContractConfig(boundary.root);
|
|
409
|
+
const clientNames = [...new Set([
|
|
410
|
+
...normalizeHttpClientNames(options.clientNames),
|
|
411
|
+
...config.clientNames,
|
|
412
|
+
])];
|
|
413
|
+
const configured = [
|
|
414
|
+
...normalizeHttpWrapperDescriptors(options.wrappers, "input"),
|
|
415
|
+
...config.wrappers,
|
|
416
|
+
];
|
|
417
|
+
const discoveryEnabled = options.autoDiscoverWrappers !== false && config.autoDiscoverWrappers !== false;
|
|
418
|
+
const discovered = discoveryEnabled ? discoverHttpWrappers(sources, clientNames) : { wrappers: [], ambiguous: [], truncated: false };
|
|
419
|
+
const scopedDiscovered = discovered.wrappers.map((wrapper) => ({
|
|
420
|
+
...wrapper,
|
|
421
|
+
allowedFiles: wrapperScopeFiles(wrapper.definitionFile, options.graph),
|
|
422
|
+
}));
|
|
423
|
+
for (const { file, text } of sources) {
|
|
300
424
|
const remaining = maxCalls - calls.length;
|
|
301
425
|
if (remaining <= 0) { truncated = true; break; }
|
|
302
|
-
const extracted = extractHttpClientCallsFromText(text, file, {
|
|
426
|
+
const extracted = extractHttpClientCallsFromText(text, file, {
|
|
427
|
+
clientNames,
|
|
428
|
+
normalizedWrappers: [...configured, ...scopedDiscovered],
|
|
429
|
+
maxCalls: remaining,
|
|
430
|
+
});
|
|
303
431
|
calls.push(...extracted.calls);
|
|
304
432
|
if (extracted.truncated) truncated = true;
|
|
305
433
|
}
|
|
306
434
|
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
|
-
|
|
435
|
+
const reasons = [];
|
|
436
|
+
if (config.error) reasons.push(`HTTP contract config ${config.error}`);
|
|
437
|
+
reasons.push(...(config.warnings || []));
|
|
438
|
+
if (discovered.truncated) reasons.push("auto-discovered wrapper cap reached");
|
|
439
|
+
if (discovered.ambiguous.length) reasons.push(`${discovered.ambiguous.length} ambiguous auto-discovered wrapper name(s) skipped`);
|
|
440
|
+
return {
|
|
441
|
+
calls,
|
|
442
|
+
truncated,
|
|
443
|
+
filesScanned,
|
|
444
|
+
discovery: {
|
|
445
|
+
enabled: discoveryEnabled,
|
|
446
|
+
configured: configured.length,
|
|
447
|
+
discovered: scopedDiscovered.length,
|
|
448
|
+
ambiguous: discovered.ambiguous,
|
|
449
|
+
truncated: discovered.truncated,
|
|
450
|
+
},
|
|
451
|
+
reasons,
|
|
452
|
+
};
|
|
308
453
|
}
|
|
309
454
|
|
|
310
455
|
function pathSegments(path) {
|
|
@@ -334,6 +479,14 @@ function suffixShapeMatch(endpointSegments, callSegments) {
|
|
|
334
479
|
return routeShapeMatches(endpointSegments.slice(endpointSegments.length - callSegments.length), callSegments);
|
|
335
480
|
}
|
|
336
481
|
|
|
482
|
+
function routeShapeContains(endpointSegments, requestedSegments) {
|
|
483
|
+
if (!requestedSegments.length || requestedSegments.length > endpointSegments.length) return false;
|
|
484
|
+
for (let start = 0; start <= endpointSegments.length - requestedSegments.length; start += 1) {
|
|
485
|
+
if (routeShapeMatches(endpointSegments.slice(start, start + requestedSegments.length), requestedSegments)) return true;
|
|
486
|
+
}
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
|
|
337
490
|
function methodMatches(endpointMethod, callMethod) {
|
|
338
491
|
return endpointMethod === "ANY" || endpointMethod === "ALL" || endpointMethod === callMethod;
|
|
339
492
|
}
|
|
@@ -384,6 +537,24 @@ function reverseImports(graph = {}) {
|
|
|
384
537
|
return reverse;
|
|
385
538
|
}
|
|
386
539
|
|
|
540
|
+
function wrapperScopeFiles(sourceFile, graph) {
|
|
541
|
+
const source = normalizeFile(sourceFile);
|
|
542
|
+
if (!source || !Array.isArray(graph?.nodes)) return null;
|
|
543
|
+
const reverse = reverseImports(graph);
|
|
544
|
+
const allowed = new Set([source]);
|
|
545
|
+
const queue = [{ file: source, depth: 0 }];
|
|
546
|
+
while (queue.length && allowed.size < 2_000) {
|
|
547
|
+
const current = queue.shift();
|
|
548
|
+
if (current.depth >= 4) continue;
|
|
549
|
+
for (const importer of [...(reverse.get(current.file) || [])].sort()) {
|
|
550
|
+
if (allowed.has(importer)) continue;
|
|
551
|
+
allowed.add(importer);
|
|
552
|
+
queue.push({ file: importer, depth: current.depth + 1 });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return allowed;
|
|
556
|
+
}
|
|
557
|
+
|
|
387
558
|
function isScreen(file) {
|
|
388
559
|
const path = normalizeFile(file);
|
|
389
560
|
const base = path.split("/").at(-1) || "";
|
|
@@ -450,7 +621,9 @@ function endpointFilter(endpoint, backendId, options) {
|
|
|
450
621
|
if (options.method && endpoint.method !== options.method && endpoint.method !== "ANY" && endpoint.method !== "ALL") return false;
|
|
451
622
|
if (options.path) {
|
|
452
623
|
const requested = normalizeHttpContractPath(options.path);
|
|
453
|
-
|
|
624
|
+
const endpointSegments = pathSegments(normalizeHttpContractPath(endpoint.path));
|
|
625
|
+
const requestedSegments = pathSegments(requested);
|
|
626
|
+
if (!requested || (!routeShapeMatches(endpointSegments, requestedSegments) && !routeShapeContains(endpointSegments, requestedSegments))) return false;
|
|
454
627
|
}
|
|
455
628
|
if (options.changedFiles?.size) {
|
|
456
629
|
const file = normalizeFile(endpoint.file);
|
|
@@ -459,6 +632,43 @@ function endpointFilter(endpoint, backendId, options) {
|
|
|
459
632
|
return true;
|
|
460
633
|
}
|
|
461
634
|
|
|
635
|
+
function bareGraphLabel(value) {
|
|
636
|
+
return String(value || "").replace(/\s*\(.*$/, "").replace(/[()]/g, "").trim();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function handlerNodeEvidence(endpoint, graph) {
|
|
640
|
+
const handler = /^[A-Za-z_$][\w$]{0,127}$/.test(String(endpoint?.handler || "")) ? String(endpoint.handler) : null;
|
|
641
|
+
if (!handler) return { handler: null, handlerNodeId: null, handlerResolution: "inline-or-unresolved" };
|
|
642
|
+
const matches = (graph?.nodes || []).filter((node) =>
|
|
643
|
+
normalizeFile(node?.source_file) && bareGraphLabel(node?.label) === handler && String(node?.id || "") !== normalizeFile(node?.source_file));
|
|
644
|
+
const sameFile = matches.filter((node) => normalizeFile(node?.source_file) === normalizeFile(endpoint.file));
|
|
645
|
+
const resolved = sameFile.length === 1 ? sameFile[0] : matches.length === 1 ? matches[0] : null;
|
|
646
|
+
return {
|
|
647
|
+
handler,
|
|
648
|
+
handlerNodeId: resolved ? String(resolved.id) : null,
|
|
649
|
+
handlerResolution: resolved ? "resolved" : matches.length > 1 ? "ambiguous" : "unresolved",
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function externalUseLiveness(callsites, handlerEvidence) {
|
|
654
|
+
const proven = callsites.filter((call) => call.match?.confidence === "high" || call.match?.confidence === "medium");
|
|
655
|
+
const possible = callsites.filter((call) => call.match?.confidence === "low");
|
|
656
|
+
const status = proven.length ? "NOT_DEAD_EXTERNAL_USE" : possible.length ? "POSSIBLE_EXTERNAL_USE" : "UNKNOWN";
|
|
657
|
+
const evidence = proven.length ? proven : possible;
|
|
658
|
+
return {
|
|
659
|
+
status,
|
|
660
|
+
subject: handlerEvidence.handlerNodeId ? "handler-node" : handlerEvidence.handler ? "endpoint-handler" : "endpoint",
|
|
661
|
+
canSuppressDeadCandidate: proven.length > 0 && Boolean(handlerEvidence.handlerNodeId),
|
|
662
|
+
staticEvidence: evidence.length,
|
|
663
|
+
consumerRepositories: [...new Set(evidence.map((call) => call.clientRepo))].sort(),
|
|
664
|
+
reason: proven.length
|
|
665
|
+
? "At least one selected external repository has a medium/high-confidence static HTTP contract match."
|
|
666
|
+
: possible.length
|
|
667
|
+
? "Only low-confidence external HTTP contract matches were found; review before suppressing a dead-code candidate."
|
|
668
|
+
: "No selected external repository proved a static caller; absence of evidence is not a dead-code verdict.",
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
462
672
|
// `backends` and `clients` are arrays of {id, repoRoot, codeFiles?, graph?}. Backends may optionally
|
|
463
673
|
// supply a precomputed `endpoints` array; otherwise the shared multi-language endpoint detector is used.
|
|
464
674
|
export function analyzeHttpContracts(input = {}) {
|
|
@@ -483,7 +693,7 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
483
693
|
if (filtered.length > endpointBudget) completeness.push(`${id}: endpoint cap reached`);
|
|
484
694
|
const accepted = filtered.slice(0, endpointBudget);
|
|
485
695
|
endpointBudget -= accepted.length;
|
|
486
|
-
backends.push({ id, endpoints: accepted });
|
|
696
|
+
backends.push({ id, endpoints: accepted, graph: descriptor.graph });
|
|
487
697
|
}
|
|
488
698
|
|
|
489
699
|
const clients = [];
|
|
@@ -494,10 +704,20 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
494
704
|
maxFiles: limits.maxClientFiles,
|
|
495
705
|
maxCalls: limits.maxCallsPerClient,
|
|
496
706
|
clientNames: descriptor.clientNames || input.clientNames,
|
|
707
|
+
wrappers: descriptor.wrappers || input.wrappers,
|
|
708
|
+
autoDiscoverWrappers: descriptor.autoDiscoverWrappers ?? input.autoDiscoverWrappers,
|
|
709
|
+
graph: descriptor.graph,
|
|
497
710
|
includeTests: descriptor.includeTests ?? input.includeTests,
|
|
498
711
|
});
|
|
499
712
|
if (detected.truncated) completeness.push(`${id}: client scan cap reached`);
|
|
500
|
-
|
|
713
|
+
for (const reason of detected.reasons || []) completeness.push(`${id}: ${reason}`);
|
|
714
|
+
clients.push({
|
|
715
|
+
id,
|
|
716
|
+
calls: detected.calls,
|
|
717
|
+
reverse: reverseImports(descriptor.graph),
|
|
718
|
+
filesScanned: detected.filesScanned,
|
|
719
|
+
wrapperDiscovery: detected.discovery,
|
|
720
|
+
});
|
|
501
721
|
}
|
|
502
722
|
|
|
503
723
|
const results = [];
|
|
@@ -529,20 +749,24 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
529
749
|
method: call.method,
|
|
530
750
|
path: call.path,
|
|
531
751
|
dynamic: call.dynamic,
|
|
752
|
+
detector: call.detector,
|
|
753
|
+
wrapper: call.wrapper,
|
|
532
754
|
match,
|
|
533
755
|
});
|
|
534
756
|
}
|
|
535
757
|
}
|
|
536
758
|
callsites.sort((left, right) => right.match.score - left.match.score || left.clientRepo.localeCompare(right.clientRepo) || left.file.localeCompare(right.file) || left.line - right.line);
|
|
759
|
+
const handlerEvidence = handlerNodeEvidence(endpoint, backend.graph);
|
|
537
760
|
results.push({
|
|
538
761
|
backend: backend.id,
|
|
539
762
|
method: endpoint.method,
|
|
540
763
|
path: endpoint.path,
|
|
541
764
|
normalizedPath: normalizeHttpContractPath(endpoint.path),
|
|
542
|
-
|
|
765
|
+
...handlerEvidence,
|
|
543
766
|
file: normalizeFile(endpoint.file) || null,
|
|
544
767
|
line: Number(endpoint.line) || null,
|
|
545
768
|
callsites,
|
|
769
|
+
liveness: externalUseLiveness(callsites, handlerEvidence),
|
|
546
770
|
affected: affectedForEndpoint(callsites, clients, limits),
|
|
547
771
|
});
|
|
548
772
|
}
|
|
@@ -574,7 +798,12 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
574
798
|
matches,
|
|
575
799
|
methodMismatches,
|
|
576
800
|
uncertainCalls: uncertainAll.length,
|
|
801
|
+
notDeadExternalUse: results.filter((endpoint) => endpoint.liveness.status === "NOT_DEAD_EXTERNAL_USE").length,
|
|
802
|
+
notDeadExternalHandlers: results.filter((endpoint) => endpoint.liveness.canSuppressDeadCandidate).length,
|
|
803
|
+
possibleExternalUse: results.filter((endpoint) => endpoint.liveness.status === "POSSIBLE_EXTERNAL_USE").length,
|
|
804
|
+
unknownLiveness: results.filter((endpoint) => endpoint.liveness.status === "UNKNOWN").length,
|
|
577
805
|
},
|
|
806
|
+
wrapperDiscovery: clients.map((client) => ({ clientRepo: client.id, ...client.wrapperDiscovery })),
|
|
578
807
|
endpoints: results,
|
|
579
808
|
uncertain: uncertainAll.slice(0, limits.maxUncertain),
|
|
580
809
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Versioned origin classification for graph edges. `confidence` remains a compatibility field;
|
|
2
|
+
// provenance says how an edge was established and leaves room for a bounded LSP precision overlay.
|
|
3
|
+
export const EDGE_PROVENANCE_V = 1;
|
|
4
|
+
export const EDGE_PROVENANCE_KINDS = Object.freeze([
|
|
5
|
+
"EXACT_LSP", "EXTRACTED", "RESOLVED", "INFERRED", "CONFLICT",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const ALLOWED = new Set(EDGE_PROVENANCE_KINDS);
|
|
9
|
+
|
|
10
|
+
export function edgeProvenance(edge) {
|
|
11
|
+
const explicit = String(edge?.provenance || "").trim().toUpperCase();
|
|
12
|
+
if (ALLOWED.has(explicit)) return explicit;
|
|
13
|
+
if (edge?.semanticOrigin === true) return "RESOLVED";
|
|
14
|
+
const legacy = String(edge?.confidence || "").trim().toUpperCase();
|
|
15
|
+
return ALLOWED.has(legacy) ? legacy : "UNKNOWN";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stampEdgeProvenance(links) {
|
|
19
|
+
for (const link of Array.isArray(links) ? links : []) {
|
|
20
|
+
const provenance = edgeProvenance(link);
|
|
21
|
+
if (provenance !== "UNKNOWN") link.provenance = provenance;
|
|
22
|
+
}
|
|
23
|
+
return links;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function summarizeEdgeProvenance(links) {
|
|
27
|
+
const counts = Object.fromEntries([...EDGE_PROVENANCE_KINDS, "UNKNOWN"].map((kind) => [kind, 0]));
|
|
28
|
+
for (const link of Array.isArray(links) ? links : []) counts[edgeProvenance(link)] += 1;
|
|
29
|
+
const total = Array.isArray(links) ? links.length : 0;
|
|
30
|
+
return {version: EDGE_PROVENANCE_V, total, classified: total - counts.UNKNOWN, complete: counts.UNKNOWN === 0, counts};
|
|
31
|
+
}
|
|
@@ -9,7 +9,7 @@ import {childProcessEnv} from '../child-env.js'
|
|
|
9
9
|
import {createRepoBoundary} from '../repo-path.js'
|
|
10
10
|
|
|
11
11
|
export const REPOSITORY_FRESHNESS_PROBE_V = 1
|
|
12
|
-
export const GRAPH_BUILDER_SCHEMA_V =
|
|
12
|
+
export const GRAPH_BUILDER_SCHEMA_V = 2
|
|
13
13
|
export const GRAPH_BUILDER_VERSION = (() => {
|
|
14
14
|
try { return String(createRequire(import.meta.url)('../../package.json').version) }
|
|
15
15
|
catch { return '0.0.0' }
|
|
@@ -21,6 +21,7 @@ export const GRAPH_BUILDER_VERSION = (() => {
|
|
|
21
21
|
const CURRENT_GRAPH_SCHEMA = Object.freeze({
|
|
22
22
|
extImportsV: 2,
|
|
23
23
|
edgeTypesV: 2,
|
|
24
|
+
edgeProvenanceV: 1,
|
|
24
25
|
complexityV: 1,
|
|
25
26
|
repoBoundaryV: 1,
|
|
26
27
|
barrelResolutionV: 1,
|
|
@@ -162,7 +162,7 @@ function mergeScopedGraph(base, scoped, affected, snapshot) {
|
|
|
162
162
|
controlHashes: snapshot.controlHashes,
|
|
163
163
|
graphRevision: snapshot.revision,
|
|
164
164
|
};
|
|
165
|
-
for (const key of ["extImportsV", "edgeTypesV", "complexityV", "repoBoundaryV", "barrelResolutionV", "extractorSchemaV"]) {
|
|
165
|
+
for (const key of ["extImportsV", "edgeTypesV", "edgeProvenanceV", "complexityV", "repoBoundaryV", "barrelResolutionV", "extractorSchemaV"]) {
|
|
166
166
|
merged[key] = Math.max(Number(base[key]) || 0, Number(scoped[key]) || 0);
|
|
167
167
|
}
|
|
168
168
|
return merged;
|
|
@@ -183,7 +183,7 @@ export async function refreshGraphIncrementally(repoDir, existingGraph, {
|
|
|
183
183
|
|
|
184
184
|
if (!existingGraph || !existingGraph.fileHashes || !existingGraph.fileExportSignatures
|
|
185
185
|
|| !existingGraph.controlHashes || !existingGraph.jsExportRecords || Number(existingGraph.barrelResolutionV) < 1
|
|
186
|
-
|| Number(existingGraph.extractorSchemaV) < 1) {
|
|
186
|
+
|| Number(existingGraph.extractorSchemaV) < 1 || Number(existingGraph.edgeProvenanceV) < 1) {
|
|
187
187
|
return full("incremental-baseline-unavailable");
|
|
188
188
|
}
|
|
189
189
|
if (!sameRecord(existingGraph.controlHashes, snapshot.controlHashes)) return full("ignore-or-control-config-changed");
|
|
@@ -12,6 +12,7 @@ import { addJavaReferences } from "./internal-builder.java.js";
|
|
|
12
12
|
import { assignDeterministicCommunities } from "./community.js";
|
|
13
13
|
import { resolveJsBarrels } from "./internal-builder.barrels.js";
|
|
14
14
|
import { snapshotRepository } from "./incremental-refresh.js";
|
|
15
|
+
import { EDGE_PROVENANCE_V, stampEdgeProvenance } from "./edge-provenance.js";
|
|
15
16
|
|
|
16
17
|
// Parse a repo directory into a graph-builder-compatible { nodes, links } graph.
|
|
17
18
|
export async function buildInternalGraph(repoDir, opts = {}) {
|
|
@@ -128,6 +129,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
128
129
|
target: tgt,
|
|
129
130
|
relation: meta.relation || "imports",
|
|
130
131
|
confidence: "EXTRACTED",
|
|
132
|
+
provenance: meta.provenance || "RESOLVED",
|
|
131
133
|
...(typeof meta.typeOnly === "boolean" ? { typeOnly: meta.typeOnly } : {}),
|
|
132
134
|
...(meta.compileOnly === true ? { compileOnly: true } : {}),
|
|
133
135
|
...(meta.line ? { line: meta.line } : {}),
|
|
@@ -317,6 +319,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
317
319
|
// community = folder bucket (top 2 path parts) — deterministic, mirrors the folder-based module grouping the
|
|
318
320
|
// app already uses (graph-builder-analysis.js). Populates Modules/community cards without a heavy clustering pass.
|
|
319
321
|
assignDeterministicCommunities(nodes);
|
|
322
|
+
stampEdgeProvenance(links);
|
|
320
323
|
|
|
321
324
|
// extImportsV: bump when the externalImports schema/coverage changes (v2 = go/python ecosystems) —
|
|
322
325
|
// deps-engine rebuilds in memory when a saved graph is older than this.
|
|
@@ -328,6 +331,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
328
331
|
externalImports,
|
|
329
332
|
extImportsV: 2,
|
|
330
333
|
edgeTypesV: 2,
|
|
334
|
+
edgeProvenanceV: EDGE_PROVENANCE_V,
|
|
331
335
|
complexityV: 1,
|
|
332
336
|
repoBoundaryV: 1,
|
|
333
337
|
barrelResolutionV: 1,
|
|
@@ -248,6 +248,21 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
248
248
|
};
|
|
249
249
|
|
|
250
250
|
const JS_EXTS = ["", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".json", "/index.js", "/index.ts", "/index.jsx", "/index.tsx"];
|
|
251
|
+
const resolveJsBase = (base) => {
|
|
252
|
+
for (const extension of JS_EXTS) {
|
|
253
|
+
const candidate = (base + extension).replace(/\/+/g, "/");
|
|
254
|
+
if (fileSet.has(candidate)) return candidate;
|
|
255
|
+
}
|
|
256
|
+
// TypeScript NodeNext source commonly imports the emitted extension (`./http.js`) while the
|
|
257
|
+
// repository contains `http.ts`/`http.tsx`. Exact runtime files win above; a source counterpart
|
|
258
|
+
// is accepted only when unique so same-basename ambiguity is never guessed.
|
|
259
|
+
const sourceCandidates = [];
|
|
260
|
+
if (/\.js$/i.test(base)) sourceCandidates.push(base.replace(/\.js$/i, ".ts"), base.replace(/\.js$/i, ".tsx"));
|
|
261
|
+
else if (/\.jsx$/i.test(base)) sourceCandidates.push(base.replace(/\.jsx$/i, ".tsx"));
|
|
262
|
+
const existing = [...new Set(sourceCandidates.map((candidate) => candidate.replace(/\/+/g, "/")))]
|
|
263
|
+
.filter((candidate) => fileSet.has(candidate));
|
|
264
|
+
return existing.length === 1 ? existing[0] : null;
|
|
265
|
+
};
|
|
251
266
|
const resolveJsImport = (fromRel, spec) => {
|
|
252
267
|
if (!spec) return null;
|
|
253
268
|
let base;
|
|
@@ -258,13 +273,13 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
258
273
|
// baseUrl-rooted internal import ("components/Button" with baseUrl:"src") — try before calling it an npm package
|
|
259
274
|
for (const ctx of contextsForFile(fromRel)) for (const b of ctx.baseUrls) {
|
|
260
275
|
const root = (b ? b + "/" : "") + spec;
|
|
261
|
-
|
|
276
|
+
const resolved = resolveJsBase(root);
|
|
277
|
+
if (resolved) return resolved;
|
|
262
278
|
}
|
|
263
279
|
return null; // genuinely bare → npm package (stays unresolved here)
|
|
264
280
|
}
|
|
265
281
|
}
|
|
266
|
-
|
|
267
|
-
return null;
|
|
282
|
+
return resolveJsBase(base);
|
|
268
283
|
};
|
|
269
284
|
|
|
270
285
|
const resolvePyPath = (baseDir, parts) => {
|