weavatrix 0.2.12 → 0.2.14

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 (132) hide show
  1. package/README.md +90 -21
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.14.md +93 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +108 -43
  6. package/src/analysis/allowed-test-runner.js +20 -9
  7. package/src/analysis/architecture/contract-graph.js +119 -0
  8. package/src/analysis/architecture/contract-schema.js +110 -0
  9. package/src/analysis/architecture/contract-storage.js +35 -0
  10. package/src/analysis/architecture/contract-verification.js +168 -0
  11. package/src/analysis/architecture-contract.js +16 -343
  12. package/src/analysis/change-classification/diff-parser.js +103 -0
  13. package/src/analysis/change-classification/options.js +40 -0
  14. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  15. package/src/analysis/change-classification.js +120 -519
  16. package/src/analysis/dead-code-review/policy.js +23 -0
  17. package/src/analysis/dead-code-review.js +9 -19
  18. package/src/analysis/dep-check.js +10 -57
  19. package/src/analysis/dep-rules.js +10 -303
  20. package/src/analysis/dependency/scoped-dependencies.js +40 -0
  21. package/src/analysis/endpoints/common.js +62 -0
  22. package/src/analysis/endpoints/extract.js +107 -0
  23. package/src/analysis/endpoints/inventory.js +84 -0
  24. package/src/analysis/endpoints/mounts.js +129 -0
  25. package/src/analysis/endpoints-java.js +80 -3
  26. package/src/analysis/endpoints.js +3 -448
  27. package/src/analysis/git-history/analytics.js +177 -0
  28. package/src/analysis/git-history/collector.js +109 -0
  29. package/src/analysis/git-history/options.js +68 -0
  30. package/src/analysis/git-history.js +128 -577
  31. package/src/analysis/health-capabilities.js +82 -0
  32. package/src/analysis/http-contracts/analysis.js +169 -0
  33. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  34. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  35. package/src/analysis/http-contracts/graph-context.js +143 -0
  36. package/src/analysis/http-contracts/matching.js +61 -0
  37. package/src/analysis/http-contracts/shared.js +86 -0
  38. package/src/analysis/http-contracts.js +6 -822
  39. package/src/analysis/internal-audit/dependency-health.js +111 -0
  40. package/src/analysis/internal-audit/python-manifests.js +65 -0
  41. package/src/analysis/internal-audit/repo-files.js +55 -0
  42. package/src/analysis/internal-audit/supply-chain.js +132 -0
  43. package/src/analysis/internal-audit.collect.js +5 -106
  44. package/src/analysis/internal-audit.run.js +113 -200
  45. package/src/analysis/jvm-dependency-evidence.js +69 -0
  46. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  47. package/src/analysis/source-correctness.js +225 -0
  48. package/src/analysis/structure/dependency-graph.js +156 -0
  49. package/src/analysis/structure/findings.js +142 -0
  50. package/src/graph/builder/go-receiver-resolution.js +112 -0
  51. package/src/graph/builder/js/queries.js +48 -0
  52. package/src/graph/builder/lang-go.js +89 -4
  53. package/src/graph/builder/lang-js.js +4 -44
  54. package/src/graph/builder/pass2-resolution.js +68 -0
  55. package/src/graph/freshness-probe.js +2 -1
  56. package/src/graph/incremental-refresh.js +3 -2
  57. package/src/graph/internal-builder.build.js +22 -183
  58. package/src/graph/internal-builder.pass2.js +161 -0
  59. package/src/graph/internal-builder.resolvers.js +2 -105
  60. package/src/graph/resolvers/rust.js +117 -0
  61. package/src/mcp/actions/advisories.mjs +18 -0
  62. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  63. package/src/mcp/actions/graph-sync.mjs +195 -0
  64. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  65. package/src/mcp/architecture-bootstrap.mjs +168 -0
  66. package/src/mcp/architecture-starter.mjs +234 -0
  67. package/src/mcp/catalog.mjs +22 -6
  68. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  69. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  70. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  71. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  72. package/src/mcp/graph/context-core.mjs +111 -0
  73. package/src/mcp/graph/context-seeds.mjs +208 -0
  74. package/src/mcp/graph/context-state.mjs +86 -0
  75. package/src/mcp/graph/tools-core.mjs +143 -0
  76. package/src/mcp/graph/tools-query.mjs +223 -0
  77. package/src/mcp/graph-context.mjs +4 -496
  78. package/src/mcp/health/audit-format.mjs +172 -0
  79. package/src/mcp/health/audit.mjs +171 -0
  80. package/src/mcp/health/dead-code.mjs +110 -0
  81. package/src/mcp/health/duplicates.mjs +83 -0
  82. package/src/mcp/health/endpoints.mjs +42 -0
  83. package/src/mcp/health/structure.mjs +219 -0
  84. package/src/mcp/runtime-version.mjs +32 -0
  85. package/src/mcp/server/auto-refresh.mjs +66 -0
  86. package/src/mcp/server/runtime-config.mjs +43 -0
  87. package/src/mcp/server/shutdown.mjs +40 -0
  88. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  89. package/src/mcp/sync/evidence-common.mjs +88 -0
  90. package/src/mcp/sync/evidence-duplicates.mjs +100 -0
  91. package/src/mcp/sync/evidence-health.mjs +56 -0
  92. package/src/mcp/sync/evidence-packages.mjs +95 -0
  93. package/src/mcp/sync/payload-common.mjs +97 -0
  94. package/src/mcp/sync/payload-v2.mjs +53 -0
  95. package/src/mcp/sync/payload-v3.mjs +79 -0
  96. package/src/mcp/sync-evidence.mjs +7 -402
  97. package/src/mcp/sync-payload.mjs +10 -244
  98. package/src/mcp/tools-actions.mjs +18 -414
  99. package/src/mcp/tools-architecture.mjs +18 -70
  100. package/src/mcp/tools-context.mjs +56 -15
  101. package/src/mcp/tools-endpoints.mjs +4 -1
  102. package/src/mcp/tools-graph.mjs +17 -330
  103. package/src/mcp/tools-health.mjs +24 -705
  104. package/src/mcp/tools-verified-change.mjs +12 -4
  105. package/src/mcp-server.mjs +49 -146
  106. package/src/precision/lsp-client/constants.js +12 -0
  107. package/src/precision/lsp-client/environment.js +20 -0
  108. package/src/precision/lsp-client/errors.js +15 -0
  109. package/src/precision/lsp-client/lifecycle.js +127 -0
  110. package/src/precision/lsp-client/message-parser.js +81 -0
  111. package/src/precision/lsp-client/protocol.js +212 -0
  112. package/src/precision/lsp-client/registry.js +58 -0
  113. package/src/precision/lsp-client/repo-uri.js +60 -0
  114. package/src/precision/lsp-client/stdio-client.js +151 -0
  115. package/src/precision/lsp-client.js +10 -682
  116. package/src/precision/lsp-overlay/build.js +238 -0
  117. package/src/precision/lsp-overlay/contract.js +61 -0
  118. package/src/precision/lsp-overlay/reference-results.js +108 -0
  119. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  120. package/src/precision/lsp-overlay/source-session.js +134 -0
  121. package/src/precision/lsp-overlay/store.js +150 -0
  122. package/src/precision/lsp-overlay/target-index.js +147 -0
  123. package/src/precision/lsp-overlay/target-query.js +55 -0
  124. package/src/precision/lsp-overlay.js +11 -868
  125. package/src/precision/typescript-lsp-provider.js +12 -682
  126. package/src/precision/typescript-provider/client.js +69 -0
  127. package/src/precision/typescript-provider/discovery.js +124 -0
  128. package/src/precision/typescript-provider/project-host.js +249 -0
  129. package/src/precision/typescript-provider/project-safety.js +161 -0
  130. package/src/precision/typescript-provider/reference-usage.js +53 -0
  131. package/src/version.js +7 -0
  132. package/docs/releases/v0.2.12.md +0 -45
@@ -0,0 +1,82 @@
1
+ // Honest Health capability coverage. `status` says whether a check actually ran for this repository;
2
+ // `completeness` says whether its evidence can support a repository-wide conclusion.
3
+ const capability = (status, completeness, detail, extra = {}) => ({ status, completeness, detail, ...extra });
4
+
5
+ const sourceLanguages = (files) => {
6
+ const languages = new Set();
7
+ for (const file of files || []) {
8
+ if (/\.(?:[cm]?js|jsx|[cm]?ts|tsx)$/i.test(file)) languages.add("javascript/typescript");
9
+ else if (/\.py$/i.test(file)) languages.add("python");
10
+ else if (/\.go$/i.test(file)) languages.add("go");
11
+ else if (/\.java$/i.test(file)) languages.add("java");
12
+ else if (/\.rs$/i.test(file)) languages.add("rust");
13
+ else if (/\.cs$/i.test(file)) languages.add("csharp");
14
+ }
15
+ return languages;
16
+ };
17
+
18
+ const checkCapability = (check, supported, label) => {
19
+ if (!supported) return capability("NOT_SUPPORTED", "PARTIAL", `${label} is not supported for the discovered package ecosystem.`);
20
+ if (check?.status === "OK") return capability("CHECKED", "COMPLETE", check.detail || `${label} completed.`);
21
+ if (check?.status === "PARTIAL") return capability("NOT_CHECKED", "PARTIAL", check.detail || `${label} evidence is partial.`);
22
+ if (check?.status === "ERROR") return capability("NOT_CHECKED", "PARTIAL", check.detail || `${label} failed.`);
23
+ return capability("NOT_CHECKED", "PARTIAL", check?.detail || `${label} was not requested.`);
24
+ };
25
+
26
+ export function buildHealthCapabilityMatrix({
27
+ graphComplete,
28
+ dependencyStatus,
29
+ dependencyEcosystems = {},
30
+ checks = {},
31
+ sourceFiles = [],
32
+ correctnessCoverage = {},
33
+ measuredCoverageFiles = 0,
34
+ } = {}) {
35
+ const languages = sourceLanguages(sourceFiles);
36
+ const ecosystemRows = Object.values(dependencyEcosystems).filter((item) => item?.present);
37
+ const supportedDependencyRows = ecosystemRows.filter((item) => item.status === "CHECKED");
38
+ const unsupportedDependencyRows = ecosystemRows.filter((item) => item.status === "NOT_SUPPORTED");
39
+ const noDependencyEvidence = ecosystemRows.length === 0;
40
+ const onlyUnsupportedDependencies = unsupportedDependencyRows.length > 0 && supportedDependencyRows.length === 0;
41
+ const advisorySupported = supportedDependencyRows.some((item) => ["npm", "go", "python"].includes(item.ecosystem));
42
+ const malwareSupported = supportedDependencyRows.some((item) => ["npm", "go", "python"].includes(item.ecosystem));
43
+ const coverageSupported = [...languages].some((language) => ["javascript/typescript", "python", "go"].includes(language));
44
+ const runtimeFiles = Number(correctnessCoverage.runtimeCorrectnessFiles || 0);
45
+ const concurrencyFiles = Number(correctnessCoverage.concurrencyFiles || 0);
46
+
47
+ return {
48
+ capabilityMatrixV: 1,
49
+ structure: capability(
50
+ "CHECKED",
51
+ graphComplete ? "COMPLETE" : "PARTIAL",
52
+ graphComplete
53
+ ? "Graph cycles, orphans and configured boundaries were checked over the complete graph."
54
+ : "Structure checks ran, but the graph is scoped or excludes part of the repository.",
55
+ ),
56
+ dependencies: capability(
57
+ noDependencyEvidence ? "NOT_CHECKED" : onlyUnsupportedDependencies ? "NOT_SUPPORTED" : "CHECKED",
58
+ !noDependencyEvidence && dependencyStatus === "COMPLETE" ? "COMPLETE" : "PARTIAL",
59
+ noDependencyEvidence
60
+ ? "No dependency manifest was discovered, so manifest-to-import verification did not run."
61
+ : onlyUnsupportedDependencies
62
+ ? "Dependency manifests were detected, but every discovered build ecosystem lacks import-to-artifact verification."
63
+ : unsupportedDependencyRows.length
64
+ ? `Supported ecosystems were checked; ${unsupportedDependencyRows.map((item) => item.ecosystem).join(", ")} remains NOT_SUPPORTED.`
65
+ : "Discovered supported manifests were compared with indexed imports at their documented evidence level.",
66
+ { ecosystems: dependencyEcosystems },
67
+ ),
68
+ runtimeCorrectness: runtimeFiles
69
+ ? capability("CHECKED", "PARTIAL", `Checked ${runtimeFiles} product source file(s) for the bounded Go/Java/retry correctness patterns. This is not compiler or runtime proof.`, { checks: correctnessCoverage.checks || {} })
70
+ : capability("NOT_SUPPORTED", "PARTIAL", "No source file matched the currently supported bounded correctness patterns."),
71
+ concurrency: concurrencyFiles
72
+ ? capability("CHECKED", "PARTIAL", `Checked ${concurrencyFiles} Java source file(s) for direct InterruptedException restore/rethrow evidence. No race detector ran; race freedom is not claimed.`)
73
+ : capability("NOT_SUPPORTED", "PARTIAL", "No supported Java interruption check applied. No race detector ran; race freedom is not claimed."),
74
+ advisories: checkCapability(checks.osv, advisorySupported, "OSV advisory matching"),
75
+ malware: checkCapability(checks.malware, malwareSupported, "Installed-package malware scanning"),
76
+ coverage: measuredCoverageFiles > 0
77
+ ? capability("CHECKED", "COMPLETE", `Mapped an existing supported coverage report to ${measuredCoverageFiles} file(s).`)
78
+ : coverageSupported
79
+ ? capability("NOT_CHECKED", "PARTIAL", "No supported measured coverage report was found; static test reachability is not coverage.")
80
+ : capability("NOT_SUPPORTED", "PARTIAL", `No supported coverage format applies to the discovered language set${languages.size ? ` (${[...languages].join(", ")})` : ""}.`),
81
+ };
82
+ }
@@ -0,0 +1,169 @@
1
+ import { detectEndpoints } from "../endpoints.js";
2
+ import { detectHttpClientCalls } from "./client-call-detection.js";
3
+ import { affectedForEndpoint, externalUseLiveness, handlerNodeEvidence, reverseRuntimeImports } from "./graph-context.js";
4
+ import { matchHttpContract, methodMatches, pathSegments, routeShapeContains, routeShapeMatches, suffixShapeMatch } from "./matching.js";
5
+ import {
6
+ HTTP_CONTRACTS_V,
7
+ HTTP_CONTRACT_METHODS,
8
+ filesFromGraph,
9
+ normalizeContractFile,
10
+ normalizeHttpContractLimits,
11
+ normalizeHttpContractPath,
12
+ safeContractName,
13
+ } from "./shared.js";
14
+
15
+ const descriptorFiles = (descriptor) => descriptor.codeFiles || filesFromGraph(descriptor.graph);
16
+
17
+ function endpointFilter(endpoint, backendId, options) {
18
+ if (options.method && endpoint.method !== options.method && endpoint.method !== "ANY" && endpoint.method !== "ALL") return false;
19
+ if (options.path) {
20
+ const requested = normalizeHttpContractPath(options.path);
21
+ const endpointSegments = pathSegments(normalizeHttpContractPath(endpoint.path));
22
+ const requestedSegments = pathSegments(requested);
23
+ if (!requested || (!routeShapeMatches(endpointSegments, requestedSegments) && !routeShapeContains(endpointSegments, requestedSegments))) return false;
24
+ }
25
+ if (options.changedFiles?.size) {
26
+ const file = normalizeContractFile(endpoint.file);
27
+ if (!options.changedFiles.has(file) && !options.changedFiles.has(`${backendId}::${file}`)) return false;
28
+ }
29
+ return true;
30
+ }
31
+
32
+ export function analyzeHttpContracts(input = {}) {
33
+ const limits = normalizeHttpContractLimits(input);
34
+ const method = input.method ? String(input.method).toUpperCase() : null;
35
+ if (method && !HTTP_CONTRACT_METHODS.has(method)) throw new Error("method must be a concrete HTTP method");
36
+ const changedFiles = new Set((Array.isArray(input.changedFiles) ? input.changedFiles : []).map((file) => normalizeContractFile(file)).filter(Boolean));
37
+ const backendDescriptors = (Array.isArray(input.backends) ? input.backends : input.backend ? [input.backend] : []).slice(0, 20);
38
+ const clientDescriptors = (Array.isArray(input.clients) ? input.clients : input.client ? [input.client] : []).slice(0, 20);
39
+ const completeness = [], backends = [];
40
+ let endpointBudget = limits.maxEndpoints;
41
+ for (let index = 0; index < backendDescriptors.length; index++) {
42
+ const descriptor = backendDescriptors[index] || {};
43
+ const id = safeContractName(descriptor.id, `backend-${index + 1}`);
44
+ const candidates = descriptorFiles(descriptor).slice().sort();
45
+ if (candidates.length > limits.maxBackendFiles) completeness.push(`${id}: backend file cap reached`);
46
+ const detected = Array.isArray(descriptor.endpoints)
47
+ ? descriptor.endpoints
48
+ : detectEndpoints(descriptor.repoRoot, candidates.slice(0, limits.maxBackendFiles));
49
+ const filtered = detected
50
+ .filter((endpoint) => normalizeHttpContractPath(endpoint?.path) && normalizeContractFile(endpoint?.file) && endpointFilter(endpoint, id, { method, path: input.path, changedFiles }))
51
+ .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));
52
+ if (filtered.length > endpointBudget) completeness.push(`${id}: endpoint cap reached`);
53
+ const accepted = filtered.slice(0, endpointBudget);
54
+ endpointBudget -= accepted.length;
55
+ backends.push({ id, endpoints: accepted, graph: descriptor.graph });
56
+ }
57
+
58
+ const clients = [];
59
+ for (let index = 0; index < clientDescriptors.length; index++) {
60
+ const descriptor = clientDescriptors[index] || {};
61
+ const id = safeContractName(descriptor.id, `client-${index + 1}`);
62
+ const detected = detectHttpClientCalls(descriptor.repoRoot, descriptorFiles(descriptor), {
63
+ maxFiles: limits.maxClientFiles,
64
+ maxCalls: limits.maxCallsPerClient,
65
+ clientNames: descriptor.clientNames || input.clientNames,
66
+ wrappers: descriptor.wrappers || input.wrappers,
67
+ autoDiscoverWrappers: descriptor.autoDiscoverWrappers ?? input.autoDiscoverWrappers,
68
+ graph: descriptor.graph,
69
+ includeTests: descriptor.includeTests ?? input.includeTests,
70
+ });
71
+ if (detected.truncated) completeness.push(`${id}: client scan cap reached`);
72
+ for (const reason of detected.reasons || []) completeness.push(`${id}: ${reason}`);
73
+ clients.push({
74
+ id,
75
+ calls: detected.calls,
76
+ reverse: reverseRuntimeImports(descriptor.graph),
77
+ filesScanned: detected.filesScanned,
78
+ wrapperDiscovery: detected.discovery,
79
+ });
80
+ }
81
+
82
+ const results = [];
83
+ let matches = 0, methodMismatches = 0, callsiteCapReached = false;
84
+ for (const backend of backends) {
85
+ for (const endpoint of backend.endpoints) {
86
+ const callsites = [];
87
+ for (const client of clients) {
88
+ for (const call of client.calls) {
89
+ if (call.path && !methodMatches(endpoint.method, call.method)) {
90
+ const expected = pathSegments(normalizeHttpContractPath(endpoint.path));
91
+ const actual = pathSegments(call.path);
92
+ if (routeShapeMatches(expected, actual) || suffixShapeMatch(expected, actual)) methodMismatches++;
93
+ continue;
94
+ }
95
+ const match = matchHttpContract(endpoint, call);
96
+ if (!match) continue;
97
+ if (matches >= limits.maxMatches || callsites.length >= limits.maxCallsitesPerEndpoint) {
98
+ callsiteCapReached = true;
99
+ continue;
100
+ }
101
+ matches++;
102
+ callsites.push({
103
+ clientRepo: client.id,
104
+ file: call.file,
105
+ line: call.line,
106
+ method: call.method,
107
+ path: call.path,
108
+ dynamic: call.dynamic,
109
+ detector: call.detector,
110
+ wrapper: call.wrapper,
111
+ match,
112
+ });
113
+ }
114
+ }
115
+ callsites.sort((left, right) => right.match.score - left.match.score || left.clientRepo.localeCompare(right.clientRepo) || left.file.localeCompare(right.file) || left.line - right.line);
116
+ const handlerEvidence = handlerNodeEvidence(endpoint, backend.graph);
117
+ results.push({
118
+ backend: backend.id,
119
+ method: endpoint.method,
120
+ path: endpoint.path,
121
+ normalizedPath: normalizeHttpContractPath(endpoint.path),
122
+ ...handlerEvidence,
123
+ file: normalizeContractFile(endpoint.file) || null,
124
+ line: Number(endpoint.line) || null,
125
+ callsites,
126
+ liveness: externalUseLiveness(callsites, handlerEvidence),
127
+ affected: affectedForEndpoint(callsites, clients, limits),
128
+ });
129
+ }
130
+ }
131
+ if (callsiteCapReached) completeness.push("match or per-endpoint callsite cap reached");
132
+
133
+ const uncertainAll = clients.flatMap((client) => client.calls
134
+ .filter((call) => !call.path || call.unknownPrefix || call.partialDynamic || call.method === "UNKNOWN")
135
+ .map((call) => ({
136
+ clientRepo: client.id,
137
+ file: call.file,
138
+ line: call.line,
139
+ method: call.method,
140
+ reason: call.reason || "URL retains an unresolved dynamic component",
141
+ })))
142
+ .sort((left, right) => left.clientRepo.localeCompare(right.clientRepo) || left.file.localeCompare(right.file) || left.line - right.line);
143
+ if (uncertainAll.length > limits.maxUncertain) completeness.push("uncertain callsite cap reached");
144
+ if (results.some((endpoint) => !endpoint.affected.complete)) completeness.push("affected-file traversal cap reached");
145
+
146
+ return {
147
+ httpContractsV: HTTP_CONTRACTS_V,
148
+ status: completeness.length ? "partial" : "complete",
149
+ filters: { method, path: input.path ? normalizeHttpContractPath(input.path) : null, changedFiles: [...changedFiles].sort() },
150
+ limits,
151
+ completeness: { complete: completeness.length === 0, reasons: [...new Set(completeness)] },
152
+ totals: {
153
+ backends: backends.length,
154
+ clients: clients.length,
155
+ endpoints: results.length,
156
+ clientCalls: clients.reduce((sum, client) => sum + client.calls.length, 0),
157
+ matches,
158
+ methodMismatches,
159
+ uncertainCalls: uncertainAll.length,
160
+ notDeadExternalUse: results.filter((endpoint) => endpoint.liveness.status === "NOT_DEAD_EXTERNAL_USE").length,
161
+ notDeadExternalHandlers: results.filter((endpoint) => endpoint.liveness.canSuppressDeadCandidate).length,
162
+ possibleExternalUse: results.filter((endpoint) => endpoint.liveness.status === "POSSIBLE_EXTERNAL_USE").length,
163
+ unknownLiveness: results.filter((endpoint) => endpoint.liveness.status === "UNKNOWN").length,
164
+ },
165
+ wrapperDiscovery: clients.map((client) => ({ clientRepo: client.id, ...client.wrapperDiscovery })),
166
+ endpoints: results,
167
+ uncertain: uncertainAll.slice(0, limits.maxUncertain),
168
+ };
169
+ }
@@ -0,0 +1,81 @@
1
+ import { createPathClassifier, hasPathClass } from "../../path-classification.js";
2
+ import { isWeavatrixIgnored, loadWeavatrixIgnore } from "../../path-ignore.js";
3
+ import { createRepoBoundary } from "../../repo-path.js";
4
+ import { safeRead } from "../../util.js";
5
+ import {
6
+ discoverHttpWrappers,
7
+ loadHttpContractConfig,
8
+ normalizeHttpClientNames,
9
+ normalizeHttpWrapperDescriptors,
10
+ } from "../http-contract-wrappers.js";
11
+ import { wrapperScopeFiles } from "./graph-context.js";
12
+ import { extractHttpClientCallsFromText } from "./client-call-parser.js";
13
+ import {
14
+ HTTP_CONTRACT_DEFAULTS,
15
+ HTTP_CONTRACT_HARD_LIMITS,
16
+ boundedInteger,
17
+ normalizeContractFile,
18
+ } from "./shared.js";
19
+
20
+ export function detectHttpClientCalls(repoRoot, codeFiles, options = {}) {
21
+ const boundary = createRepoBoundary(repoRoot);
22
+ if (!boundary.root) return { calls: [], truncated: false, filesScanned: 0, discovery: { enabled: false, configured: 0, discovered: 0, ambiguous: [] }, reasons: [] };
23
+ const maxFiles = boundedInteger(options.maxFiles, HTTP_CONTRACT_DEFAULTS.maxClientFiles, 1, HTTP_CONTRACT_HARD_LIMITS.maxClientFiles);
24
+ const maxCalls = boundedInteger(options.maxCalls, HTTP_CONTRACT_DEFAULTS.maxCallsPerClient, 1, HTTP_CONTRACT_HARD_LIMITS.maxCallsPerClient);
25
+ const ignoreRules = loadWeavatrixIgnore(boundary.root);
26
+ const classifier = createPathClassifier(boundary.root);
27
+ const candidates = [...new Set((codeFiles || []).map((entry) => normalizeContractFile(entry?.path || entry)).filter(Boolean))].sort();
28
+ let truncated = candidates.length > maxFiles;
29
+ let filesScanned = 0;
30
+ const sources = [], calls = [];
31
+ for (const file of candidates.slice(0, maxFiles)) {
32
+ if (!/\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file) || isWeavatrixIgnored(file, ignoreRules)) continue;
33
+ const classification = classifier.explain(file, { content: "" });
34
+ if (classification.excluded || (!options.includeTests && hasPathClass(classification, "test", "e2e"))) continue;
35
+ const resolved = boundary.resolve(file);
36
+ if (!resolved.ok) continue;
37
+ const text = safeRead(resolved.path);
38
+ if (!text) continue;
39
+ filesScanned++;
40
+ sources.push({ file, text });
41
+ }
42
+ const config = loadHttpContractConfig(boundary.root);
43
+ const clientNames = [...new Set([...normalizeHttpClientNames(options.clientNames), ...config.clientNames])];
44
+ const configured = [...normalizeHttpWrapperDescriptors(options.wrappers, "input"), ...config.wrappers];
45
+ const discoveryEnabled = options.autoDiscoverWrappers !== false && config.autoDiscoverWrappers !== false;
46
+ const discovered = discoveryEnabled ? discoverHttpWrappers(sources, clientNames) : { wrappers: [], ambiguous: [], truncated: false };
47
+ const scopedDiscovered = discovered.wrappers.map((wrapper) => ({
48
+ ...wrapper,
49
+ allowedFiles: wrapperScopeFiles(wrapper.definitionFile, options.graph),
50
+ }));
51
+ for (const { file, text } of sources) {
52
+ const remaining = maxCalls - calls.length;
53
+ if (remaining <= 0) { truncated = true; break; }
54
+ const extracted = extractHttpClientCallsFromText(text, file, {
55
+ clientNames,
56
+ normalizedWrappers: [...configured, ...scopedDiscovered],
57
+ maxCalls: remaining,
58
+ });
59
+ calls.push(...extracted.calls);
60
+ if (extracted.truncated) truncated = true;
61
+ }
62
+ 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)));
63
+ const reasons = [];
64
+ if (config.error) reasons.push(`HTTP contract config ${config.error}`);
65
+ reasons.push(...(config.warnings || []));
66
+ if (discovered.truncated) reasons.push("auto-discovered wrapper cap reached");
67
+ if (discovered.ambiguous.length) reasons.push(`${discovered.ambiguous.length} ambiguous auto-discovered wrapper name(s) skipped`);
68
+ return {
69
+ calls,
70
+ truncated,
71
+ filesScanned,
72
+ discovery: {
73
+ enabled: discoveryEnabled,
74
+ configured: configured.length,
75
+ discovered: scopedDiscovered.length,
76
+ ambiguous: discovered.ambiguous,
77
+ truncated: discovered.truncated,
78
+ },
79
+ reasons,
80
+ };
81
+ }
@@ -0,0 +1,255 @@
1
+ import { DEFAULT_HTTP_CLIENT_NAMES, normalizeHttpClientNames, normalizeHttpWrapperDescriptors } from "../http-contract-wrappers.js";
2
+ import {
3
+ HTTP_CONTRACT_DEFAULTS,
4
+ HTTP_CONTRACT_HARD_LIMITS,
5
+ boundedInteger,
6
+ contractLineAt,
7
+ normalizeContractFile,
8
+ normalizeHttpContractPath,
9
+ } from "./shared.js";
10
+
11
+ function maskNonCode(text) {
12
+ const chars = String(text || "").split("");
13
+ let index = 0;
14
+ while (index < chars.length) {
15
+ const char = chars[index], next = chars[index + 1];
16
+ if (char === "/" && next === "/") {
17
+ chars[index++] = " "; chars[index++] = " ";
18
+ while (index < chars.length && chars[index] !== "\n") chars[index++] = " ";
19
+ continue;
20
+ }
21
+ if (char === "/" && next === "*") {
22
+ chars[index++] = " "; chars[index++] = " ";
23
+ while (index < chars.length) {
24
+ if (chars[index] === "*" && chars[index + 1] === "/") { chars[index++] = " "; chars[index++] = " "; break; }
25
+ if (chars[index] !== "\n") chars[index] = " ";
26
+ index++;
27
+ }
28
+ continue;
29
+ }
30
+ if (char === "'" || char === '"' || char === "`") {
31
+ const quote = char;
32
+ chars[index++] = " ";
33
+ while (index < chars.length) {
34
+ if (chars[index] === "\\") {
35
+ chars[index++] = " ";
36
+ if (index < chars.length && chars[index] !== "\n") chars[index] = " ";
37
+ index++;
38
+ continue;
39
+ }
40
+ const closes = chars[index] === quote;
41
+ if (chars[index] !== "\n") chars[index] = " ";
42
+ index++;
43
+ if (closes) break;
44
+ }
45
+ continue;
46
+ }
47
+ index++;
48
+ }
49
+ return chars.join("");
50
+ }
51
+
52
+ function quotedArgument(text, start, quote) {
53
+ let value = "";
54
+ for (let index = start + 1; index < text.length; index++) {
55
+ const char = text[index];
56
+ if (char === "\\") {
57
+ if (index + 1 >= text.length) break;
58
+ value += text[++index];
59
+ continue;
60
+ }
61
+ if (char === quote) return { value, endIndex: index + 1 };
62
+ if (char === "\n" || char === "\r") break;
63
+ value += char;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function templateArgument(text, start, constants = null, requireStatic = false) {
69
+ let value = "", dynamicSegments = 0, unknownPrefix = false, partialDynamic = false;
70
+ for (let index = start + 1; index < text.length; index++) {
71
+ const char = text[index];
72
+ if (char === "\\") {
73
+ if (index + 1 >= text.length) break;
74
+ value += text[++index];
75
+ continue;
76
+ }
77
+ if (char === "`") return { value, endIndex: index + 1, dynamicSegments, unknownPrefix, partialDynamic };
78
+ if (char !== "$" || text[index + 1] !== "{") { value += char; continue; }
79
+ let cursor = index + 2, depth = 1, quote = null;
80
+ while (cursor < text.length && depth > 0) {
81
+ const token = text[cursor];
82
+ if (quote) {
83
+ if (token === "\\") cursor += 2;
84
+ else { if (token === quote) quote = null; cursor++; }
85
+ continue;
86
+ }
87
+ if (token === "'" || token === '"' || token === "`") { quote = token; cursor++; continue; }
88
+ if (token === "{") depth++;
89
+ else if (token === "}") depth--;
90
+ cursor++;
91
+ }
92
+ if (depth !== 0) return null;
93
+ const expression = text.slice(index + 2, cursor - 1).trim();
94
+ if (/^[A-Za-z_$][\w$]*$/.test(expression) && constants?.has(expression)) {
95
+ value += constants.get(expression);
96
+ index = cursor - 1;
97
+ continue;
98
+ }
99
+ if (requireStatic) return null;
100
+ const next = text[cursor];
101
+ const leftBoundary = value === "" || value.endsWith("/");
102
+ const rightBoundary = !next || next === "/" || next === "?" || next === "#" || next === "`";
103
+ dynamicSegments++;
104
+ if (value === "" && next === "/") unknownPrefix = true;
105
+ else if (leftBoundary && rightBoundary) value += ":param";
106
+ else { value += ":dynamic"; partialDynamic = true; }
107
+ index = cursor - 1;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function extractStaticStringConstants(text) {
113
+ const source = String(text || "");
114
+ const mask = maskNonCode(source);
115
+ const declarations = [];
116
+ const declaration = /\bconst\s+([A-Za-z_$][\w$]*)\s*=/g;
117
+ let match;
118
+ while ((match = declaration.exec(mask)) && declarations.length < 500) {
119
+ let start = match.index + match[0].length;
120
+ while (/\s/.test(source[start] || "")) start++;
121
+ declarations.push({ name: match[1], start });
122
+ }
123
+ const constants = new Map();
124
+ for (const item of declarations) {
125
+ const quote = source[item.start];
126
+ if (quote !== "'" && quote !== '"') continue;
127
+ const parsed = quotedArgument(source, item.start, quote);
128
+ if (parsed && parsed.value.length <= 2_048) constants.set(item.name, parsed.value);
129
+ }
130
+ for (let pass = 0; pass < Math.min(8, declarations.length); pass++) {
131
+ let changed = false;
132
+ for (const item of declarations) {
133
+ if (constants.has(item.name) || source[item.start] !== "`") continue;
134
+ const parsed = templateArgument(source, item.start, constants, true);
135
+ if (!parsed || parsed.value.length > 2_048) continue;
136
+ constants.set(item.name, parsed.value);
137
+ changed = true;
138
+ }
139
+ if (!changed) break;
140
+ }
141
+ return constants;
142
+ }
143
+
144
+ function argumentStart(text, openParen, target) {
145
+ let current = 0, round = 0, square = 0, curly = 0;
146
+ for (let index = openParen + 1; index < text.length; index++) {
147
+ const char = text[index];
148
+ if (current === target && !/\s|,/.test(char)) return index;
149
+ if (char === "'" || char === '"' || char === "`") {
150
+ const quote = char;
151
+ for (index++; index < text.length; index++) {
152
+ if (text[index] === "\\") index++;
153
+ else if (text[index] === quote) break;
154
+ }
155
+ continue;
156
+ }
157
+ if (char === "(") round++;
158
+ else if (char === "[") square++;
159
+ else if (char === "{") curly++;
160
+ else if (char === ")") {
161
+ if (round === 0 && square === 0 && curly === 0) return null;
162
+ round--;
163
+ } else if (char === "]") square--;
164
+ else if (char === "}") curly--;
165
+ else if (char === "," && round === 0 && square === 0 && curly === 0) current++;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function parseUrlArgument(text, openParen, constants, argument = 0) {
171
+ let start = argumentStart(text, openParen, argument);
172
+ if (start == null) return { path: null, endIndex: openParen, kind: "dynamic", dynamic: true, unknownPrefix: false, partialDynamic: true, reason: `URL argument ${argument} is missing` };
173
+ while (/\s/.test(text[start] || "")) start++;
174
+ const quote = text[start];
175
+ if (quote === "'" || quote === '"') {
176
+ const parsed = quotedArgument(text, start, quote);
177
+ if (!parsed) return { path: null, endIndex: start, kind: "dynamic", dynamic: true, reason: "unterminated URL literal" };
178
+ return { path: normalizeHttpContractPath(parsed.value), endIndex: parsed.endIndex, kind: "literal", dynamic: false, unknownPrefix: false, partialDynamic: false, reason: null };
179
+ }
180
+ if (quote === "`") {
181
+ const parsed = templateArgument(text, start, constants);
182
+ if (!parsed) return { path: null, endIndex: start, kind: "dynamic", dynamic: true, reason: "unterminated URL template" };
183
+ return {
184
+ path: normalizeHttpContractPath(parsed.value), endIndex: parsed.endIndex,
185
+ kind: parsed.dynamicSegments ? "template" : "literal", dynamic: parsed.dynamicSegments > 0,
186
+ unknownPrefix: parsed.unknownPrefix, partialDynamic: parsed.partialDynamic,
187
+ reason: parsed.partialDynamic ? "URL template contains a partial dynamic segment" : null,
188
+ };
189
+ }
190
+ return { path: null, endIndex: start, kind: "dynamic", dynamic: true, unknownPrefix: false, partialDynamic: true, reason: "URL argument is not a string or template literal" };
191
+ }
192
+
193
+ function fetchMethod(text, argumentEnd) {
194
+ const tail = text.slice(argumentEnd, argumentEnd + 500);
195
+ if (!/^\s*,/.test(tail)) return { method: "GET", uncertain: false };
196
+ const config = tail.replace(/^\s*,\s*/, "");
197
+ if (!config.startsWith("{") || /^\{\s*\.\.\./.test(config)) return { method: "UNKNOWN", uncertain: true };
198
+ const literal = /\bmethod\s*:\s*(["'`])(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\1/i.exec(tail);
199
+ if (literal) return { method: literal[2].toUpperCase(), uncertain: false };
200
+ return /\bmethod\s*:/.test(tail) ? { method: "UNKNOWN", uncertain: true } : { method: "GET", uncertain: false };
201
+ }
202
+
203
+ function normalizedClientNames(values) {
204
+ return new Set([...DEFAULT_HTTP_CLIENT_NAMES, ...normalizeHttpClientNames(values)]
205
+ .map((value) => String(value || "").trim().toLowerCase())
206
+ .filter((value) => /^[a-z_$][\w$]*$/i.test(value)));
207
+ }
208
+
209
+ const escapeRegex = (value) => String(value).replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&");
210
+
211
+ export function extractHttpClientCallsFromText(text, file, options = {}) {
212
+ const source = String(text || "");
213
+ const mask = maskNonCode(source);
214
+ const constants = extractStaticStringConstants(source);
215
+ const allowed = normalizedClientNames(options.clientNames);
216
+ const wrappers = normalizeHttpWrapperDescriptors(options.wrappers, "input").concat(Array.isArray(options.normalizedWrappers) ? options.normalizedWrappers : []);
217
+ const maxCalls = boundedInteger(options.maxCalls, HTTP_CONTRACT_DEFAULTS.maxCallsPerClient, 1, HTTP_CONTRACT_HARD_LIMITS.maxCallsPerClient);
218
+ const calls = [], seen = new Set();
219
+ let truncated = false;
220
+ const add = (clientName, method, openParen, fetch = false, urlArgument = 0, detector = "builtin", wrapper = null) => {
221
+ const key = `${openParen}\0${method}\0${urlArgument}`;
222
+ if (seen.has(key)) return;
223
+ seen.add(key);
224
+ if (calls.length >= maxCalls) { truncated = true; return; }
225
+ const parsed = parseUrlArgument(source, openParen, constants, urlArgument);
226
+ const fetchInfo = fetch ? fetchMethod(source, parsed.endIndex) : { method: method.toUpperCase(), uncertain: false };
227
+ calls.push({
228
+ file: normalizeContractFile(file), line: contractLineAt(source, openParen), client: clientName, method: fetchInfo.method,
229
+ path: parsed.path, kind: parsed.kind, dynamic: parsed.dynamic, unknownPrefix: Boolean(parsed.unknownPrefix),
230
+ partialDynamic: Boolean(parsed.partialDynamic || fetchInfo.uncertain),
231
+ reason: fetchInfo.uncertain ? "HTTP method is dynamic" : parsed.reason, detector, wrapper,
232
+ });
233
+ };
234
+ const member = /(^|[^\w$])([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*(get|post|put|patch|delete|head|options)\s*(?:<[^>\n]{1,200}>)?\s*\(/gim;
235
+ let match;
236
+ while ((match = member.exec(mask))) if (allowed.has(match[2].toLowerCase())) add(match[2], match[3], member.lastIndex - 1);
237
+ const fetchCall = /(^|[^\w$])fetch\s*\(/gim;
238
+ while ((match = fetchCall.exec(mask))) add("fetch", "GET", fetchCall.lastIndex - 1, true);
239
+ for (const wrapper of wrappers) {
240
+ if (wrapper.allowedFiles instanceof Set && !wrapper.allowedFiles.has(normalizeContractFile(file))) continue;
241
+ if (wrapper.kind === "function") {
242
+ const bare = new RegExp(`(^|[^\\w$.?])${escapeRegex(wrapper.call)}\\s*(?:<[^>\\n]{1,200}>)?\\s*\\(`, "gim");
243
+ while ((match = bare.exec(mask))) {
244
+ const nameAt = bare.lastIndex - match[0].length + match[1].length;
245
+ if (/\bfunction\s*$/i.test(mask.slice(Math.max(0, nameAt - 30), nameAt))) continue;
246
+ add(wrapper.call, wrapper.method, bare.lastIndex - 1, false, wrapper.urlArgument, `${wrapper.source}-wrapper`, { kind: wrapper.kind, call: wrapper.call, definitionFile: wrapper.definitionFile || null });
247
+ }
248
+ } else if (wrapper.kind === "member") {
249
+ const memberCall = new RegExp(`(^|[^\\w$])${escapeRegex(wrapper.object)}\\s*(?:\\?\\.|\\.)\\s*${escapeRegex(wrapper.member)}\\s*(?:<[^>\\n]{1,200}>)?\\s*\\(`, "gim");
250
+ while ((match = memberCall.exec(mask))) add(`${wrapper.object}.${wrapper.member}`, wrapper.method, memberCall.lastIndex - 1, false, wrapper.urlArgument, `${wrapper.source}-wrapper`, { kind: wrapper.kind, object: wrapper.object, member: wrapper.member, definitionFile: wrapper.definitionFile || null });
251
+ }
252
+ }
253
+ 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)));
254
+ return { calls, truncated };
255
+ }