weavatrix 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +138 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +92 -30
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +549 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +581 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +62 -19
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +51 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +144 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph.mjs +25 -14
  62. package/src/mcp/tools-health.mjs +336 -14
  63. package/src/mcp/tools-history.mjs +22 -0
  64. package/src/mcp/tools-impact-change.mjs +261 -0
  65. package/src/mcp/tools-impact.mjs +25 -6
  66. package/src/mcp-server.mjs +100 -17
  67. package/src/mcp-source-tools.mjs +11 -3
  68. package/src/path-classification.js +201 -0
  69. package/src/path-ignore.js +69 -0
  70. package/src/security/typosquat.js +1 -1
@@ -0,0 +1,549 @@
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 {
410
+ 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",
451
+ window,
452
+ 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 },
455
+ fileChurn: [],
456
+ hotspots: [],
457
+ coupling: { eligibleCommits: 0, totalCandidates: 0, candidatesTruncated: false, observed: [], expectedTestSource: [], hidden: [] },
458
+ };
459
+ }
460
+
461
+ // Execute one local, read-only Git query and return aggregates only (no commit messages/authors/source).
462
+ 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
+ });
521
+ }
522
+
523
+ 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");
549
+ }
@@ -0,0 +1,74 @@
1
+ // Build a source graph for an immutable Git commit without mutating the user's worktree.
2
+ // The commit is first resolved to a full object id, then exported into a private temporary
3
+ // directory. No hooks are run and no branch/worktree metadata is changed.
4
+ import {mkdtempSync, mkdirSync, rmSync} from 'node:fs'
5
+ import {join} from 'node:path'
6
+ import {tmpdir} from 'node:os'
7
+ import {spawnSync} from 'node:child_process'
8
+ import {childProcessEnv} from '../child-env.js'
9
+ import {buildInternalGraph} from '../graph/internal-builder.js'
10
+
11
+ const SAFE_REF = /^(?!-)[A-Za-z0-9][A-Za-z0-9._\/@{}+~^-]{0,199}$/
12
+
13
+ function git(repoRoot, args, timeout = 15_000) {
14
+ return spawnSync('git', ['-C', repoRoot, ...args], {
15
+ encoding: 'utf8', timeout, env: childProcessEnv(), windowsHide: true,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ })
18
+ }
19
+
20
+ export function resolveGitCommit(repoRoot, requestedRef) {
21
+ const ref = String(requestedRef || '').trim()
22
+ if (!SAFE_REF.test(ref)) return {ok: false, error: 'base_ref contains unsupported characters'}
23
+ const result = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
24
+ const commit = String(result.stdout || '').trim()
25
+ if (result.status !== 0 || !/^[a-f0-9]{40,64}$/i.test(commit)) {
26
+ return {ok: false, error: `could not resolve Git ref "${ref}" to a commit`}
27
+ }
28
+ return {ok: true, ref, commit}
29
+ }
30
+
31
+ export async function withGitRefCheckout(repoRoot, requestedRef, operation) {
32
+ const resolved = resolveGitCommit(repoRoot, requestedRef)
33
+ if (!resolved.ok) return resolved
34
+ const temp = mkdtempSync(join(tmpdir(), 'weavatrix-git-ref-'))
35
+ const checkout = join(temp, 'repo')
36
+ const archive = join(temp, 'source.tar')
37
+ mkdirSync(checkout, {recursive: true})
38
+ try {
39
+ const archived = git(repoRoot, ['archive', '--format=tar', `--output=${archive}`, resolved.commit], 60_000)
40
+ if (archived.status !== 0) {
41
+ return {ok: false, error: `git archive failed for ${resolved.ref}: ${String(archived.stderr || '').trim() || 'unknown error'}`}
42
+ }
43
+ const extracted = spawnSync('tar', ['-xf', archive, '-C', checkout], {
44
+ encoding: 'utf8', timeout: 60_000, env: childProcessEnv(), windowsHide: true,
45
+ maxBuffer: 4 * 1024 * 1024,
46
+ })
47
+ if (extracted.status !== 0) {
48
+ return {ok: false, error: `temporary Git archive extraction failed: ${String(extracted.stderr || '').trim() || 'tar unavailable'}`}
49
+ }
50
+ // A normal build asks Git for tracked + non-ignored files. A plain archive has no .git
51
+ // directory, so the fallback walker deliberately skips tracked dot paths such as
52
+ // .github/workflows. Recreate only an index over the immutable archive (no commit, hooks or
53
+ // checkout) so live and baseline graphs use the same file-universe semantics.
54
+ const initialized = git(checkout, ['init', '--quiet'])
55
+ if (initialized.status !== 0) {
56
+ return {ok: false, error: `temporary Git index initialization failed: ${String(initialized.stderr || '').trim() || 'unknown error'}`}
57
+ }
58
+ const indexed = git(checkout, ['add', '--all', '--force'], 60_000)
59
+ if (indexed.status !== 0) {
60
+ return {ok: false, error: `temporary Git index population failed: ${String(indexed.stderr || '').trim() || 'unknown error'}`}
61
+ }
62
+ const value = await operation(checkout, resolved)
63
+ return {ok: true, ref: resolved.ref, commit: resolved.commit, value}
64
+ } catch (error) {
65
+ return {ok: false, error: error instanceof Error ? error.message : String(error)}
66
+ } finally {
67
+ rmSync(temp, {recursive: true, force: true})
68
+ }
69
+ }
70
+
71
+ export async function buildGraphAtGitRef(repoRoot, requestedRef) {
72
+ const result = await withGitRefCheckout(repoRoot, requestedRef, async (checkout) => buildInternalGraph(checkout))
73
+ return result.ok ? {...result, graph: result.value} : result
74
+ }
@@ -110,7 +110,7 @@ export function aggregateGraph(graph, repoRoot) {
110
110
  if (link.relation) edge.rels[link.relation] = (edge.rels[link.relation] || 0) + 1;
111
111
  };
112
112
  for (const link of links) {
113
- if (link.relation === "contains") continue;
113
+ if (link.relation === "contains" || link.barrelProxy === true) continue;
114
114
  const fromFile = id2file.get(endpoint(link.source));
115
115
  const toFile = id2file.get(endpoint(link.target));
116
116
  if (fromFile && toFile && fromFile !== toFile) {
@@ -133,7 +133,7 @@ export function aggregateGraph(graph, repoRoot) {
133
133
  // tagged with its file's module so the Symbols view can still cluster into module regions.
134
134
  const symEdges = new Map();
135
135
  for (const link of links) {
136
- if (link.relation === "contains") continue;
136
+ if (link.relation === "contains" || link.barrelProxy === true) continue;
137
137
  const s = endpoint(link.source);
138
138
  const t = endpoint(link.target);
139
139
  if (s === t || !symbolIds.has(s) || !symbolIds.has(t)) continue;
@@ -1,6 +1,9 @@
1
+ import { communityTerritoryOf } from "../graph/community.js";
2
+
1
3
  // Stable folder territories used by module_map. Nested package source roots keep the first directory below
2
4
  // `src`; otherwise a monorepo's entire crate/app would collapse into one module with no visible dependencies.
3
5
  export function folderModuleOf(file) {
6
+ if (/\.java$/i.test(String(file || ""))) return communityTerritoryOf(file);
4
7
  const dirs = String(file || "").split(/[\\/]/).filter(Boolean).slice(0, -1);
5
8
  if (!dirs.length) return "(root)";
6
9
  const sourceRoot = dirs.lastIndexOf("src");
@@ -43,7 +43,7 @@ export function summarizeHotspots(graphJsonPath, max = 15) {
43
43
  const inDeg = new Map();
44
44
  const outDeg = new Map();
45
45
  for (const link of graph.links || []) {
46
- if (isStructuralRelation(link.relation)) continue;
46
+ if (isStructuralRelation(link.relation) || link.barrelProxy === true) continue;
47
47
  const s = endpoint(link.source);
48
48
  const t = endpoint(link.target);
49
49
  outDeg.set(s, (outDeg.get(s) || 0) + 1);