@titan-design/code-graph 0.11.0 → 0.13.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.
- package/README.md +129 -5
- package/dist/analysis/browser.d.ts +2 -0
- package/dist/analysis/browser.js +84 -0
- package/dist/analysis/browser.js.map +1 -0
- package/dist/browser-BBzShneN.d.ts +936 -0
- package/dist/{change-coupling-CyqHgRsm.d.ts → change-coupling-CT3bcZMD.d.ts} +5 -1
- package/dist/{chunk-PFI5XMUG.js → chunk-GX563F6R.js} +79 -89
- package/dist/chunk-GX563F6R.js.map +1 -0
- package/dist/chunk-HIVVDGWE.js +83 -0
- package/dist/chunk-HIVVDGWE.js.map +1 -0
- package/dist/chunk-L5KU543A.js +1395 -0
- package/dist/chunk-L5KU543A.js.map +1 -0
- package/dist/history/index.d.ts +25 -4
- package/dist/history/index.js +11 -3
- package/dist/index.d.ts +331 -353
- package/dist/index.js +1658 -926
- package/dist/index.js.map +1 -1
- package/package.json +8 -4
- package/dist/chunk-PFI5XMUG.js.map +0 -1
|
@@ -21,9 +21,13 @@ interface LoadChurnOptions {
|
|
|
21
21
|
/** Directory paths are made relative to; entries outside it are dropped. */
|
|
22
22
|
repoRoot: string;
|
|
23
23
|
windowDays?: ChurnWindow;
|
|
24
|
+
/** Walk history from this rev instead of HEAD. */
|
|
25
|
+
rev?: string;
|
|
26
|
+
/** Epoch seconds a finite window ends at; defaults to the wall clock. */
|
|
27
|
+
untilEpoch?: number;
|
|
24
28
|
}
|
|
25
29
|
/**
|
|
26
|
-
* Parse the last `windowDays` of git history into ChurnEntry[] rebased onto
|
|
30
|
+
* Parse the last `windowDays` of git history up to `rev` (default HEAD) into ChurnEntry[] rebased onto
|
|
27
31
|
* `repoRoot`. Returns null if git isn't available; [] if no commits matched.
|
|
28
32
|
* Used both for churn metrics and for change-coupling.
|
|
29
33
|
*/
|
|
@@ -45,6 +45,14 @@ var DAY_SECONDS = 86400;
|
|
|
45
45
|
function windowCutoff(windowDays, nowEpoch) {
|
|
46
46
|
return nowEpoch - windowDays * DAY_SECONDS;
|
|
47
47
|
}
|
|
48
|
+
function sinceArgs(windowDays, untilEpoch) {
|
|
49
|
+
if (windowDays === "lifetime") return [];
|
|
50
|
+
if (untilEpoch === void 0) return [`--since=${windowDays}.days.ago`];
|
|
51
|
+
return [`--since=@${windowCutoff(windowDays, untilEpoch)}`];
|
|
52
|
+
}
|
|
53
|
+
function revArgs(rev) {
|
|
54
|
+
return rev === void 0 ? [] : [rev, "--"];
|
|
55
|
+
}
|
|
48
56
|
|
|
49
57
|
// src/history/log.ts
|
|
50
58
|
var DEFAULT_WINDOW_DAYS = 30;
|
|
@@ -54,7 +62,7 @@ function loadChurnEntries(options) {
|
|
|
54
62
|
const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;
|
|
55
63
|
const gitRoot = detectGitToplevel(options.repoRoot);
|
|
56
64
|
if (gitRoot === null) return null;
|
|
57
|
-
const log = runChurnLog(options.repoRoot, windowDays);
|
|
65
|
+
const log = runChurnLog(options.repoRoot, windowDays, options.rev, options.untilEpoch);
|
|
58
66
|
if (log === null) return null;
|
|
59
67
|
const canonicalRoot = canonicalize(options.repoRoot);
|
|
60
68
|
return parseChurnLog(log).flatMap((entry) => {
|
|
@@ -111,17 +119,76 @@ function entriesWithin(entries, windowDays, nowEpoch) {
|
|
|
111
119
|
const cutoff = windowCutoff(windowDays, nowEpoch);
|
|
112
120
|
return entries.filter((e) => e.epoch >= cutoff);
|
|
113
121
|
}
|
|
114
|
-
function runChurnLog(repoRoot, windowDays) {
|
|
115
|
-
const sinceArgs = windowDays === "lifetime" ? [] : [`--since=${windowDays}.days.ago`];
|
|
122
|
+
function runChurnLog(repoRoot, windowDays, rev, untilEpoch) {
|
|
116
123
|
const format = "--pretty=format:%H%x09%ae%x09%ct";
|
|
117
|
-
|
|
124
|
+
const args = ["log", ...sinceArgs(windowDays, untilEpoch), "--no-merges", "--numstat", "-M", format, ...revArgs(rev)];
|
|
125
|
+
return runGitLarge(repoRoot, args, 64 * 1024 * 1024);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/history/git-tree.ts
|
|
129
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
130
|
+
var TREE_LIST_BUFFER = 256 * 1024 * 1024;
|
|
131
|
+
var BLOB_READ_BUFFER = 1024 * 1024 * 1024;
|
|
132
|
+
var REGULAR_FILE_MODES = /* @__PURE__ */ new Set(["100644", "100755"]);
|
|
133
|
+
var SYMLINK_MODE = "120000";
|
|
134
|
+
function resolveCommit(repoRoot, rev) {
|
|
135
|
+
const commit = runGit(repoRoot, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
|
|
136
|
+
if (!commit) throw new Error(`Unknown git revision "${rev}": it does not name a commit in ${repoRoot}`);
|
|
137
|
+
const epoch = Number(runGit(repoRoot, ["show", "-s", "--format=%ct", commit]));
|
|
138
|
+
if (!Number.isFinite(epoch)) throw new Error(`Could not read the commit time of ${commit} in ${repoRoot}`);
|
|
139
|
+
return { commit, commitEpoch: epoch };
|
|
140
|
+
}
|
|
141
|
+
function listTreeBlobs(repoRoot, commit) {
|
|
142
|
+
const out = runGitLarge(repoRoot, ["ls-tree", "-r", "-z", "--full-tree", commit], TREE_LIST_BUFFER);
|
|
143
|
+
if (out === null) throw new Error(`git ls-tree failed for ${commit} in ${repoRoot}`);
|
|
144
|
+
return parseLsTree(out);
|
|
145
|
+
}
|
|
146
|
+
function parseLsTree(text) {
|
|
147
|
+
const blobs = [];
|
|
148
|
+
for (const record of text.split("\0")) {
|
|
149
|
+
const tab = record.indexOf(" ");
|
|
150
|
+
if (tab < 0) continue;
|
|
151
|
+
const [mode, type, oid] = record.slice(0, tab).split(" ");
|
|
152
|
+
if (type !== "blob" || !oid) continue;
|
|
153
|
+
const link = mode === SYMLINK_MODE;
|
|
154
|
+
if (link || REGULAR_FILE_MODES.has(mode)) blobs.push({ path: record.slice(tab + 1), oid, link });
|
|
155
|
+
}
|
|
156
|
+
return blobs;
|
|
157
|
+
}
|
|
158
|
+
function readBlobs(repoRoot, oids) {
|
|
159
|
+
const unique = [...new Set(oids)];
|
|
160
|
+
if (unique.length === 0) return /* @__PURE__ */ new Map();
|
|
161
|
+
const out = execFileSync2("git", ["cat-file", "--batch"], {
|
|
162
|
+
cwd: repoRoot,
|
|
163
|
+
input: unique.map((oid) => `${oid}
|
|
164
|
+
`).join(""),
|
|
165
|
+
maxBuffer: BLOB_READ_BUFFER,
|
|
166
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
167
|
+
env: discoveryEnv()
|
|
168
|
+
});
|
|
169
|
+
return parseCatFileBatch(out);
|
|
170
|
+
}
|
|
171
|
+
function parseCatFileBatch(out) {
|
|
172
|
+
const blobs = /* @__PURE__ */ new Map();
|
|
173
|
+
let at = 0;
|
|
174
|
+
while (at < out.length) {
|
|
175
|
+
const eol = out.indexOf(10, at);
|
|
176
|
+
if (eol < 0) break;
|
|
177
|
+
const [oid, type, size] = out.toString("utf-8", at, eol).split(" ");
|
|
178
|
+
if (type === "missing" || size === void 0) throw new Error(`git object ${oid} is missing`);
|
|
179
|
+
const start = eol + 1;
|
|
180
|
+
const end = start + Number(size);
|
|
181
|
+
blobs.set(oid, out.toString("utf-8", start, end));
|
|
182
|
+
at = end + 1;
|
|
183
|
+
}
|
|
184
|
+
return blobs;
|
|
118
185
|
}
|
|
119
186
|
|
|
120
187
|
// src/history/first-seen.ts
|
|
121
188
|
function loadFileFirstSeen(options) {
|
|
122
189
|
const gitRoot = detectGitToplevel(options.repoRoot);
|
|
123
190
|
if (gitRoot === null) return null;
|
|
124
|
-
const log = runFirstSeenLog(options.repoRoot);
|
|
191
|
+
const log = runFirstSeenLog(options.repoRoot, options.rev);
|
|
125
192
|
if (log === null) return null;
|
|
126
193
|
const canonicalRoot = canonicalize(options.repoRoot);
|
|
127
194
|
const firstSeen = /* @__PURE__ */ new Map();
|
|
@@ -148,8 +215,8 @@ function parseFirstSeenLog(text) {
|
|
|
148
215
|
}
|
|
149
216
|
return out;
|
|
150
217
|
}
|
|
151
|
-
function runFirstSeenLog(repoRoot) {
|
|
152
|
-
const args = ["log", "--reverse", "--no-merges", "--no-renames", "--name-only", "--pretty=format:%H%x09%ct"];
|
|
218
|
+
function runFirstSeenLog(repoRoot, rev) {
|
|
219
|
+
const args = ["log", "--reverse", "--no-merges", "--no-renames", "--name-only", "--pretty=format:%H%x09%ct", ...revArgs(rev)];
|
|
153
220
|
return runGitLarge(repoRoot, args, 128 * 1024 * 1024);
|
|
154
221
|
}
|
|
155
222
|
|
|
@@ -235,87 +302,12 @@ function minAuthorsToReach(sortedDesc, total, threshold) {
|
|
|
235
302
|
return sortedDesc.length;
|
|
236
303
|
}
|
|
237
304
|
|
|
238
|
-
// src/history/change-coupling.ts
|
|
239
|
-
var DEFAULT_MIN_COUNT = 2;
|
|
240
|
-
var DEFAULT_MAX_COMMITS_PER_PAIR = 10;
|
|
241
|
-
var DEFAULT_LARGE_COMMIT_THRESHOLD = 50;
|
|
242
|
-
function computeChangeCoupling(entries, options = {}) {
|
|
243
|
-
const maxCommits = options.maxCommitsPerPair ?? DEFAULT_MAX_COMMITS_PER_PAIR;
|
|
244
|
-
const largeThreshold = options.largeCommitThreshold ?? DEFAULT_LARGE_COMMIT_THRESHOLD;
|
|
245
|
-
const pairs = /* @__PURE__ */ new Map();
|
|
246
|
-
let skippedLargeCommits = 0;
|
|
247
|
-
for (const [commit, files] of groupByCommit(entries, options.knownPaths)) {
|
|
248
|
-
if (files.length < 2) continue;
|
|
249
|
-
if (files.length > largeThreshold) {
|
|
250
|
-
skippedLargeCommits++;
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
accumulatePairs(commit, files, pairs, maxCommits);
|
|
254
|
-
}
|
|
255
|
-
return {
|
|
256
|
-
pairs: finalize(pairs, options.minCount ?? DEFAULT_MIN_COUNT),
|
|
257
|
-
skippedLargeCommits,
|
|
258
|
-
largeCommitThreshold: largeThreshold
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
function groupByCommit(entries, known) {
|
|
262
|
-
const out = /* @__PURE__ */ new Map();
|
|
263
|
-
for (const e of entries) {
|
|
264
|
-
if (known && !known.has(e.filePath)) continue;
|
|
265
|
-
let files = out.get(e.commit);
|
|
266
|
-
if (!files) {
|
|
267
|
-
files = [];
|
|
268
|
-
out.set(e.commit, files);
|
|
269
|
-
}
|
|
270
|
-
if (!files.includes(e.filePath)) files.push(e.filePath);
|
|
271
|
-
}
|
|
272
|
-
return out;
|
|
273
|
-
}
|
|
274
|
-
function accumulatePairs(commit, files, pairs, maxCommits) {
|
|
275
|
-
const sorted = [...files].sort();
|
|
276
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
277
|
-
for (let j = i + 1; j < sorted.length; j++) {
|
|
278
|
-
const key = `${sorted[i]} ${sorted[j]}`;
|
|
279
|
-
let acc = pairs.get(key);
|
|
280
|
-
if (!acc) {
|
|
281
|
-
acc = { count: 0, commits: [] };
|
|
282
|
-
pairs.set(key, acc);
|
|
283
|
-
}
|
|
284
|
-
acc.count++;
|
|
285
|
-
if (acc.commits.length < maxCommits) acc.commits.push(commit);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
function finalize(pairs, minCount) {
|
|
290
|
-
const out = [];
|
|
291
|
-
for (const [key, acc] of pairs) {
|
|
292
|
-
if (acc.count < minCount) continue;
|
|
293
|
-
const [a, b] = key.split(" ");
|
|
294
|
-
out.push({ fileA: a, fileB: b, count: acc.count, commits: acc.commits });
|
|
295
|
-
}
|
|
296
|
-
return out.sort(comparePairs);
|
|
297
|
-
}
|
|
298
|
-
function comparePairs(a, b) {
|
|
299
|
-
if (b.count !== a.count) return b.count - a.count;
|
|
300
|
-
if (a.fileA !== b.fileA) return a.fileA < b.fileA ? -1 : 1;
|
|
301
|
-
return a.fileB < b.fileB ? -1 : a.fileB > b.fileB ? 1 : 0;
|
|
302
|
-
}
|
|
303
|
-
function couplingFor(pairs, seed) {
|
|
304
|
-
const out = [];
|
|
305
|
-
for (const p of pairs) {
|
|
306
|
-
if (p.fileA === seed) out.push({ partner: p.fileB, count: p.count, commits: p.commits });
|
|
307
|
-
else if (p.fileB === seed) out.push({ partner: p.fileA, count: p.count, commits: p.commits });
|
|
308
|
-
}
|
|
309
|
-
out.sort((a, b) => {
|
|
310
|
-
if (b.count !== a.count) return b.count - a.count;
|
|
311
|
-
return a.partner < b.partner ? -1 : a.partner > b.partner ? 1 : 0;
|
|
312
|
-
});
|
|
313
|
-
return out;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
305
|
export {
|
|
317
306
|
runGit,
|
|
318
307
|
detectGitToplevel,
|
|
308
|
+
resolveCommit,
|
|
309
|
+
listTreeBlobs,
|
|
310
|
+
readBlobs,
|
|
319
311
|
loadChurnEntries,
|
|
320
312
|
parseChurnLog,
|
|
321
313
|
resolveRenamedPath,
|
|
@@ -325,8 +317,6 @@ export {
|
|
|
325
317
|
aggregateChurnWindows,
|
|
326
318
|
computeOwnership,
|
|
327
319
|
authorLinesByPath,
|
|
328
|
-
summarizeOwnership
|
|
329
|
-
computeChangeCoupling,
|
|
330
|
-
couplingFor
|
|
320
|
+
summarizeOwnership
|
|
331
321
|
};
|
|
332
|
-
//# sourceMappingURL=chunk-
|
|
322
|
+
//# sourceMappingURL=chunk-GX563F6R.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/history/log.ts","../src/history/git.ts","../src/history/window.ts","../src/history/git-tree.ts","../src/history/first-seen.ts","../src/history/churn.ts","../src/history/ownership.ts"],"sourcesContent":["import { realpathSync } from \"node:fs\";\nimport * as path from \"node:path\";\nimport { detectGitToplevel, runGitLarge } from \"./git.js\";\nimport { revArgs, sinceArgs, windowCutoff, type ChurnWindow } from \"./window.js\";\n\nexport interface ChurnEntry {\n commit: string;\n /** Author identity: git author email (%ae), stable across name spelling drift. */\n author: string;\n /** Committer time (%ct), epoch seconds; lets one wide log be sliced per window. */\n epoch: number;\n /** Repo-relative posix path, rebased onto the caller's root. */\n filePath: string;\n added: number;\n deleted: number;\n}\n\nexport interface LoadChurnOptions {\n /** Directory paths are made relative to; entries outside it are dropped. */\n repoRoot: string;\n windowDays?: ChurnWindow;\n /** Walk history from this rev instead of HEAD. */\n rev?: string;\n /** Epoch seconds a finite window ends at; defaults to the wall clock. */\n untilEpoch?: number;\n}\n\nexport const DEFAULT_WINDOW_DAYS = 30;\nexport const COMMIT_HASH_RE = /^[0-9a-f]{7,40}$/;\nconst NUMSTAT_FIRST_RE = /^(\\d+|-)$/;\n\n/**\n * Parse the last `windowDays` of git history up to `rev` (default HEAD) into ChurnEntry[] rebased onto\n * `repoRoot`. Returns null if git isn't available; [] if no commits matched.\n * Used both for churn metrics and for change-coupling.\n */\nexport function loadChurnEntries(options: LoadChurnOptions): ChurnEntry[] | null {\n const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;\n const gitRoot = detectGitToplevel(options.repoRoot);\n if (gitRoot === null) return null;\n const log = runChurnLog(options.repoRoot, windowDays, options.rev, options.untilEpoch);\n if (log === null) return null;\n const canonicalRoot = canonicalize(options.repoRoot);\n return parseChurnLog(log).flatMap((entry) => {\n const rel = rebasePath(entry.filePath, gitRoot, canonicalRoot);\n return rel === null ? [] : [{ ...entry, filePath: rel }];\n });\n}\n\n/** A toplevel-relative git path re-expressed relative to `rootDir`, or null when outside it. */\nexport function rebasePath(gitPath: string, gitRoot: string, rootDir: string): string | null {\n const rel = path.relative(rootDir, path.resolve(gitRoot, gitPath));\n if (rel.startsWith(\"..\") || path.isAbsolute(rel)) return null;\n return rel.split(path.sep).join(\"/\");\n}\n\nexport function canonicalize(p: string): string {\n try {\n return realpathSync(p);\n } catch {\n return path.resolve(p);\n }\n}\n\nexport function parseChurnLog(text: string): ChurnEntry[] {\n const out: ChurnEntry[] = [];\n let header: Pick<ChurnEntry, \"commit\" | \"author\" | \"epoch\"> | null = null;\n for (const rawLine of text.split(\"\\n\")) {\n const line = rawLine.trimEnd();\n if (!line) continue;\n const parts = line.split(\"\\t\");\n // A numstat row's two leading fields are number-or-`-`; a header's second field (an email) never is.\n if (parts.length === 3 && header && NUMSTAT_FIRST_RE.test(parts[0]!) && NUMSTAT_FIRST_RE.test(parts[1]!)) {\n out.push({ ...header, ...parseNumstat(parts), filePath: resolveRenamedPath(parts[2]!) });\n continue;\n }\n if (parts.length >= 2 && COMMIT_HASH_RE.test(parts[0]!)) {\n header = { commit: parts[0]!, author: parts[1]!, epoch: parts.length >= 3 ? Number(parts[2]) : 0 };\n }\n }\n return out;\n}\n\nfunction parseNumstat(parts: readonly string[]): { added: number; deleted: number } {\n return {\n added: parts[0] === \"-\" ? 0 : Number(parts[0]),\n deleted: parts[1] === \"-\" ? 0 : Number(parts[1]),\n };\n}\n\nexport function resolveRenamedPath(rawPath: string): string {\n const braceMatch = /^(.*)\\{(.*) => (.*)\\}(.*)$/.exec(rawPath);\n if (braceMatch) {\n const [, prefix, , newSeg, suffix] = braceMatch;\n return `${prefix}${newSeg}${suffix}`.replace(/\\/+/g, \"/\");\n }\n const arrow = rawPath.indexOf(\" => \");\n if (arrow >= 0) return rawPath.slice(arrow + 4);\n return rawPath;\n}\n\n/** Entries whose commit landed within the last `windowDays` of `nowEpoch`. */\nexport function entriesWithin(entries: readonly ChurnEntry[], windowDays: number, nowEpoch: number): ChurnEntry[] {\n const cutoff = windowCutoff(windowDays, nowEpoch);\n return entries.filter((e) => e.epoch >= cutoff);\n}\n\nfunction runChurnLog(repoRoot: string, windowDays: ChurnWindow, rev?: string, untilEpoch?: number): string | null {\n // %ae is a steadier identity than %an; %ct lets one wide log be sliced into narrower windows.\n const format = \"--pretty=format:%H%x09%ae%x09%ct\";\n const args = [\"log\", ...sinceArgs(windowDays, untilEpoch), \"--no-merges\", \"--numstat\", \"-M\", format, ...revArgs(rev)];\n return runGitLarge(repoRoot, args, 64 * 1024 * 1024);\n}\n","import { execFileSync } from \"node:child_process\";\n\n/**\n * Strip the inherited GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE vars when we\n * invoke git as a *repo-discovery* sub-tool. Those vars are set by git itself\n * when running hooks (and by some IDE integrations) to point the *parent* git\n * at a specific repo; if they leak into our subprocess, `git rev-parse\n * --show-toplevel` returns cwd rather than the real toplevel, and other\n * discovery commands misbehave similarly.\n */\nexport function discoveryEnv(): NodeJS.ProcessEnv {\n const env = { ...process.env };\n delete env.GIT_DIR;\n delete env.GIT_INDEX_FILE;\n delete env.GIT_WORK_TREE;\n return env;\n}\n\n/** Run a short git command and return its trimmed stdout, or null on any failure. */\nexport function runGit(cwd: string, args: readonly string[]): string | null {\n try {\n return execFileSync(\"git\", [...args], {\n cwd,\n encoding: \"utf-8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n env: discoveryEnv(),\n }).trim();\n } catch {\n return null;\n }\n}\n\nexport function detectGitToplevel(cwd: string): string | null {\n return runGit(cwd, [\"rev-parse\", \"--show-toplevel\"]);\n}\n\n/** Run a git command whose output can be large; null when git is missing or fails. */\nexport function runGitLarge(cwd: string, args: readonly string[], maxBuffer: number): string | null {\n try {\n return execFileSync(\"git\", [...args], {\n cwd,\n encoding: \"utf-8\",\n maxBuffer,\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n env: discoveryEnv(),\n });\n } catch {\n return null;\n }\n}\n","/**\n * A churn window is either a rolling N-day window or `\"lifetime\"` = all of git\n * history (no `--since` bound). Lifetime mode lets an unfamiliar repo be audited\n * cold, where any recent rolling slice is thin relative to the repo's whole\n * life, so a windowed view reads an established repo as nearly churn-free.\n */\nexport type ChurnWindow = number | \"lifetime\";\n\nexport const DAY_SECONDS = 86400;\n\n/** Lower bound (epoch seconds) of a finite window ending at `nowEpoch`. */\nexport function windowCutoff(windowDays: number, nowEpoch: number): number {\n return nowEpoch - windowDays * DAY_SECONDS;\n}\n\n/**\n * `git log` arguments bounding a window: none for lifetime, a cutoff at `untilEpoch`\n * when given, else relative to the wall clock.\n */\nexport function sinceArgs(windowDays: ChurnWindow, untilEpoch?: number): string[] {\n if (windowDays === \"lifetime\") return [];\n if (untilEpoch === undefined) return [`--since=${windowDays}.days.ago`];\n return [`--since=@${windowCutoff(windowDays, untilEpoch)}`];\n}\n\n/** The rev to walk from, terminated by `--` so it never reads as a path; none walks from HEAD. */\nexport function revArgs(rev?: string): string[] {\n return rev === undefined ? [] : [rev, \"--\"];\n}\n","import { execFileSync } from \"node:child_process\";\nimport { discoveryEnv, runGit, runGitLarge } from \"./git.js\";\n\n/** A regular file or symlink in a commit's tree: its repo-relative path and blob id. */\nexport interface TreeBlob {\n path: string;\n oid: string;\n /** A symlink (mode 120000): the blob holds the link's target. */\n link: boolean;\n}\n\nexport interface ResolvedCommit {\n commit: string;\n /** Committer time, seconds since the epoch. */\n commitEpoch: number;\n}\n\nconst TREE_LIST_BUFFER = 256 * 1024 * 1024;\nconst BLOB_READ_BUFFER = 1024 * 1024 * 1024;\nconst REGULAR_FILE_MODES = new Set([\"100644\", \"100755\"]);\nconst SYMLINK_MODE = \"120000\";\n\n/** Resolve `rev` to a commit sha and its committer epoch; throws naming `rev` when it is not a commit. */\nexport function resolveCommit(repoRoot: string, rev: string): ResolvedCommit {\n const commit = runGit(repoRoot, [\"rev-parse\", \"--verify\", \"--quiet\", `${rev}^{commit}`]);\n if (!commit) throw new Error(`Unknown git revision \"${rev}\": it does not name a commit in ${repoRoot}`);\n const epoch = Number(runGit(repoRoot, [\"show\", \"-s\", \"--format=%ct\", commit]));\n if (!Number.isFinite(epoch)) throw new Error(`Could not read the commit time of ${commit} in ${repoRoot}`);\n return { commit, commitEpoch: epoch };\n}\n\n/** Every regular file and symlink in `commit`'s tree, via `git ls-tree -r -z`. */\nexport function listTreeBlobs(repoRoot: string, commit: string): TreeBlob[] {\n const out = runGitLarge(repoRoot, [\"ls-tree\", \"-r\", \"-z\", \"--full-tree\", commit], TREE_LIST_BUFFER);\n if (out === null) throw new Error(`git ls-tree failed for ${commit} in ${repoRoot}`);\n return parseLsTree(out);\n}\n\n/** Parse NUL-terminated `<mode> <type> <oid>\\t<path>` records, keeping regular files and symlinks; submodules are dropped. */\nexport function parseLsTree(text: string): TreeBlob[] {\n const blobs: TreeBlob[] = [];\n for (const record of text.split(\"\\0\")) {\n const tab = record.indexOf(\"\\t\");\n if (tab < 0) continue;\n const [mode, type, oid] = record.slice(0, tab).split(\" \");\n if (type !== \"blob\" || !oid) continue;\n const link = mode === SYMLINK_MODE;\n if (link || REGULAR_FILE_MODES.has(mode!)) blobs.push({ path: record.slice(tab + 1), oid, link });\n }\n return blobs;\n}\n\n/** UTF-8 content of each blob in `oids`, read with a single `git cat-file --batch`. */\nexport function readBlobs(repoRoot: string, oids: readonly string[]): Map<string, string> {\n const unique = [...new Set(oids)];\n if (unique.length === 0) return new Map();\n const out = execFileSync(\"git\", [\"cat-file\", \"--batch\"], {\n cwd: repoRoot,\n input: unique.map((oid) => `${oid}\\n`).join(\"\"),\n maxBuffer: BLOB_READ_BUFFER,\n stdio: [\"pipe\", \"pipe\", \"ignore\"],\n env: discoveryEnv(),\n });\n return parseCatFileBatch(out);\n}\n\n/** Parse `<oid> <type> <size>\\n<bytes>\\n` records; sizes are byte counts, so this walks a Buffer. */\nexport function parseCatFileBatch(out: Buffer): Map<string, string> {\n const blobs = new Map<string, string>();\n let at = 0;\n while (at < out.length) {\n const eol = out.indexOf(0x0a, at);\n if (eol < 0) break;\n const [oid, type, size] = out.toString(\"utf-8\", at, eol).split(\" \");\n if (type === \"missing\" || size === undefined) throw new Error(`git object ${oid} is missing`);\n const start = eol + 1;\n const end = start + Number(size);\n blobs.set(oid!, out.toString(\"utf-8\", start, end));\n at = end + 1;\n }\n return blobs;\n}\n","import { detectGitToplevel, runGitLarge } from \"./git.js\";\nimport { revArgs } from \"./window.js\";\nimport { canonicalize, COMMIT_HASH_RE, rebasePath, resolveRenamedPath } from \"./log.js\";\n\nexport interface FirstSeenOptions {\n /** Directory paths are made relative to; paths outside it are dropped. */\n repoRoot: string;\n knownPaths?: ReadonlySet<string>;\n /** Walk history from this rev instead of HEAD. */\n rev?: string;\n}\n\n/**\n * Earliest commit time (epoch seconds) per path, from one reverse-ordered pass\n * over full history up to `rev` (default HEAD): the first commit that touched a path approximates its birth.\n * Rebased onto `repoRoot` and filtered to `knownPaths` when given. Renames slightly\n * underestimate age (pre-rename history lives under the old path). Returns null\n * when git is unavailable, so callers degrade to \"no age discount\".\n */\nexport function loadFileFirstSeen(options: FirstSeenOptions): Map<string, number> | null {\n const gitRoot = detectGitToplevel(options.repoRoot);\n if (gitRoot === null) return null;\n const log = runFirstSeenLog(options.repoRoot, options.rev);\n if (log === null) return null;\n const canonicalRoot = canonicalize(options.repoRoot);\n const firstSeen = new Map<string, number>();\n for (const { epoch, gitPath } of parseFirstSeenLog(log)) {\n const rel = rebasePath(resolveRenamedPath(gitPath), gitRoot, canonicalRoot);\n if (rel === null) continue;\n if (options.knownPaths && !options.knownPaths.has(rel)) continue;\n if (!firstSeen.has(rel)) firstSeen.set(rel, epoch); // --reverse, so the first sighting wins\n }\n return firstSeen;\n}\n\n/** (commit epoch, touched path) rows from `%H%x09%ct` headers followed by `--name-only` paths. */\nexport function parseFirstSeenLog(text: string): { epoch: number; gitPath: string }[] {\n const out: { epoch: number; gitPath: string }[] = [];\n let epoch = 0;\n for (const rawLine of text.split(\"\\n\")) {\n const line = rawLine.trimEnd();\n if (!line) continue;\n const parts = line.split(\"\\t\");\n if (parts.length === 2 && COMMIT_HASH_RE.test(parts[0]!)) {\n epoch = Number(parts[1]);\n continue;\n }\n if (epoch) out.push({ epoch, gitPath: line });\n }\n return out;\n}\n\n/** `--reverse` so the first time a path is seen is its birth; `--no-renames` keeps name lines bare. */\nfunction runFirstSeenLog(repoRoot: string, rev?: string): string | null {\n const args = [\"log\", \"--reverse\", \"--no-merges\", \"--no-renames\", \"--name-only\", \"--pretty=format:%H%x09%ct\", ...revArgs(rev)];\n return runGitLarge(repoRoot, args, 128 * 1024 * 1024);\n}\n","import { entriesWithin, type ChurnEntry } from \"./log.js\";\nimport type { ChurnWindow } from \"./window.js\";\n\n/** One path's churn over a set of entries. */\nexport interface PathChurn {\n /** Lines added plus lines deleted. */\n lines: number;\n /** Distinct commits that touched the path. */\n commits: number;\n /** Distinct author identities that touched the path. */\n authors: number;\n}\n\n/** Per-path churn, in first-touched order, filtered to `knownPaths` when given. */\nexport function aggregateChurn(\n entries: readonly ChurnEntry[],\n knownPaths?: ReadonlySet<string>,\n): Map<string, PathChurn> {\n const lines = new Map<string, number>();\n const commits = new Map<string, Set<string>>();\n const authors = new Map<string, Set<string>>();\n for (const e of entries) {\n if (knownPaths && !knownPaths.has(e.filePath)) continue;\n lines.set(e.filePath, (lines.get(e.filePath) ?? 0) + e.added + e.deleted);\n setAdd(commits, e.filePath, e.commit);\n setAdd(authors, e.filePath, e.author);\n }\n const out = new Map<string, PathChurn>();\n for (const [filePath, total] of lines) {\n out.set(filePath, {\n lines: total,\n commits: commits.get(filePath)!.size,\n authors: authors.get(filePath)!.size,\n });\n }\n return out;\n}\n\n/**\n * Churn for several windows from one wide log: slice `entries` by commit time\n * per window and aggregate each slice independently, so 30/90/180-day churn can\n * be stored side by side from a single git pass.\n */\nexport function aggregateChurnWindows(\n entries: readonly ChurnEntry[],\n windowsDays: readonly ChurnWindow[],\n nowEpoch: number,\n knownPaths?: ReadonlySet<string>,\n): Map<ChurnWindow, Map<string, PathChurn>> {\n const out = new Map<ChurnWindow, Map<string, PathChurn>>();\n for (const windowDays of windowsDays) {\n // Lifetime is the whole wide log (all history); finite windows slice by time.\n const within = windowDays === \"lifetime\" ? entries : entriesWithin(entries, windowDays, nowEpoch);\n out.set(windowDays, aggregateChurn(within, knownPaths));\n }\n return out;\n}\n\nfunction setAdd(map: Map<string, Set<string>>, key: string, value: string): void {\n let set = map.get(key);\n if (!set) {\n set = new Set();\n map.set(key, set);\n }\n set.add(value);\n}\n","import type { ChurnEntry } from \"./log.js\";\n\nexport interface OwnershipForFile {\n /** Distinct contributing authors. */\n authors: number;\n /** Fraction of churn (lines) from the single largest contributor (0..1), unrounded. */\n topAuthorShare: number;\n /** Min number of authors whose combined contribution covers >= the threshold of churn. */\n busFactor: number;\n}\n\nexport interface ComputeOwnershipOptions {\n knownPaths?: ReadonlySet<string>;\n /** Coverage threshold for busFactor (default 0.5 = 50% of churn). */\n busFactorThreshold?: number;\n}\n\nexport const DEFAULT_BUS_FACTOR_THRESHOLD = 0.5;\n\n/** Ownership per path with non-zero churn, keyed by repo-relative path. */\nexport function computeOwnership(\n entries: readonly ChurnEntry[],\n options: ComputeOwnershipOptions = {},\n): Map<string, OwnershipForFile> {\n const threshold = options.busFactorThreshold ?? DEFAULT_BUS_FACTOR_THRESHOLD;\n const out = new Map<string, OwnershipForFile>();\n for (const [filePath, byAuthor] of authorLinesByPath(entries, options.knownPaths)) {\n const summary = summarizeOwnership(byAuthor, threshold);\n if (summary !== null) out.set(filePath, summary);\n }\n return out;\n}\n\n/** Lines changed per author per path, skipping zero-line entries (binary files). */\nexport function authorLinesByPath(\n entries: readonly ChurnEntry[],\n known: ReadonlySet<string> | undefined,\n): Map<string, Map<string, number>> {\n const out = new Map<string, Map<string, number>>();\n for (const e of entries) {\n if (known && !known.has(e.filePath)) continue;\n const lines = e.added + e.deleted;\n if (lines === 0) continue;\n let byAuthor = out.get(e.filePath);\n if (!byAuthor) {\n byAuthor = new Map();\n out.set(e.filePath, byAuthor);\n }\n byAuthor.set(e.author, (byAuthor.get(e.author) ?? 0) + lines);\n }\n return out;\n}\n\n/** Summarize one author tally; null when it holds no churn. */\nexport function summarizeOwnership(\n byAuthor: ReadonlyMap<string, number>,\n threshold: number = DEFAULT_BUS_FACTOR_THRESHOLD,\n): OwnershipForFile | null {\n let total = 0;\n for (const v of byAuthor.values()) total += v;\n if (total === 0) return null;\n const sorted = [...byAuthor.values()].sort((a, b) => b - a);\n const topAuthorShare = sorted[0]! / total;\n const busFactor = minAuthorsToReach(sorted, total, threshold);\n return { authors: sorted.length, topAuthorShare, busFactor };\n}\n\nfunction minAuthorsToReach(sortedDesc: readonly number[], total: number, threshold: number): number {\n let acc = 0;\n for (let i = 0; i < sortedDesc.length; i++) {\n acc += sortedDesc[i]!;\n if (acc / total >= threshold) return i + 1;\n }\n return sortedDesc.length;\n}\n"],"mappings":"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","names":["execFileSync","execFileSync"]}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/history/change-coupling.ts
|
|
2
|
+
var DEFAULT_MIN_COUNT = 2;
|
|
3
|
+
var DEFAULT_MAX_COMMITS_PER_PAIR = 10;
|
|
4
|
+
var DEFAULT_LARGE_COMMIT_THRESHOLD = 50;
|
|
5
|
+
function computeChangeCoupling(entries, options = {}) {
|
|
6
|
+
const maxCommits = options.maxCommitsPerPair ?? DEFAULT_MAX_COMMITS_PER_PAIR;
|
|
7
|
+
const largeThreshold = options.largeCommitThreshold ?? DEFAULT_LARGE_COMMIT_THRESHOLD;
|
|
8
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
9
|
+
let skippedLargeCommits = 0;
|
|
10
|
+
for (const [commit, files] of groupByCommit(entries, options.knownPaths)) {
|
|
11
|
+
if (files.length < 2) continue;
|
|
12
|
+
if (files.length > largeThreshold) {
|
|
13
|
+
skippedLargeCommits++;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
accumulatePairs(commit, files, pairs, maxCommits);
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
pairs: finalize(pairs, options.minCount ?? DEFAULT_MIN_COUNT),
|
|
20
|
+
skippedLargeCommits,
|
|
21
|
+
largeCommitThreshold: largeThreshold
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function groupByCommit(entries, known) {
|
|
25
|
+
const out = /* @__PURE__ */ new Map();
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
if (known && !known.has(e.filePath)) continue;
|
|
28
|
+
let files = out.get(e.commit);
|
|
29
|
+
if (!files) {
|
|
30
|
+
files = [];
|
|
31
|
+
out.set(e.commit, files);
|
|
32
|
+
}
|
|
33
|
+
if (!files.includes(e.filePath)) files.push(e.filePath);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function accumulatePairs(commit, files, pairs, maxCommits) {
|
|
38
|
+
const sorted = [...files].sort();
|
|
39
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
40
|
+
for (let j = i + 1; j < sorted.length; j++) {
|
|
41
|
+
const key = `${sorted[i]} ${sorted[j]}`;
|
|
42
|
+
let acc = pairs.get(key);
|
|
43
|
+
if (!acc) {
|
|
44
|
+
acc = { count: 0, commits: [] };
|
|
45
|
+
pairs.set(key, acc);
|
|
46
|
+
}
|
|
47
|
+
acc.count++;
|
|
48
|
+
if (acc.commits.length < maxCommits) acc.commits.push(commit);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function finalize(pairs, minCount) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const [key, acc] of pairs) {
|
|
55
|
+
if (acc.count < minCount) continue;
|
|
56
|
+
const [a, b] = key.split(" ");
|
|
57
|
+
out.push({ fileA: a, fileB: b, count: acc.count, commits: acc.commits });
|
|
58
|
+
}
|
|
59
|
+
return out.sort(comparePairs);
|
|
60
|
+
}
|
|
61
|
+
function comparePairs(a, b) {
|
|
62
|
+
if (b.count !== a.count) return b.count - a.count;
|
|
63
|
+
if (a.fileA !== b.fileA) return a.fileA < b.fileA ? -1 : 1;
|
|
64
|
+
return a.fileB < b.fileB ? -1 : a.fileB > b.fileB ? 1 : 0;
|
|
65
|
+
}
|
|
66
|
+
function couplingFor(pairs, seed) {
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const p of pairs) {
|
|
69
|
+
if (p.fileA === seed) out.push({ partner: p.fileB, count: p.count, commits: p.commits });
|
|
70
|
+
else if (p.fileB === seed) out.push({ partner: p.fileA, count: p.count, commits: p.commits });
|
|
71
|
+
}
|
|
72
|
+
out.sort((a, b) => {
|
|
73
|
+
if (b.count !== a.count) return b.count - a.count;
|
|
74
|
+
return a.partner < b.partner ? -1 : a.partner > b.partner ? 1 : 0;
|
|
75
|
+
});
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export {
|
|
80
|
+
computeChangeCoupling,
|
|
81
|
+
couplingFor
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=chunk-HIVVDGWE.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/history/change-coupling.ts"],"sourcesContent":["import type { ChurnEntry } from \"./log.js\";\n\nexport interface CoEditPair {\n fileA: string;\n fileB: string;\n /** Commits in which both files changed. */\n count: number;\n /** Most recent commits (up to maxCommitsPerPair). */\n commits: string[];\n}\n\nexport interface ComputeChangeCouplingOptions {\n /** Skip pairs that co-occur in fewer than this many commits. Default 2. */\n minCount?: number;\n /** Truncate the commits sample per pair. Default 10. */\n maxCommitsPerPair?: number;\n /**\n * Skip commits touching more than this many files (sweeping refactors, mass\n * renames). Default 50: beyond that the O(n²) pair explosion is signal-poor.\n */\n largeCommitThreshold?: number;\n /** Filter file paths to this set before pairing. */\n knownPaths?: ReadonlySet<string>;\n}\n\nexport interface ChangeCouplingResult {\n pairs: CoEditPair[];\n /** Commits dropped by largeCommitThreshold, so callers can say when sweeps suppressed the signal. */\n skippedLargeCommits: number;\n /** Threshold that was applied (echoed for diagnostic messages). */\n largeCommitThreshold: number;\n}\n\nexport interface CouplingPartner {\n partner: string;\n count: number;\n commits: string[];\n}\n\ninterface PairAccum {\n count: number;\n commits: string[];\n}\n\nconst DEFAULT_MIN_COUNT = 2;\nconst DEFAULT_MAX_COMMITS_PER_PAIR = 10;\nconst DEFAULT_LARGE_COMMIT_THRESHOLD = 50;\n\nexport function computeChangeCoupling(\n entries: readonly ChurnEntry[],\n options: ComputeChangeCouplingOptions = {},\n): ChangeCouplingResult {\n const maxCommits = options.maxCommitsPerPair ?? DEFAULT_MAX_COMMITS_PER_PAIR;\n const largeThreshold = options.largeCommitThreshold ?? DEFAULT_LARGE_COMMIT_THRESHOLD;\n const pairs = new Map<string, PairAccum>();\n let skippedLargeCommits = 0;\n for (const [commit, files] of groupByCommit(entries, options.knownPaths)) {\n if (files.length < 2) continue;\n if (files.length > largeThreshold) {\n skippedLargeCommits++;\n continue;\n }\n accumulatePairs(commit, files, pairs, maxCommits);\n }\n return {\n pairs: finalize(pairs, options.minCount ?? DEFAULT_MIN_COUNT),\n skippedLargeCommits,\n largeCommitThreshold: largeThreshold,\n };\n}\n\nfunction groupByCommit(entries: readonly ChurnEntry[], known: ReadonlySet<string> | undefined): Map<string, string[]> {\n const out = new Map<string, string[]>();\n for (const e of entries) {\n if (known && !known.has(e.filePath)) continue;\n let files = out.get(e.commit);\n if (!files) {\n files = [];\n out.set(e.commit, files);\n }\n if (!files.includes(e.filePath)) files.push(e.filePath);\n }\n return out;\n}\n\nfunction accumulatePairs(commit: string, files: readonly string[], pairs: Map<string, PairAccum>, maxCommits: number): void {\n const sorted = [...files].sort();\n for (let i = 0; i < sorted.length; i++) {\n for (let j = i + 1; j < sorted.length; j++) {\n const key = `${sorted[i]!}\\t${sorted[j]!}`;\n let acc = pairs.get(key);\n if (!acc) {\n acc = { count: 0, commits: [] };\n pairs.set(key, acc);\n }\n acc.count++;\n if (acc.commits.length < maxCommits) acc.commits.push(commit);\n }\n }\n}\n\nfunction finalize(pairs: ReadonlyMap<string, PairAccum>, minCount: number): CoEditPair[] {\n const out: CoEditPair[] = [];\n for (const [key, acc] of pairs) {\n if (acc.count < minCount) continue;\n const [a, b] = key.split(\"\\t\");\n out.push({ fileA: a!, fileB: b!, count: acc.count, commits: acc.commits });\n }\n return out.sort(comparePairs);\n}\n\nfunction comparePairs(a: CoEditPair, b: CoEditPair): number {\n if (b.count !== a.count) return b.count - a.count;\n if (a.fileA !== b.fileA) return a.fileA < b.fileA ? -1 : 1;\n return a.fileB < b.fileB ? -1 : a.fileB > b.fileB ? 1 : 0;\n}\n\n/** Files most coupled to `seed`, by co-edit count desc then path; the seed itself is excluded. */\nexport function couplingFor(pairs: readonly CoEditPair[], seed: string): CouplingPartner[] {\n const out: CouplingPartner[] = [];\n for (const p of pairs) {\n if (p.fileA === seed) out.push({ partner: p.fileB, count: p.count, commits: p.commits });\n else if (p.fileB === seed) out.push({ partner: p.fileA, count: p.count, commits: p.commits });\n }\n out.sort((a, b) => {\n if (b.count !== a.count) return b.count - a.count;\n return a.partner < b.partner ? -1 : a.partner > b.partner ? 1 : 0;\n });\n return out;\n}\n"],"mappings":"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","names":[]}
|