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 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((resolve4) => this.waiters.push(resolve4));
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((resolve4) => setImmediate(resolve4));
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((resolve4) => {
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
- resolve4(void 0);
83
+ resolve5(void 0);
84
84
  return;
85
85
  }
86
- resolve4(stdout);
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((resolve4) => {
1017
+ () => new Promise((resolve5) => {
1018
1018
  execFile2(bin, ["--version"], { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024 }, (error) => {
1019
- resolve4(!error);
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((resolve4) => {
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
- resolve4({ ok: false, stdout: "", split: true });
1045
+ resolve5({ ok: false, stdout: "", split: true });
1046
1046
  return;
1047
1047
  }
1048
1048
  if (error && typeof error.code !== "number") {
1049
- resolve4({ ok: false, stdout: "", split: false });
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
- resolve4({ ok: false, stdout: "", split: false });
1055
+ resolve5({ ok: false, stdout: "", split: false });
1056
1056
  return;
1057
1057
  }
1058
- resolve4({ ok: true, stdout: "", split: false });
1058
+ resolve5({ ok: true, stdout: "", split: false });
1059
1059
  return;
1060
1060
  }
1061
- resolve4({ ok: true, stdout: stdout ?? "", split: false });
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((resolve4) => {
1153
+ () => new Promise((resolve5) => {
1154
1154
  execFile2(bin, ["scan", "--help"], { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024 }, (error) => {
1155
- resolve4(!error);
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((resolve4) => {
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
- resolve4(value);
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 MAX_FILES_PER_COMMIT = 24;
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 = 3;
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().join("\n")).digest("hex").slice(0, 12);
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, ["log", "--format=%x00%ct", "--numstat", "-n", String(LOG_COMMITS), "--no-renames", "--diff-filter=AMR", "--", "."]) ?? "";
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
- let inCommit = false;
2057
- let cur = [];
2058
- let curTs = 0;
2059
- const flush = () => {
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 > MAX_FILES_PER_COMMIT) return;
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 = `${fs[i]}|${fs[j]}`;
2143
+ const k = JSON.stringify([fs[i], fs[j]]);
2072
2144
  pairCount.set(k, (pairCount.get(k) ?? 0) + 1);
2073
- if (curTs) pairLast.set(k, Math.max(pairLast.get(k) ?? 0, curTs));
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] = k.split("|");
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 resolve3, sep as sep2 } from "node:path";
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 = resolve3(root);
4779
- const absolute = isAbsolute2(raw) ? resolve3(raw) : resolve3(rootPath, 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.21.2",
3
+ "version": "0.22.0",
4
4
  "description": "Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 are always edited in the same commits.
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
- // committer timestamp of the most recent joint commit; at wall-clock `now`
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, gitRelativePath } from "./git.js";
30
+ import { gitHead, gitOut, gitPrefix } from "./git.js";
31
31
 
32
32
  const LOG_COMMITS = 400;
33
- const MAX_FILES_PER_COMMIT = 24; // squashed monsters carry no pair signal
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; // cap each file's history fan-out
36
- const COCHANGE_CACHE_VERSION = 3;
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
- /** Committer epoch ms of the most recent joint commit of this pair. */
50
+ /** Latest member committer epoch ms of this pair's most recent joint unit. */
51
51
  lastTs: number;
52
- /** Joint commits for this directional pair in the bounded history window. */
52
+ /** Joint integration units for this directional pair in the bounded window. */
53
53
  n_ij?: number;
54
- /** Commits in the window that touch the source file. */
54
+ /** Integration units in the window that touch the source file. */
55
55
  n_i?: number;
56
- /** Commits in the window that touch the partner file. */
56
+ /** Integration units in the window that touch the partner file. */
57
57
  n_j?: number;
58
- /** Total commits observed in the bounded history window. */
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 commit time. filesInGraph
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([...tracked].sort().join("\n"))
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
- const log = await gitOut(root, ["log", "--format=%x00%ct", "--numstat", "-n", String(LOG_COMMITS), "--no-renames", "--diff-filter=AMR", "--", "."]) ?? "";
202
- // numstat lines: "<added>\t<deleted>\t<file>"; each commit begins with a NUL
203
- // line carrying its committer timestamp (%ct).
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
- let inCommit = false;
209
- let cur: string[] = [];
210
- let curTs = 0;
211
- const flush = (): void => {
212
- if (!inCommit) {
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 > MAX_FILES_PER_COMMIT) return;
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 = `${fs[i]}|${fs[j]}`;
290
+ const k = JSON.stringify([fs[i], fs[j]]);
226
291
  pairCount.set(k, (pairCount.get(k) ?? 0) + 1);
227
- if (curTs) pairLast.set(k, Math.max(pairLast.get(k) ?? 0, curTs));
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] = k.split("|") as [string, string];
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
- export const gitRelativePath = (path: string, prefix: string): string | undefined => {
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;