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
@@ -1,591 +1,142 @@
1
- // Bounded behavioral analysis over local Git history. The collector never reads source bodies:
2
- // it asks Git for numstat metadata, applies repository-local exclusions, then combines raw churn
3
- // with the existing file graph. Large commits are deliberately excluded rather than truncated so
4
- // mass formatting/generated-code updates cannot manufacture co-change evidence.
5
- import { spawn } from "node:child_process";
6
- import { isAbsolute } from "node:path";
7
- import { childProcessEnv } from "../child-env.js";
8
- import { isTestPath } from "../graph/graph-filter.js";
9
- import { isStructuralRelation } from "../graph/relations.js";
10
- import { createRepoBoundary } from "../repo-path.js";
11
- import { isWeavatrixIgnored, loadWeavatrixIgnore } from "../path-ignore.js";
12
-
13
- export const GIT_HISTORY_V = 1;
14
- export const GIT_HISTORY_WINDOWS = Object.freeze([3, 6, 12]);
15
-
16
- const DEFAULTS = Object.freeze({
17
- months: 6,
18
- maxCommits: 500,
19
- maxFilesPerCommit: 80,
20
- maxPairs: 100,
21
- minPairCount: 2,
22
- maxPairCandidates: 100_000,
23
- maxOutputBytes: 16 * 1024 * 1024,
24
- timeoutMs: 20_000,
25
- });
26
- const HARD_CAPS = Object.freeze({
27
- maxCommits: 2_000,
28
- maxFilesPerCommit: 200,
29
- maxPairs: 500,
30
- maxPairCandidates: 250_000,
31
- maxOutputBytes: 64 * 1024 * 1024,
32
- timeoutMs: 60_000,
33
- });
34
- const HEADER_SEPARATOR = "\x1e";
35
- const FIELD_SEPARATOR = "\x1f";
36
- const GIT_FORMAT = "%x1e%H%x1f%ct";
37
-
38
- const endpoint = (value) => value && typeof value === "object" ? value.id : value;
39
- const round = (value, digits = 4) => {
40
- if (!Number.isFinite(value)) return 0;
41
- const scale = 10 ** digits;
42
- return Math.round(value * scale) / scale;
43
- };
44
- const boundedInteger = (value, fallback, min, max) => {
45
- const number = Number(value);
46
- return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback;
47
- };
48
- const normalizePath = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
49
-
50
- function safeHistoryPath(value) {
51
- const path = normalizePath(value);
52
- if (!path || path.includes("\0") || isAbsolute(path) || /^[a-z]:\//i.test(path)) return null;
53
- if (/[\x00-\x1f\x7f]/.test(path)) return null;
54
- const parts = path.split("/");
55
- if (parts.some((part) => !part || part === "." || part === "..")) return null;
56
- return path;
57
- }
58
-
59
- function utcMonthsBefore(date, months) {
60
- const source = new Date(date);
61
- if (!Number.isFinite(source.getTime())) throw new Error("now must be a valid date");
62
- const targetMonth = source.getUTCMonth() - months;
63
- const first = new Date(Date.UTC(source.getUTCFullYear(), targetMonth, 1, source.getUTCHours(), source.getUTCMinutes(), source.getUTCSeconds(), source.getUTCMilliseconds()));
64
- const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
65
- first.setUTCDate(Math.min(source.getUTCDate(), lastDay));
66
- return first;
67
- }
68
-
69
- function normalizeOptions(input = {}) {
70
- const months = Number(input.months ?? DEFAULTS.months);
71
- if (!GIT_HISTORY_WINDOWS.includes(months)) throw new Error("months must be one of 3, 6 or 12");
72
- return {
73
- months,
74
- maxCommits: boundedInteger(input.maxCommits, DEFAULTS.maxCommits, 1, HARD_CAPS.maxCommits),
75
- maxFilesPerCommit: boundedInteger(input.maxFilesPerCommit, DEFAULTS.maxFilesPerCommit, 2, HARD_CAPS.maxFilesPerCommit),
76
- maxPairs: boundedInteger(input.maxPairs, DEFAULTS.maxPairs, 1, HARD_CAPS.maxPairs),
77
- minPairCount: boundedInteger(input.minPairCount, DEFAULTS.minPairCount, 1, 100),
78
- maxPairCandidates: boundedInteger(input.maxPairCandidates, DEFAULTS.maxPairCandidates, 100, HARD_CAPS.maxPairCandidates),
79
- maxOutputBytes: boundedInteger(input.maxOutputBytes, DEFAULTS.maxOutputBytes, 64 * 1024, HARD_CAPS.maxOutputBytes),
80
- timeoutMs: boundedInteger(input.timeoutMs, DEFAULTS.timeoutMs, 1_000, HARD_CAPS.timeoutMs),
81
- };
82
- }
83
-
84
- function boundedGitCommand(command, args, options) {
85
- return new Promise((resolve, reject) => {
86
- const child = spawn(command, args, {
87
- cwd: options.cwd,
88
- env: options.env,
89
- shell: false,
90
- windowsHide: true,
91
- stdio: ["ignore", "pipe", "pipe"],
92
- });
93
- const stdout = [];
94
- const stderr = [];
95
- let stdoutBytes = 0;
96
- let stderrBytes = 0;
97
- let truncated = false;
98
- let timedOut = false;
99
- let settled = false;
100
- const finish = (fn) => {
101
- if (settled) return;
102
- settled = true;
103
- clearTimeout(timer);
104
- fn();
105
- };
106
- const stop = () => {
107
- try { child.kill("SIGKILL"); } catch { /* process may already have exited */ }
108
- };
109
- const timer = setTimeout(() => {
110
- timedOut = true;
111
- stop();
112
- }, options.timeoutMs);
113
-
114
- child.stdout?.on("data", (chunk) => {
115
- if (truncated) return;
116
- const remaining = options.maxOutputBytes - stdoutBytes;
117
- if (remaining <= 0) {
118
- truncated = true;
119
- stop();
120
- return;
121
- }
122
- const kept = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
123
- stdout.push(kept);
124
- stdoutBytes += kept.length;
125
- if (kept.length !== chunk.length) {
126
- truncated = true;
127
- stop();
128
- }
129
- });
130
- child.stderr?.on("data", (chunk) => {
131
- const remaining = 64 * 1024 - stderrBytes;
132
- if (remaining <= 0) return;
133
- const kept = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
134
- stderr.push(kept);
135
- stderrBytes += kept.length;
136
- });
137
- child.on("error", (error) => finish(() => reject(error)));
138
- child.on("close", (exitCode) => finish(() => {
139
- if (timedOut) return reject(new Error("git history collection timed out"));
140
- resolve({
141
- stdout: Buffer.concat(stdout),
142
- stderr: Buffer.concat(stderr).toString("utf8"),
143
- exitCode: Number(exitCode ?? 1),
144
- truncated,
145
- });
146
- }));
147
- });
148
- }
149
-
150
- function statNumber(value) {
151
- return value === "-" ? { value: 0, binary: true } : { value: Number(value), binary: false };
152
- }
153
-
154
- // Parse the NUL-delimited output produced by `git log --no-merges --numstat -z`.
155
- // The destination path is used for a rename. A change-set over the cap is marked oversized and its
156
- // partial file list is discarded so downstream metrics are never based on a misleading prefix.
157
- export function parseGitNumstatLog(raw, options = {}) {
158
- const maxFilesPerCommit = boundedInteger(options.maxFilesPerCommit, DEFAULTS.maxFilesPerCommit, 2, HARD_CAPS.maxFilesPerCommit);
159
- const ignoreRules = options.ignoreRules || [];
160
- const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw || "");
161
- const segments = text.split(HEADER_SEPARATOR).slice(1);
162
- if (options.dropLastIncomplete && segments.length) segments.pop();
163
- const commits = [];
164
-
165
- for (const segment of segments) {
166
- const firstNul = segment.indexOf("\0");
167
- if (firstNul < 0) continue;
168
- const header = segment.slice(0, firstNul).replace(/^[\r\n]+/, "");
169
- const separator = header.indexOf(FIELD_SEPARATOR);
170
- if (separator < 0) continue;
171
- const hash = header.slice(0, separator);
172
- const timestamp = Number(header.slice(separator + 1));
173
- if (!/^[a-f0-9]{40,64}$/i.test(hash) || !Number.isInteger(timestamp) || timestamp < 0) continue;
174
-
175
- const tokens = segment.slice(firstNul + 1).split("\0");
176
- const files = new Map();
177
- let fileCount = 0;
178
- let ignoredFiles = 0;
179
- let invalidPaths = 0;
180
- let oversized = false;
181
- for (let index = 0; index < tokens.length; index += 1) {
182
- const token = tokens[index].replace(/^[\r\n]+/, "");
183
- const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(token);
184
- if (!match) continue;
185
- let rawPath = match[3];
186
- let renamedFrom = null;
187
- if (!rawPath) {
188
- renamedFrom = safeHistoryPath(tokens[index + 1]);
189
- rawPath = tokens[index + 2];
190
- index += 2;
191
- }
192
- const path = safeHistoryPath(rawPath);
193
- if (!path) {
194
- invalidPaths += 1;
195
- continue;
196
- }
197
- if (isWeavatrixIgnored(path, ignoreRules)) {
198
- ignoredFiles += 1;
199
- continue;
200
- }
201
- const additions = statNumber(match[1]);
202
- const deletions = statNumber(match[2]);
203
- fileCount += 1;
204
- if (fileCount > maxFilesPerCommit) {
205
- oversized = true;
206
- files.clear();
207
- continue;
208
- }
209
- if (oversized) continue;
210
- const previous = files.get(path);
211
- files.set(path, {
212
- file: path,
213
- additions: (previous?.additions || 0) + additions.value,
214
- deletions: (previous?.deletions || 0) + deletions.value,
215
- binary: Boolean(previous?.binary || additions.binary || deletions.binary),
216
- ...(renamedFrom ? { renamedFrom } : previous?.renamedFrom ? { renamedFrom: previous.renamedFrom } : {}),
217
- });
218
- }
219
- commits.push({
220
- hash,
221
- timestamp,
222
- fileCount,
223
- ignoredFiles,
224
- invalidPaths,
225
- oversized,
226
- files: oversized ? [] : [...files.values()].sort((left, right) => left.file.localeCompare(right.file)),
227
- });
228
- }
229
- return commits;
230
- }
231
-
232
- function graphFilesAndAdjacency(graph = {}) {
233
- const byId = new Map();
234
- const files = new Set();
235
- for (const node of graph.nodes || []) {
236
- const file = safeHistoryPath(node?.source_file);
237
- if (!file) continue;
238
- byId.set(String(node.id), file);
239
- files.add(file);
240
- }
241
- const adjacency = new Map([...files].map((file) => [file, new Set()]));
242
- for (const link of graph.links || []) {
243
- if (isStructuralRelation(link?.relation) || link?.barrelProxy === true) continue;
244
- const left = byId.get(String(endpoint(link?.source)));
245
- const right = byId.get(String(endpoint(link?.target)));
246
- if (!left || !right || left === right) continue;
247
- adjacency.get(left)?.add(right);
248
- adjacency.get(right)?.add(left);
249
- }
250
- return { files, adjacency };
251
- }
252
-
253
- function graphDistanceAtMostTwo(left, right, adjacency) {
254
- if (left === right) return 0;
255
- const neighbors = adjacency.get(left);
256
- if (!neighbors) return null;
257
- if (neighbors.has(right)) return 1;
258
- for (const middle of neighbors) if (adjacency.get(middle)?.has(right)) return 2;
259
- return null;
260
- }
261
-
262
- function percentile(value, positiveValues) {
263
- if (!(value > 0) || !positiveValues.length) return 0;
264
- let atOrBelow = 0;
265
- for (const candidate of positiveValues) if (candidate <= value) atOrBelow += 1;
266
- return round(atOrBelow / positiveValues.length);
267
- }
268
-
269
- function pairSort(left, right) {
270
- return right.count - left.count
271
- || right.confidence - left.confidence
272
- || right.lift - left.lift
273
- || left.left.localeCompare(right.left)
274
- || left.right.localeCompare(right.right);
275
- }
276
-
277
- function publicPair(pair, graphDistance) {
278
- return {
279
- left: pair.left,
280
- right: pair.right,
281
- count: pair.count,
282
- jaccard: pair.jaccard,
283
- lift: pair.lift,
284
- confidence: pair.confidence,
285
- leftConfidence: pair.leftConfidence,
286
- rightConfidence: pair.rightConfidence,
287
- graphDistance,
288
- };
289
- }
290
-
291
- // Pure deterministic reducer for tests and for future hosted evidence snapshots.
292
- export function buildGitHistoryAnalytics({ commits = [], graph = {}, window, limits = {}, status = "complete" } = {}) {
293
- const maxPairs = boundedInteger(limits.maxPairs, DEFAULTS.maxPairs, 1, HARD_CAPS.maxPairs);
294
- const minPairCount = boundedInteger(limits.minPairCount, DEFAULTS.minPairCount, 1, 100);
295
- const maxPairCandidates = boundedInteger(limits.maxPairCandidates, DEFAULTS.maxPairCandidates, 100, HARD_CAPS.maxPairCandidates);
296
- const eligible = commits.filter((commit) => !commit.oversized && commit.files.length > 0);
297
- const skipped = commits.filter((commit) => commit.oversized);
298
- const activity = new Map();
299
- const fileCommits = new Map();
300
- let additions = 0;
301
- let deletions = 0;
302
- let binaryChanges = 0;
303
-
304
- for (const commit of eligible) {
305
- const seen = new Set();
306
- for (const stat of commit.files) {
307
- if (seen.has(stat.file)) continue;
308
- seen.add(stat.file);
309
- const entry = activity.get(stat.file) || { file: stat.file, commits: 0, additions: 0, deletions: 0, binaryChanges: 0 };
310
- entry.commits += 1;
311
- entry.additions += stat.additions;
312
- entry.deletions += stat.deletions;
313
- entry.binaryChanges += stat.binary ? 1 : 0;
314
- activity.set(stat.file, entry);
315
- fileCommits.set(stat.file, (fileCommits.get(stat.file) || 0) + 1);
316
- additions += stat.additions;
317
- deletions += stat.deletions;
318
- if (stat.binary) binaryChanges += 1;
319
- }
320
- }
321
-
322
- const { files: graphFiles, adjacency } = graphFilesAndAdjacency(graph);
323
- const raw = [...activity.values()].map((entry) => ({ ...entry, churn: entry.additions + entry.deletions }));
324
- const churnValues = raw.map((entry) => entry.churn).filter((value) => value > 0);
325
- const connectivityValues = [...graphFiles].map((file) => adjacency.get(file)?.size || 0).filter((value) => value > 0);
326
- const fileChurn = raw.map((entry) => {
327
- const connectivity = adjacency.get(entry.file)?.size || 0;
328
- const churnPercentile = percentile(entry.churn, churnValues);
329
- const connectivityPercentile = percentile(connectivity, connectivityValues);
330
- return {
331
- ...entry,
332
- connectivity,
333
- churnPercentile,
334
- connectivityPercentile,
335
- hotspotScore: round(Math.sqrt(churnPercentile * connectivityPercentile)),
336
- };
337
- }).sort((left, right) => right.churn - left.churn || right.commits - left.commits || left.file.localeCompare(right.file));
338
- const hotspots = fileChurn.filter((entry) => entry.connectivity > 0)
339
- .sort((left, right) => right.hotspotScore - left.hotspotScore || right.churn - left.churn || right.connectivity - left.connectivity || left.file.localeCompare(right.file));
340
-
341
- const pairCounts = new Map();
342
- let pairCandidatesTruncated = false;
343
- for (const commit of eligible) {
344
- const files = [...new Set(commit.files.map((entry) => entry.file))].sort();
345
- for (let leftIndex = 0; leftIndex < files.length; leftIndex += 1) {
346
- for (let rightIndex = leftIndex + 1; rightIndex < files.length; rightIndex += 1) {
347
- const key = `${files[leftIndex]}\0${files[rightIndex]}`;
348
- if (!pairCounts.has(key) && pairCounts.size >= maxPairCandidates) {
349
- pairCandidatesTruncated = true;
350
- continue;
351
- }
352
- pairCounts.set(key, (pairCounts.get(key) || 0) + 1);
353
- }
354
- }
355
- }
356
- const denominator = eligible.length;
357
- const pairs = [];
358
- for (const [key, count] of pairCounts) {
359
- if (count < minPairCount || denominator === 0) continue;
360
- const split = key.indexOf("\0");
361
- const left = key.slice(0, split);
362
- const right = key.slice(split + 1);
363
- const leftCount = fileCommits.get(left) || 0;
364
- const rightCount = fileCommits.get(right) || 0;
365
- const leftConfidence = count / leftCount;
366
- const rightConfidence = count / rightCount;
367
- pairs.push({
368
- left,
369
- right,
370
- count,
371
- jaccard: round(count / (leftCount + rightCount - count)),
372
- lift: round((count * denominator) / (leftCount * rightCount)),
373
- confidence: round(Math.max(leftConfidence, rightConfidence)),
374
- leftConfidence: round(leftConfidence),
375
- rightConfidence: round(rightConfidence),
376
- });
377
- }
378
- pairs.sort(pairSort);
379
-
380
- const observed = pairs.slice(0, maxPairs).map((pair) => publicPair(pair, graphDistanceAtMostTwo(pair.left, pair.right, adjacency)));
381
- const expectedTestSource = pairs.filter((pair) => isTestPath(pair.left) !== isTestPath(pair.right)).slice(0, maxPairs).map((pair) => {
382
- const test = isTestPath(pair.left) ? pair.left : pair.right;
383
- const source = test === pair.left ? pair.right : pair.left;
384
- return {
385
- source,
386
- test,
387
- count: pair.count,
388
- jaccard: pair.jaccard,
389
- lift: pair.lift,
390
- confidence: pair.confidence,
391
- sourceConfidence: source === pair.left ? pair.leftConfidence : pair.rightConfidence,
392
- testConfidence: test === pair.left ? pair.leftConfidence : pair.rightConfidence,
393
- graphDistance: graphDistanceAtMostTwo(source, test, adjacency),
394
- };
395
- });
396
- const hidden = pairs.filter((pair) => {
397
- if (isTestPath(pair.left) || isTestPath(pair.right)) return false;
398
- if (!graphFiles.has(pair.left) || !graphFiles.has(pair.right)) return false;
399
- return graphDistanceAtMostTwo(pair.left, pair.right, adjacency) === null;
400
- }).slice(0, maxPairs).map((pair) => publicPair(pair, null));
401
-
402
- const ignoredFiles = commits.reduce((sum, commit) => sum + (commit.ignoredFiles || 0), 0);
403
- const invalidPaths = commits.reduce((sum, commit) => sum + (commit.invalidPaths || 0), 0);
404
- const completenessReasons = [
405
- skipped.length ? `${skipped.length} oversized change-set(s) excluded` : null,
406
- pairCandidatesTruncated ? "co-change candidate cap reached" : null,
407
- status === "partial" ? "git output or commit window was truncated" : null,
408
- ].filter(Boolean);
409
- return {
1
+ // Public facade and bounded local collector for behavioral Git-history intelligence.
2
+ import {childProcessEnv} from '../child-env.js'
3
+ import {createRepoBoundary} from '../repo-path.js'
4
+ import {loadWeavatrixIgnore} from '../path-ignore.js'
5
+ import {buildGitHistoryAnalytics} from './git-history/analytics.js'
6
+ import {boundedGitCommand, parseGitNumstatLog} from './git-history/collector.js'
7
+ import {
8
+ boundedHistoryInteger,
9
+ GIT_FORMAT,
10
+ GIT_HISTORY_V,
11
+ normalizeGitHistoryOptions,
12
+ utcMonthsBefore,
13
+ } from './git-history/options.js'
14
+
15
+ export {buildGitHistoryAnalytics} from './git-history/analytics.js'
16
+ export {parseGitNumstatLog} from './git-history/collector.js'
17
+ export {GIT_HISTORY_V, GIT_HISTORY_WINDOWS} from './git-history/options.js'
18
+
19
+ const unavailableResult = (window, limits, reason) => ({
410
20
  gitHistoryV: GIT_HISTORY_V,
411
- status: completenessReasons.length ? "partial" : status,
412
- window: window || null,
413
- limits: {
414
- maxCommits: limits.maxCommits ?? null,
415
- maxFilesPerCommit: limits.maxFilesPerCommit ?? null,
416
- maxPairs,
417
- minPairCount,
418
- maxPairCandidates,
419
- },
420
- completeness: { complete: completenessReasons.length === 0, reasons: completenessReasons },
421
- totals: {
422
- commitsRead: commits.length,
423
- commitsAnalyzed: eligible.length,
424
- oversizedCommitsSkipped: skipped.length,
425
- files: fileChurn.length,
426
- additions,
427
- deletions,
428
- churn: additions + deletions,
429
- binaryChanges,
430
- ignoredFiles,
431
- invalidPaths,
432
- graphFiles: graphFiles.size,
433
- },
434
- fileChurn,
435
- hotspots,
436
- coupling: {
437
- eligibleCommits: denominator,
438
- totalCandidates: pairCounts.size,
439
- candidatesTruncated: pairCandidatesTruncated,
440
- observed,
441
- expectedTestSource,
442
- hidden,
443
- },
444
- };
445
- }
446
-
447
- function unavailableResult(window, limits, reason) {
448
- return {
449
- gitHistoryV: GIT_HISTORY_V,
450
- status: "unavailable",
21
+ status: 'unavailable',
451
22
  window,
452
23
  limits,
453
- completeness: { complete: false, reasons: [reason] },
454
- totals: { commitsRead: 0, commitsAnalyzed: 0, oversizedCommitsSkipped: 0, files: 0, additions: 0, deletions: 0, churn: 0, binaryChanges: 0, ignoredFiles: 0, invalidPaths: 0, graphFiles: 0 },
24
+ completeness: {complete: false, reasons: [reason]},
25
+ totals: {
26
+ commitsRead: 0, commitsAnalyzed: 0, oversizedCommitsSkipped: 0, files: 0,
27
+ additions: 0, deletions: 0, churn: 0, binaryChanges: 0, ignoredFiles: 0,
28
+ invalidPaths: 0, graphFiles: 0,
29
+ },
455
30
  fileChurn: [],
456
31
  hotspots: [],
457
- coupling: { eligibleCommits: 0, totalCandidates: 0, candidatesTruncated: false, observed: [], expectedTestSource: [], hidden: [] },
458
- };
459
- }
32
+ coupling: {eligibleCommits: 0, totalCandidates: 0, candidatesTruncated: false, observed: [], expectedTestSource: [], hidden: []},
33
+ })
460
34
 
461
- // Execute one local, read-only Git query and return aggregates only (no commit messages/authors/source).
462
35
  export async function analyzeGitHistory(input = {}) {
463
- const options = normalizeOptions(input);
464
- const now = new Date(input.now ?? Date.now());
465
- if (!Number.isFinite(now.getTime())) throw new Error("now must be a valid date");
466
- const since = utcMonthsBefore(now, options.months);
467
- const window = { months: options.months, since: since.toISOString(), until: now.toISOString() };
468
- const limits = {
469
- maxCommits: options.maxCommits,
470
- maxFilesPerCommit: options.maxFilesPerCommit,
471
- maxPairs: options.maxPairs,
472
- minPairCount: options.minPairCount,
473
- maxPairCandidates: options.maxPairCandidates,
474
- };
475
- const boundary = createRepoBoundary(input.repoRoot);
476
- if (!boundary.root) return unavailableResult(window, limits, "repository root is unavailable");
477
- const args = [
478
- "log",
479
- "--no-merges",
480
- "--numstat",
481
- "-z",
482
- `--format=${GIT_FORMAT}`,
483
- `--since=${window.since}`,
484
- `--until=${window.until}`,
485
- `--max-count=${options.maxCommits + 1}`,
486
- "--",
487
- ".",
488
- ];
489
- const runner = input.runner || boundedGitCommand;
490
- let execution;
491
- try {
492
- execution = await runner("git", args, {
493
- cwd: boundary.root,
494
- env: childProcessEnv(),
495
- timeoutMs: options.timeoutMs,
496
- maxOutputBytes: options.maxOutputBytes,
497
- });
498
- } catch (error) {
499
- return unavailableResult(window, limits, String(error?.message || "git history collection failed").slice(0, 200));
500
- }
501
- if (execution.exitCode !== 0 && !execution.truncated) return unavailableResult(window, limits, "git log failed");
502
-
503
- const rawBuffer = Buffer.isBuffer(execution.stdout) ? execution.stdout : Buffer.from(String(execution.stdout || ""));
504
- const tooLarge = rawBuffer.length > options.maxOutputBytes;
505
- const truncated = Boolean(execution.truncated || tooLarge);
506
- const bounded = tooLarge ? rawBuffer.subarray(0, options.maxOutputBytes) : rawBuffer;
507
- const parsed = parseGitNumstatLog(bounded, {
508
- maxFilesPerCommit: options.maxFilesPerCommit,
509
- ignoreRules: loadWeavatrixIgnore(boundary.root),
510
- dropLastIncomplete: truncated,
511
- });
512
- const commitsTruncated = parsed.length > options.maxCommits;
513
- const commits = parsed.slice(0, options.maxCommits);
514
- return buildGitHistoryAnalytics({
515
- commits,
516
- graph: input.graph || {},
517
- window,
518
- limits,
519
- status: truncated || commitsTruncated ? "partial" : "complete",
520
- });
36
+ const options = normalizeGitHistoryOptions(input)
37
+ const now = new Date(input.now ?? Date.now())
38
+ if (!Number.isFinite(now.getTime())) throw new Error('now must be a valid date')
39
+ const since = utcMonthsBefore(now, options.months)
40
+ const window = {months: options.months, since: since.toISOString(), until: now.toISOString()}
41
+ const limits = {
42
+ maxCommits: options.maxCommits,
43
+ maxFilesPerCommit: options.maxFilesPerCommit,
44
+ maxPairs: options.maxPairs,
45
+ minPairCount: options.minPairCount,
46
+ maxPairCandidates: options.maxPairCandidates,
47
+ }
48
+ const boundary = createRepoBoundary(input.repoRoot)
49
+ if (!boundary.root) return unavailableResult(window, limits, 'repository root is unavailable')
50
+ const args = [
51
+ 'log', '--no-merges', '--numstat', '-z', `--format=${GIT_FORMAT}`,
52
+ `--since=${window.since}`, `--until=${window.until}`, `--max-count=${options.maxCommits + 1}`,
53
+ '--', '.',
54
+ ]
55
+ let execution
56
+ try {
57
+ execution = await (input.runner || boundedGitCommand)('git', args, {
58
+ cwd: boundary.root,
59
+ env: childProcessEnv(),
60
+ timeoutMs: options.timeoutMs,
61
+ maxOutputBytes: options.maxOutputBytes,
62
+ })
63
+ } catch (error) {
64
+ return unavailableResult(window, limits, String(error?.message || 'git history collection failed').slice(0, 200))
65
+ }
66
+ if (execution.exitCode !== 0 && !execution.truncated) return unavailableResult(window, limits, 'git log failed')
67
+ const raw = Buffer.isBuffer(execution.stdout) ? execution.stdout : Buffer.from(String(execution.stdout || ''))
68
+ const tooLarge = raw.length > options.maxOutputBytes
69
+ const truncated = Boolean(execution.truncated || tooLarge)
70
+ const bounded = tooLarge ? raw.subarray(0, options.maxOutputBytes) : raw
71
+ const parsed = parseGitNumstatLog(bounded, {
72
+ maxFilesPerCommit: options.maxFilesPerCommit,
73
+ ignoreRules: loadWeavatrixIgnore(boundary.root),
74
+ dropLastIncomplete: truncated,
75
+ })
76
+ const commitsTruncated = parsed.length > options.maxCommits
77
+ return buildGitHistoryAnalytics({
78
+ commits: parsed.slice(0, options.maxCommits),
79
+ graph: input.graph || {},
80
+ window,
81
+ limits,
82
+ status: truncated || commitsTruncated ? 'partial' : 'complete',
83
+ })
521
84
  }
522
85
 
523
86
  export function formatGitHistoryAnalytics(result, options = {}) {
524
- const topN = boundedInteger(options.topN, 10, 1, 50);
525
- if (!result || result.status === "unavailable") {
526
- const reason = result?.completeness?.reasons?.[0] || "history is unavailable";
527
- return `Git history intelligence: UNAVAILABLE — ${reason}`;
528
- }
529
- const window = result.window ? `${result.window.months} months (${result.window.since.slice(0, 10)} → ${result.window.until.slice(0, 10)})` : "configured window";
530
- const lines = [
531
- `Git history intelligence — ${window}`,
532
- `Status: ${String(result.status).toUpperCase()} · ${result.totals.commitsAnalyzed}/${result.totals.commitsRead} commits analyzed · ${result.totals.files} files · ${result.totals.churn} changed lines`,
533
- "",
534
- "Hotspots (churn percentile × graph-connectivity percentile):",
535
- ];
536
- const hotspots = result.hotspots.slice(0, topN);
537
- if (!hotspots.length) lines.push("- none");
538
- for (const item of hotspots) lines.push(`- ${item.file}: score ${item.hotspotScore.toFixed(4)} · churn ${item.churn} in ${item.commits} commits · connectivity ${item.connectivity}`);
539
- lines.push("", "Hidden co-change coupling (no graph path within 2 hops):");
540
- const hidden = result.coupling.hidden.slice(0, topN);
541
- if (!hidden.length) lines.push("- none");
542
- for (const pair of hidden) lines.push(`- ${pair.left} ↔ ${pair.right}: ${pair.count} commits · Jaccard ${pair.jaccard.toFixed(4)} · lift ${pair.lift.toFixed(4)} · confidence ${pair.confidence.toFixed(4)}`);
543
- lines.push("", "Expected test/source co-change:");
544
- const expected = result.coupling.expectedTestSource.slice(0, topN);
545
- if (!expected.length) lines.push("- none");
546
- for (const pair of expected) lines.push(`- ${pair.test} ↔ ${pair.source}: ${pair.count} commits · confidence ${pair.confidence.toFixed(4)}`);
547
- if (result.completeness.reasons.length) lines.push("", `Partial: ${result.completeness.reasons.join("; ")}.`);
548
- return lines.join("\n");
87
+ const topN = boundedHistoryInteger(options.topN, 10, 1, 50)
88
+ if (!result || result.status === 'unavailable') {
89
+ return `Git history intelligence: UNAVAILABLE — ${result?.completeness?.reasons?.[0] || 'history is unavailable'}`
90
+ }
91
+ const window = result.window
92
+ ? `${result.window.months} months (${result.window.since.slice(0, 10)} → ${result.window.until.slice(0, 10)})`
93
+ : 'configured window'
94
+ const lines = [
95
+ `Git history intelligence ${window}`,
96
+ `Status: ${String(result.status).toUpperCase()} · ${result.totals.commitsAnalyzed}/${result.totals.commitsRead} commits analyzed · ${result.totals.files} files · ${result.totals.churn} changed lines`,
97
+ '',
98
+ 'Hotspots (churn percentile × graph-connectivity percentile):',
99
+ ]
100
+ const hotspots = result.hotspots.slice(0, topN)
101
+ if (!hotspots.length) lines.push('- none')
102
+ for (const item of hotspots) lines.push(`- ${item.file}: score ${item.hotspotScore.toFixed(4)} · churn ${item.churn} in ${item.commits} commits · connectivity ${item.connectivity}`)
103
+ lines.push('', 'Hidden co-change coupling (no graph path within 2 hops):')
104
+ const hidden = result.coupling.hidden.slice(0, topN)
105
+ if (!hidden.length) lines.push('- none')
106
+ for (const pair of hidden) lines.push(`- ${pair.left} ${pair.right}: ${pair.count} commits · Jaccard ${pair.jaccard.toFixed(4)} · lift ${pair.lift.toFixed(4)} · confidence ${pair.confidence.toFixed(4)}`)
107
+ lines.push('', 'Expected test/source co-change:')
108
+ const expected = result.coupling.expectedTestSource.slice(0, topN)
109
+ if (!expected.length) lines.push('- none')
110
+ for (const pair of expected) lines.push(`- ${pair.test} ↔ ${pair.source}: ${pair.count} commits · confidence ${pair.confidence.toFixed(4)}`)
111
+ if (result.completeness.reasons.length) lines.push('', `Partial: ${result.completeness.reasons.join('; ')}.`)
112
+ return lines.join('\n')
549
113
  }
550
114
 
551
- // Keep the full analytics object available to local callers, but never expose its unbounded
552
- // collections through an MCP response. `topN` is a per-collection ceiling, not merely a text
553
- // formatting hint. The accompanying page metadata lets machine consumers distinguish "there were
554
- // no more results" from "more evidence exists but was deliberately omitted".
555
115
  export function boundGitHistoryAnalytics(result, options = {}) {
556
- const topN = boundedInteger(options.topN, 10, 1, 50);
557
- const source = result && typeof result === "object" ? result : {};
558
- const coupling = source.coupling && typeof source.coupling === "object" ? source.coupling : {};
559
- const collections = {};
560
- const cap = (name, value) => {
561
- const items = Array.isArray(value) ? value : [];
562
- const bounded = items.slice(0, topN);
563
- collections[name] = {
564
- total: items.length,
565
- returned: bounded.length,
566
- truncated: items.length > bounded.length,
567
- };
568
- return bounded;
569
- };
570
-
571
- const bounded = {
572
- ...source,
573
- limits: {...(source.limits || {}), topN},
574
- fileChurn: cap("fileChurn", source.fileChurn),
575
- hotspots: cap("hotspots", source.hotspots),
576
- coupling: {
577
- ...coupling,
578
- observed: cap("coupling.observed", coupling.observed),
579
- expectedTestSource: cap("coupling.expectedTestSource", coupling.expectedTestSource),
580
- hidden: cap("coupling.hidden", coupling.hidden),
581
- },
582
- };
583
- return {
584
- result: bounded,
585
- page: {
586
- limit: topN,
587
- truncated: Object.values(collections).some((entry) => entry.truncated),
588
- collections,
589
- },
590
- };
116
+ const topN = boundedHistoryInteger(options.topN, 10, 1, 50)
117
+ const source = result && typeof result === 'object' ? result : {}
118
+ const coupling = source.coupling && typeof source.coupling === 'object' ? source.coupling : {}
119
+ const collections = {}
120
+ const cap = (name, value) => {
121
+ const items = Array.isArray(value) ? value : []
122
+ const bounded = items.slice(0, topN)
123
+ collections[name] = {total: items.length, returned: bounded.length, truncated: items.length > bounded.length}
124
+ return bounded
125
+ }
126
+ const bounded = {
127
+ ...source,
128
+ limits: {...(source.limits || {}), topN},
129
+ fileChurn: cap('fileChurn', source.fileChurn),
130
+ hotspots: cap('hotspots', source.hotspots),
131
+ coupling: {
132
+ ...coupling,
133
+ observed: cap('coupling.observed', coupling.observed),
134
+ expectedTestSource: cap('coupling.expectedTestSource', coupling.expectedTestSource),
135
+ hidden: cap('coupling.hidden', coupling.hidden),
136
+ },
137
+ }
138
+ return {
139
+ result: bounded,
140
+ page: {limit: topN, truncated: Object.values(collections).some((entry) => entry.truncated), collections},
141
+ }
591
142
  }