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.
@@ -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 = 1;
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 parseUrlArgument(text, openParen) {
198
- let start = openParen + 1;
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([...DEFAULT_CLIENT_NAMES, ...(Array.isArray(values) ? values : [])]
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, { clientNames: options.clientNames, maxCalls: remaining });
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
- return { calls, truncated, filesScanned };
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
- if (!requested || !routeShapeMatches(pathSegments(normalizeHttpContractPath(endpoint.path)), pathSegments(requested))) return false;
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
- clients.push({ id, calls: detected.calls, reverse: reverseImports(descriptor.graph), filesScanned: detected.filesScanned });
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
- handler: /^[A-Za-z_$][\w$]{0,127}$/.test(String(endpoint.handler || "")) ? String(endpoint.handler) : null,
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 = 1
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
- for (const e of JS_EXTS) { const cand = (root + e).replace(/\/+/g, "/"); if (fileSet.has(cand)) return cand; }
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
- for (const e of JS_EXTS) { const cand = (base + e).replace(/\/+/g, "/"); if (fileSet.has(cand)) return cand; }
267
- return null;
282
+ return resolveJsBase(base);
268
283
  };
269
284
 
270
285
  const resolvePyPath = (baseDir, parts) => {