pi-fovea 0.21.2 → 0.22.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 +11 -0
- package/dist/cli.mjs +113 -60
- package/package.json +1 -1
- package/src/core/cochange.ts +101 -55
- package/src/core/git.ts +1 -1
package/README.md
CHANGED
|
@@ -259,6 +259,17 @@ partners, and decays with wall-clock time. Edits that resist symbol-level
|
|
|
259
259
|
location fall back to the old file-node seed. New files, deletions, renames,
|
|
260
260
|
untracked paths, and oversized diffs all take that path.
|
|
261
261
|
|
|
262
|
+
Historical co-change is a decaying **heat prior**, never a permanent graph edge.
|
|
263
|
+
It counts up to 400 first-parent integration boundaries: merge net changes count
|
|
264
|
+
once, not again through their constituent commits. Explicit `fixup!`/`squash!`
|
|
265
|
+
followups join only uniquely resolved older subjects in that window. Time,
|
|
266
|
+
author, shared issue numbers, and unlabeled "forgot this" do not group work.
|
|
267
|
+
Boundaries are not proof of a semantic feature (release merges can mix work).
|
|
268
|
+
Aggregates above 24 tracked files emit no pairs but retain directional touch
|
|
269
|
+
counts; two distinct retained units are needed for a pair, three for expectations.
|
|
270
|
+
Raw history caching includes shallow-state identity; recency still applies at use.
|
|
271
|
+
Focus, sketch, and the structural diffusion operator remain unchanged.
|
|
272
|
+
|
|
262
273
|
The obligation ledger keeps the list. Every cascade merges its per-file
|
|
263
274
|
residual mass into a session epoch. Entries stay until evidence moves them. A
|
|
264
275
|
read marks `inspected`. An edit marks `changed` and raises the generation. A
|
package/dist/cli.mjs
CHANGED
|
@@ -46,7 +46,7 @@ var Semaphore = class {
|
|
|
46
46
|
waiters = [];
|
|
47
47
|
async run(fn) {
|
|
48
48
|
if (this.active >= this.limit) {
|
|
49
|
-
await new Promise((
|
|
49
|
+
await new Promise((resolve5) => this.waiters.push(resolve5));
|
|
50
50
|
}
|
|
51
51
|
this.active++;
|
|
52
52
|
try {
|
|
@@ -58,7 +58,7 @@ var Semaphore = class {
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
var spawnGate = new Semaphore(SPAWN_CONCURRENCY);
|
|
61
|
-
var yieldToLoop = () => new Promise((
|
|
61
|
+
var yieldToLoop = () => new Promise((resolve5) => setImmediate(resolve5));
|
|
62
62
|
var forEachChunked = async (items, batchSize, fn) => {
|
|
63
63
|
for (let i = 0; i < items.length; i++) {
|
|
64
64
|
if (i > 0 && i % batchSize === 0) await yieldToLoop();
|
|
@@ -69,7 +69,7 @@ var forEachChunked = async (items, batchSize, fn) => {
|
|
|
69
69
|
// src/core/git.ts
|
|
70
70
|
var GIT_TIMEOUT = 15e3;
|
|
71
71
|
var gitOut = async (root, args, opts = {}) => spawnGate.run(
|
|
72
|
-
() => new Promise((
|
|
72
|
+
() => new Promise((resolve5) => {
|
|
73
73
|
execFile(
|
|
74
74
|
"git",
|
|
75
75
|
["-C", root, ...args],
|
|
@@ -80,10 +80,10 @@ var gitOut = async (root, args, opts = {}) => spawnGate.run(
|
|
|
80
80
|
},
|
|
81
81
|
(error, stdout) => {
|
|
82
82
|
if (error) {
|
|
83
|
-
|
|
83
|
+
resolve5(void 0);
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
|
-
|
|
86
|
+
resolve5(stdout);
|
|
87
87
|
}
|
|
88
88
|
);
|
|
89
89
|
})
|
|
@@ -1014,9 +1014,9 @@ var hasAstGrepAsync = () => {
|
|
|
1014
1014
|
const pending = availabilityInflight.get(bin);
|
|
1015
1015
|
if (pending) return pending;
|
|
1016
1016
|
const probe = spawnGate.run(
|
|
1017
|
-
() => new Promise((
|
|
1017
|
+
() => new Promise((resolve5) => {
|
|
1018
1018
|
execFile2(bin, ["--version"], { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024 }, (error) => {
|
|
1019
|
-
|
|
1019
|
+
resolve5(!error);
|
|
1020
1020
|
});
|
|
1021
1021
|
})
|
|
1022
1022
|
).then((ok) => {
|
|
@@ -1035,30 +1035,30 @@ var drainExtractionFailures = () => failures.splice(0, failures.length);
|
|
|
1035
1035
|
var RUN_TIMEOUT = 12e4;
|
|
1036
1036
|
var RUN_MAX_BUFFER = 16 * 1024 * 1024;
|
|
1037
1037
|
var run = async (args, cwd) => spawnGate.run(
|
|
1038
|
-
() => new Promise((
|
|
1038
|
+
() => new Promise((resolve5) => {
|
|
1039
1039
|
execFile2(
|
|
1040
1040
|
binary(),
|
|
1041
1041
|
args,
|
|
1042
1042
|
{ cwd, encoding: "utf8", timeout: RUN_TIMEOUT, maxBuffer: RUN_MAX_BUFFER },
|
|
1043
1043
|
(error, stdout, stderr) => {
|
|
1044
1044
|
if (error && (error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || error.code === "E2BIG")) {
|
|
1045
|
-
|
|
1045
|
+
resolve5({ ok: false, stdout: "", split: true });
|
|
1046
1046
|
return;
|
|
1047
1047
|
}
|
|
1048
1048
|
if (error && typeof error.code !== "number") {
|
|
1049
|
-
|
|
1049
|
+
resolve5({ ok: false, stdout: "", split: false });
|
|
1050
1050
|
return;
|
|
1051
1051
|
}
|
|
1052
1052
|
const status = error && typeof error.code === "number" ? error.code : 0;
|
|
1053
1053
|
if (status !== 0) {
|
|
1054
1054
|
if ((stderr ?? "").trim()) {
|
|
1055
|
-
|
|
1055
|
+
resolve5({ ok: false, stdout: "", split: false });
|
|
1056
1056
|
return;
|
|
1057
1057
|
}
|
|
1058
|
-
|
|
1058
|
+
resolve5({ ok: true, stdout: "", split: false });
|
|
1059
1059
|
return;
|
|
1060
1060
|
}
|
|
1061
|
-
|
|
1061
|
+
resolve5({ ok: true, stdout: stdout ?? "", split: false });
|
|
1062
1062
|
}
|
|
1063
1063
|
);
|
|
1064
1064
|
})
|
|
@@ -1150,9 +1150,9 @@ var hasRuleScan = () => {
|
|
|
1150
1150
|
const pending = scanSupportInflight.get(bin);
|
|
1151
1151
|
if (pending) return pending;
|
|
1152
1152
|
const probe = spawnGate.run(
|
|
1153
|
-
() => new Promise((
|
|
1153
|
+
() => new Promise((resolve5) => {
|
|
1154
1154
|
execFile2(bin, ["scan", "--help"], { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024 }, (error) => {
|
|
1155
|
-
|
|
1155
|
+
resolve5(!error);
|
|
1156
1156
|
});
|
|
1157
1157
|
})
|
|
1158
1158
|
).then((ok) => {
|
|
@@ -1186,7 +1186,7 @@ var materializeRuleFile = (rules) => {
|
|
|
1186
1186
|
return pending;
|
|
1187
1187
|
};
|
|
1188
1188
|
var scanChunk = (rulePath, files, cwd) => spawnGate.run(
|
|
1189
|
-
() => new Promise((
|
|
1189
|
+
() => new Promise((resolve5) => {
|
|
1190
1190
|
const child = spawn(
|
|
1191
1191
|
binary(),
|
|
1192
1192
|
["scan", "--rule", rulePath, "--json=stream", ...files],
|
|
@@ -1201,7 +1201,7 @@ var scanChunk = (rulePath, files, cwd) => spawnGate.run(
|
|
|
1201
1201
|
if (settled) return;
|
|
1202
1202
|
settled = true;
|
|
1203
1203
|
clearTimeout(timer);
|
|
1204
|
-
|
|
1204
|
+
resolve5(value);
|
|
1205
1205
|
};
|
|
1206
1206
|
const accept = (line) => {
|
|
1207
1207
|
if (!line || parseFailed) return;
|
|
@@ -1979,12 +1979,12 @@ var buildJoinIndex = (sites, resolveOccurrence) => {
|
|
|
1979
1979
|
import { createHash } from "node:crypto";
|
|
1980
1980
|
import { mkdir, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1981
1981
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
1982
|
-
import { dirname as dirname2, join as joinPath } from "node:path";
|
|
1982
|
+
import { dirname as dirname2, resolve as resolve3, join as joinPath } from "node:path";
|
|
1983
1983
|
var LOG_COMMITS = 400;
|
|
1984
|
-
var
|
|
1984
|
+
var MAX_FILES_PER_UNIT = 24;
|
|
1985
1985
|
var MIN_SHARED = 2;
|
|
1986
1986
|
var MAX_PAIRS_PER_FILE = 16;
|
|
1987
|
-
var COCHANGE_CACHE_VERSION =
|
|
1987
|
+
var COCHANGE_CACHE_VERSION = 4;
|
|
1988
1988
|
var EXPECTATION_MIN_SUPPORT = 3;
|
|
1989
1989
|
var WILSON_Z_95 = 1.96;
|
|
1990
1990
|
var DAY_MS = 864e5;
|
|
@@ -2040,63 +2040,116 @@ var coChangeHistory = async (root, filesInGraph, now = Date.now()) => {
|
|
|
2040
2040
|
if (!head) return /* @__PURE__ */ new Map();
|
|
2041
2041
|
const prefix = await gitPrefix(root);
|
|
2042
2042
|
if (prefix === void 0) return /* @__PURE__ */ new Map();
|
|
2043
|
+
const shallowPath = await gitOut(root, ["rev-parse", "--git-path", "shallow"]);
|
|
2044
|
+
if (shallowPath === void 0) return /* @__PURE__ */ new Map();
|
|
2045
|
+
let shallowText = "";
|
|
2046
|
+
try {
|
|
2047
|
+
shallowText = await readFile2(resolve3(root, shallowPath.trim()), "utf8");
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
if (error.code !== "ENOENT") return /* @__PURE__ */ new Map();
|
|
2050
|
+
}
|
|
2051
|
+
const shallow = new Set(shallowText.trim().split(/\s+/));
|
|
2043
2052
|
const tracked = new Set(filesInGraph);
|
|
2044
|
-
const key = createHash("sha1").update(`v${COCHANGE_CACHE_VERSION}\0`).update([...tracked].sort()
|
|
2053
|
+
const key = createHash("sha1").update(`v${COCHANGE_CACHE_VERSION}\0`).update(shallowText).update(JSON.stringify([...tracked].sort())).digest("hex").slice(0, 12);
|
|
2045
2054
|
const cp = cachePath(root);
|
|
2046
2055
|
try {
|
|
2047
2056
|
const cached = JSON.parse(await readFile2(cp, "utf8"));
|
|
2048
2057
|
if (cached.v === COCHANGE_CACHE_VERSION && cached.head === head && cached.key === key && Number.isInteger(cached.commits) && Array.isArray(cached.pairs)) return groupPairs(cached.pairs, cached.commits);
|
|
2049
2058
|
} catch {
|
|
2050
2059
|
}
|
|
2051
|
-
const log = await gitOut(root, [
|
|
2060
|
+
const log = await gitOut(root, [
|
|
2061
|
+
"log",
|
|
2062
|
+
"--first-parent",
|
|
2063
|
+
"--diff-merges=first-parent",
|
|
2064
|
+
"--root",
|
|
2065
|
+
"--format=%x00FOVEA%x00%H%x00%P%x00%ct%x00%s%x00",
|
|
2066
|
+
"--name-status",
|
|
2067
|
+
"-z",
|
|
2068
|
+
"-n",
|
|
2069
|
+
String(LOG_COMMITS),
|
|
2070
|
+
"--no-renames",
|
|
2071
|
+
"--no-ext-diff",
|
|
2072
|
+
"--no-textconv",
|
|
2073
|
+
"--no-relative",
|
|
2074
|
+
"--no-notes",
|
|
2075
|
+
"--no-show-signature",
|
|
2076
|
+
"--no-color",
|
|
2077
|
+
head,
|
|
2078
|
+
"--"
|
|
2079
|
+
], { maxBuffer: 16 * 1024 * 1024 });
|
|
2080
|
+
if (log === void 0) return /* @__PURE__ */ new Map();
|
|
2081
|
+
const units = [];
|
|
2082
|
+
const fields = log.split("\0");
|
|
2083
|
+
let cursor = 0;
|
|
2084
|
+
while (cursor < fields.length) {
|
|
2085
|
+
if (fields[cursor] === "") {
|
|
2086
|
+
cursor++;
|
|
2087
|
+
continue;
|
|
2088
|
+
}
|
|
2089
|
+
if (fields[cursor++] !== "FOVEA") return /* @__PURE__ */ new Map();
|
|
2090
|
+
const hash = fields[cursor++];
|
|
2091
|
+
const parents = fields[cursor++];
|
|
2092
|
+
const seconds = Number(fields[cursor++]);
|
|
2093
|
+
const subject = fields[cursor++];
|
|
2094
|
+
if (!hash || parents === void 0 || subject === void 0 || !Number.isFinite(seconds) || fields[cursor++] !== "") return /* @__PURE__ */ new Map();
|
|
2095
|
+
const files = /* @__PURE__ */ new Set();
|
|
2096
|
+
let first = true;
|
|
2097
|
+
while (cursor < fields.length && fields[cursor] !== "") {
|
|
2098
|
+
let status = fields[cursor++];
|
|
2099
|
+
if (first) {
|
|
2100
|
+
if (!status.startsWith("\n")) return /* @__PURE__ */ new Map();
|
|
2101
|
+
status = status.slice(1);
|
|
2102
|
+
first = false;
|
|
2103
|
+
}
|
|
2104
|
+
let file = fields[cursor++];
|
|
2105
|
+
if (!/^[AMDTUXB]$/.test(status) || !file) return /* @__PURE__ */ new Map();
|
|
2106
|
+
if ((status === "A" || status === "M") && file.startsWith(prefix)) {
|
|
2107
|
+
file = file.slice(prefix.length);
|
|
2108
|
+
if (tracked.has(file)) files.add(file);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (!shallow.has(hash)) units.push({ subject, merge: parents.includes(" "), ts: seconds * 1e3, files });
|
|
2112
|
+
}
|
|
2113
|
+
const subjects = /* @__PURE__ */ new Map();
|
|
2114
|
+
units.forEach((unit, i) => {
|
|
2115
|
+
const hits = subjects.get(unit.subject) ?? [];
|
|
2116
|
+
hits.push(i);
|
|
2117
|
+
subjects.set(unit.subject, hits);
|
|
2118
|
+
});
|
|
2119
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
2120
|
+
units.forEach((unit, i) => {
|
|
2121
|
+
if (unit.merge) return;
|
|
2122
|
+
const target = /^(?:fixup!|squash!) (.+)$/.exec(unit.subject)?.[1];
|
|
2123
|
+
if (!target) return;
|
|
2124
|
+
const hits = subjects.get(target);
|
|
2125
|
+
if (hits?.length !== 1 || hits[0] <= i) return;
|
|
2126
|
+
const parent = units[hits[0]];
|
|
2127
|
+
for (const file of unit.files) parent.files.add(file);
|
|
2128
|
+
parent.ts = Math.max(parent.ts, unit.ts);
|
|
2129
|
+
consumed.add(i);
|
|
2130
|
+
});
|
|
2052
2131
|
const pairCount = /* @__PURE__ */ new Map();
|
|
2053
2132
|
const pairLast = /* @__PURE__ */ new Map();
|
|
2054
2133
|
const touchCount = /* @__PURE__ */ new Map();
|
|
2055
2134
|
let commits = 0;
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
if (!inCommit) {
|
|
2061
|
-
cur = [];
|
|
2062
|
-
return;
|
|
2063
|
-
}
|
|
2064
|
-
inCommit = false;
|
|
2065
|
-
const fs = [...new Set(cur)].filter((f) => tracked.has(f)).sort();
|
|
2066
|
-
cur = [];
|
|
2135
|
+
units.forEach((unit, index) => {
|
|
2136
|
+
if (consumed.has(index)) return;
|
|
2137
|
+
commits++;
|
|
2138
|
+
const fs = [...unit.files].sort();
|
|
2067
2139
|
for (const f of fs) touchCount.set(f, (touchCount.get(f) ?? 0) + 1);
|
|
2068
|
-
if (fs.length < 2 || fs.length >
|
|
2140
|
+
if (fs.length < 2 || fs.length > MAX_FILES_PER_UNIT) return;
|
|
2069
2141
|
for (let i = 0; i < fs.length; i++) {
|
|
2070
2142
|
for (let j = i + 1; j < fs.length; j++) {
|
|
2071
|
-
const k =
|
|
2143
|
+
const k = JSON.stringify([fs[i], fs[j]]);
|
|
2072
2144
|
pairCount.set(k, (pairCount.get(k) ?? 0) + 1);
|
|
2073
|
-
|
|
2145
|
+
pairLast.set(k, Math.max(pairLast.get(k) ?? 0, unit.ts));
|
|
2074
2146
|
}
|
|
2075
2147
|
}
|
|
2076
|
-
};
|
|
2077
|
-
for (const line of log.split("\n")) {
|
|
2078
|
-
if (!line.trim()) continue;
|
|
2079
|
-
if (line.includes("\0")) {
|
|
2080
|
-
flush();
|
|
2081
|
-
inCommit = true;
|
|
2082
|
-
commits++;
|
|
2083
|
-
curTs = 0;
|
|
2084
|
-
const ts = Number(line.slice(line.indexOf("\0") + 1).trim());
|
|
2085
|
-
if (Number.isFinite(ts)) curTs = ts * 1e3;
|
|
2086
|
-
continue;
|
|
2087
|
-
}
|
|
2088
|
-
const tab = line.indexOf(" ");
|
|
2089
|
-
if (tab < 0) continue;
|
|
2090
|
-
const secondTab = line.indexOf(" ", tab + 1);
|
|
2091
|
-
if (secondTab < 0) continue;
|
|
2092
|
-
const file = gitRelativePath(line.slice(secondTab + 1).trim(), prefix);
|
|
2093
|
-
if (file) cur.push(file);
|
|
2094
|
-
}
|
|
2095
|
-
flush();
|
|
2148
|
+
});
|
|
2096
2149
|
const scored = [];
|
|
2097
2150
|
for (const [k, n] of pairCount) {
|
|
2098
2151
|
if (n < MIN_SHARED) continue;
|
|
2099
|
-
const [a, b] =
|
|
2152
|
+
const [a, b] = JSON.parse(k);
|
|
2100
2153
|
const nA = touchCount.get(a) ?? 0;
|
|
2101
2154
|
const nB = touchCount.get(b) ?? 0;
|
|
2102
2155
|
const w = scorePair(n, nA, nB);
|
|
@@ -2231,7 +2284,7 @@ var epochStats = (session) => {
|
|
|
2231
2284
|
// src/core/state.ts
|
|
2232
2285
|
import { createHash as createHash4 } from "node:crypto";
|
|
2233
2286
|
import { stat as stat2 } from "node:fs/promises";
|
|
2234
|
-
import { isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as
|
|
2287
|
+
import { isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve4, sep as sep2 } from "node:path";
|
|
2235
2288
|
|
|
2236
2289
|
// src/core/build.ts
|
|
2237
2290
|
import { createHash as createHash3, randomUUID } from "node:crypto";
|
|
@@ -4775,8 +4828,8 @@ var ensureState = (root, opts = {}) => {
|
|
|
4775
4828
|
};
|
|
4776
4829
|
var relativeRequest = (root, input) => {
|
|
4777
4830
|
const raw = input.startsWith("@") ? input.slice(1) : input;
|
|
4778
|
-
const rootPath =
|
|
4779
|
-
const absolute = isAbsolute2(raw) ?
|
|
4831
|
+
const rootPath = resolve4(root);
|
|
4832
|
+
const absolute = isAbsolute2(raw) ? resolve4(raw) : resolve4(rootPath, raw);
|
|
4780
4833
|
const rel = relative2(rootPath, absolute);
|
|
4781
4834
|
if (!rel || rel === "." || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) return void 0;
|
|
4782
4835
|
return rel.split(sep2).join("/");
|
package/package.json
CHANGED
package/src/core/cochange.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// Heat diffusion over the static graph says what is near what BY
|
|
4
4
|
// CONSTRUCTION (imports, calls, routes, literals). Joint git history says
|
|
5
5
|
// what ACTUALLY moves together — the signal `impact` needs when two files
|
|
6
|
-
// share no static edge but
|
|
6
|
+
// share no static edge but repeatedly move in the same integration units.
|
|
7
7
|
//
|
|
8
8
|
// Under the all-in heat model that signal is a seeded field, not permanent
|
|
9
9
|
// structure. Each (a, b) pair records directional touch counts plus the
|
|
10
|
-
//
|
|
10
|
+
// latest member timestamp of the most recent joint unit; at wall-clock `now`
|
|
11
11
|
// its heat contribution is
|
|
12
12
|
//
|
|
13
13
|
// w = w0(count, jaccard) * 2^(-ageDays / COCHANGE_HALF_LIFE_DAYS)
|
|
@@ -25,15 +25,15 @@
|
|
|
25
25
|
import { createHash } from "node:crypto";
|
|
26
26
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
|
-
import { dirname, join as joinPath } from "node:path";
|
|
28
|
+
import { dirname, resolve, join as joinPath } from "node:path";
|
|
29
29
|
import { envInt } from "./asyncutil.js";
|
|
30
|
-
import { gitHead, gitOut, gitPrefix
|
|
30
|
+
import { gitHead, gitOut, gitPrefix } from "./git.js";
|
|
31
31
|
|
|
32
32
|
const LOG_COMMITS = 400;
|
|
33
|
-
const
|
|
33
|
+
const MAX_FILES_PER_UNIT = 24; // oversized integration units carry no pair signal
|
|
34
34
|
const MIN_SHARED = 2; // a single collision is noise
|
|
35
|
-
const MAX_PAIRS_PER_FILE = 16; //
|
|
36
|
-
const COCHANGE_CACHE_VERSION =
|
|
35
|
+
const MAX_PAIRS_PER_FILE = 16; // union of endpoint top-16 selections, not a hard degree cap
|
|
36
|
+
const COCHANGE_CACHE_VERSION = 4;
|
|
37
37
|
const EXPECTATION_MIN_SUPPORT = 3;
|
|
38
38
|
const WILSON_Z_95 = 1.96;
|
|
39
39
|
const DAY_MS = 86_400_000;
|
|
@@ -47,15 +47,15 @@ export interface CoChangePartner {
|
|
|
47
47
|
partner: string;
|
|
48
48
|
/** Base conductance from count + Jaccard, BEFORE recency decay. */
|
|
49
49
|
w: number;
|
|
50
|
-
/**
|
|
50
|
+
/** Latest member committer epoch ms of this pair's most recent joint unit. */
|
|
51
51
|
lastTs: number;
|
|
52
|
-
/** Joint
|
|
52
|
+
/** Joint integration units for this directional pair in the bounded window. */
|
|
53
53
|
n_ij?: number;
|
|
54
|
-
/**
|
|
54
|
+
/** Integration units in the window that touch the source file. */
|
|
55
55
|
n_i?: number;
|
|
56
|
-
/**
|
|
56
|
+
/** Integration units in the window that touch the partner file. */
|
|
57
57
|
n_j?: number;
|
|
58
|
-
/** Total
|
|
58
|
+
/** Total integration units observed in the bounded history window. */
|
|
59
59
|
N?: number;
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -172,7 +172,7 @@ const cachePath = (root: string): string =>
|
|
|
172
172
|
joinPath(tmpdir(), `pi-fovea-cochange-${createHash("sha1").update(root).digest("hex").slice(0, 16)}.json`);
|
|
173
173
|
|
|
174
174
|
// coChangeHistory returns, for every tracked file, its past co-change partners
|
|
175
|
-
// with base conductance and the most recent joint
|
|
175
|
+
// with base conductance and the most recent joint unit time. filesInGraph
|
|
176
176
|
// restricts to files we actually track, so vendored churn is excluded.
|
|
177
177
|
export const coChangeHistory = async (
|
|
178
178
|
root: string,
|
|
@@ -183,10 +183,21 @@ export const coChangeHistory = async (
|
|
|
183
183
|
if (!head) return new Map(); // not a git repo
|
|
184
184
|
const prefix = await gitPrefix(root);
|
|
185
185
|
if (prefix === undefined) return new Map();
|
|
186
|
+
// Deepening can change evidence without changing HEAD. Do not mistake a
|
|
187
|
+
// shallow boundary's synthetic root diff for an integrated feature.
|
|
188
|
+
const shallowPath = await gitOut(root, ["rev-parse", "--git-path", "shallow"]);
|
|
189
|
+
if (shallowPath === undefined) return new Map();
|
|
190
|
+
let shallowText = "";
|
|
191
|
+
try { shallowText = await readFile(resolve(root, shallowPath.trim()), "utf8"); }
|
|
192
|
+
catch (error) {
|
|
193
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return new Map();
|
|
194
|
+
}
|
|
195
|
+
const shallow = new Set(shallowText.trim().split(/\s+/));
|
|
186
196
|
const tracked = new Set(filesInGraph);
|
|
187
197
|
const key = createHash("sha1")
|
|
188
198
|
.update(`v${COCHANGE_CACHE_VERSION}\0`)
|
|
189
|
-
.update(
|
|
199
|
+
.update(shallowText)
|
|
200
|
+
.update(JSON.stringify([...tracked].sort()))
|
|
190
201
|
.digest("hex")
|
|
191
202
|
.slice(0, 12);
|
|
192
203
|
const cp = cachePath(root);
|
|
@@ -198,60 +209,95 @@ export const coChangeHistory = async (
|
|
|
198
209
|
) return groupPairs(cached.pairs, cached.commits);
|
|
199
210
|
} catch { /* recompute */ }
|
|
200
211
|
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
|
|
212
|
+
// No pathspec: history simplification could remove integration boundaries
|
|
213
|
+
// or make subroot denominators differ from the repository's observations.
|
|
214
|
+
const log = await gitOut(root, [
|
|
215
|
+
"log", "--first-parent", "--diff-merges=first-parent", "--root",
|
|
216
|
+
"--format=%x00FOVEA%x00%H%x00%P%x00%ct%x00%s%x00", "--name-status", "-z",
|
|
217
|
+
"-n", String(LOG_COMMITS), "--no-renames", "--no-ext-diff", "--no-textconv",
|
|
218
|
+
"--no-relative", "--no-notes", "--no-show-signature", "--no-color", head, "--",
|
|
219
|
+
], { maxBuffer: 16 * 1024 * 1024 });
|
|
220
|
+
if (log === undefined) return new Map(); // never cache a failed/partial scan
|
|
221
|
+
type Unit = { subject: string; merge: boolean; ts: number; files: Set<string> };
|
|
222
|
+
const units: Unit[] = [];
|
|
223
|
+
const fields = log.split("\0");
|
|
224
|
+
let cursor = 0;
|
|
225
|
+
while (cursor < fields.length) {
|
|
226
|
+
if (fields[cursor] === "") { cursor++; continue; }
|
|
227
|
+
if (fields[cursor++] !== "FOVEA") return new Map();
|
|
228
|
+
const hash = fields[cursor++];
|
|
229
|
+
const parents = fields[cursor++];
|
|
230
|
+
const seconds = Number(fields[cursor++]);
|
|
231
|
+
const subject = fields[cursor++];
|
|
232
|
+
if (!hash || parents === undefined || subject === undefined || !Number.isFinite(seconds)
|
|
233
|
+
|| fields[cursor++] !== "") return new Map();
|
|
234
|
+
const files = new Set<string>();
|
|
235
|
+
let first = true;
|
|
236
|
+
while (cursor < fields.length && fields[cursor] !== "") {
|
|
237
|
+
let status = fields[cursor++]!;
|
|
238
|
+
if (first) {
|
|
239
|
+
if (!status.startsWith("\n")) return new Map();
|
|
240
|
+
status = status.slice(1);
|
|
241
|
+
first = false;
|
|
242
|
+
}
|
|
243
|
+
let file = fields[cursor++];
|
|
244
|
+
if (!/^[AMDTUXB]$/.test(status) || !file) return new Map();
|
|
245
|
+
// NUL names are literal, including tabs, newlines and backslashes.
|
|
246
|
+
// Filter here, not in Git: --diff-filter drops empty observations and
|
|
247
|
+
// lets the scan walk arbitrarily far looking for 400 matching diffs.
|
|
248
|
+
if ((status === "A" || status === "M") && file.startsWith(prefix)) {
|
|
249
|
+
file = file.slice(prefix.length);
|
|
250
|
+
if (tracked.has(file)) files.add(file);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!shallow.has(hash)) units.push({ subject, merge: parents.includes(" "), ts: seconds * 1000, files });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Match against the entire bounded window before mutating anything. An
|
|
257
|
+
// exact unique older subject is evidence; proximity and issue IDs are not.
|
|
258
|
+
const subjects = new Map<string, number[]>();
|
|
259
|
+
units.forEach((unit, i) => {
|
|
260
|
+
const hits = subjects.get(unit.subject) ?? [];
|
|
261
|
+
hits.push(i);
|
|
262
|
+
subjects.set(unit.subject, hits);
|
|
263
|
+
});
|
|
264
|
+
const consumed = new Set<number>();
|
|
265
|
+
units.forEach((unit, i) => {
|
|
266
|
+
if (unit.merge) return;
|
|
267
|
+
const target = /^(?:fixup!|squash!) (.+)$/.exec(unit.subject)?.[1];
|
|
268
|
+
if (!target) return;
|
|
269
|
+
const hits = subjects.get(target);
|
|
270
|
+
if (hits?.length !== 1 || hits[0]! <= i) return;
|
|
271
|
+
const parent = units[hits[0]!]!;
|
|
272
|
+
for (const file of unit.files) parent.files.add(file);
|
|
273
|
+
parent.ts = Math.max(parent.ts, unit.ts);
|
|
274
|
+
consumed.add(i);
|
|
275
|
+
});
|
|
276
|
+
|
|
204
277
|
const pairCount = new Map<string, number>();
|
|
205
278
|
const pairLast = new Map<string, number>();
|
|
206
279
|
const touchCount = new Map<string, number>();
|
|
207
|
-
let commits = 0;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
cur = [];
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
inCommit = false;
|
|
217
|
-
const fs = [...new Set(cur)].filter((f) => tracked.has(f)).sort();
|
|
218
|
-
cur = [];
|
|
219
|
-
// Directional denominators include solo touches and oversized commits;
|
|
220
|
-
// only pair production observes the monster-commit fan-out bound.
|
|
280
|
+
let commits = 0; // cache field retained: now independent integration units
|
|
281
|
+
units.forEach((unit, index) => {
|
|
282
|
+
if (consumed.has(index)) return;
|
|
283
|
+
commits++;
|
|
284
|
+
const fs = [...unit.files].sort();
|
|
285
|
+
// Solo and oversized units still contribute truthful denominators.
|
|
221
286
|
for (const f of fs) touchCount.set(f, (touchCount.get(f) ?? 0) + 1);
|
|
222
|
-
if (fs.length < 2 || fs.length >
|
|
287
|
+
if (fs.length < 2 || fs.length > MAX_FILES_PER_UNIT) return;
|
|
223
288
|
for (let i = 0; i < fs.length; i++) {
|
|
224
289
|
for (let j = i + 1; j < fs.length; j++) {
|
|
225
|
-
const k =
|
|
290
|
+
const k = JSON.stringify([fs[i], fs[j]]);
|
|
226
291
|
pairCount.set(k, (pairCount.get(k) ?? 0) + 1);
|
|
227
|
-
|
|
292
|
+
pairLast.set(k, Math.max(pairLast.get(k) ?? 0, unit.ts));
|
|
228
293
|
}
|
|
229
294
|
}
|
|
230
|
-
};
|
|
231
|
-
for (const line of log.split("\n")) {
|
|
232
|
-
if (!line.trim()) continue;
|
|
233
|
-
if (line.includes("\0")) {
|
|
234
|
-
flush();
|
|
235
|
-
inCommit = true;
|
|
236
|
-
commits++;
|
|
237
|
-
curTs = 0;
|
|
238
|
-
const ts = Number(line.slice(line.indexOf("\0") + 1).trim());
|
|
239
|
-
if (Number.isFinite(ts)) curTs = ts * 1000;
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
const tab = line.indexOf("\t");
|
|
243
|
-
if (tab < 0) continue;
|
|
244
|
-
const secondTab = line.indexOf("\t", tab + 1);
|
|
245
|
-
if (secondTab < 0) continue;
|
|
246
|
-
const file = gitRelativePath(line.slice(secondTab + 1).trim(), prefix);
|
|
247
|
-
if (file) cur.push(file);
|
|
248
|
-
}
|
|
249
|
-
flush();
|
|
295
|
+
});
|
|
250
296
|
|
|
251
297
|
const scored: CachedPair[] = [];
|
|
252
298
|
for (const [k, n] of pairCount) {
|
|
253
299
|
if (n < MIN_SHARED) continue;
|
|
254
|
-
const [a, b] =
|
|
300
|
+
const [a, b] = JSON.parse(k) as [string, string];
|
|
255
301
|
const nA = touchCount.get(a) ?? 0;
|
|
256
302
|
const nB = touchCount.get(b) ?? 0;
|
|
257
303
|
const w = scorePair(n, nA, nB);
|
package/src/core/git.ts
CHANGED
|
@@ -92,7 +92,7 @@ export const gitPrefix = async (root: string): Promise<string | undefined> => {
|
|
|
92
92
|
return prefix;
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
const gitRelativePath = (path: string, prefix: string): string | undefined => {
|
|
96
96
|
const normalized = path.replace(/\\/g, "/");
|
|
97
97
|
if (!prefix) return normalized;
|
|
98
98
|
return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : undefined;
|