weavatrix 0.2.13 → 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.
- package/README.md +83 -39
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.14.md +93 -0
- package/package.json +2 -2
- package/skill/SKILL.md +57 -38
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +10 -57
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/scoped-dependencies.js +40 -0
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.run.js +113 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +20 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +208 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +100 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -331
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp-server.mjs +35 -146
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/version.js +7 -0
- package/docs/releases/v0.2.13.md +0 -48
|
@@ -1,591 +1,142 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
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: {
|
|
454
|
-
totals: {
|
|
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: {
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
}
|