filegrc 0.12.2 → 0.12.3

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.
@@ -1,14 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { modelSupports } from "../model/index.js";
4
+ import { performance } from "node:perf_hooks";
5
+ import { loadModel, modelSupports, SUPPORTED_MODEL_VERSIONS } from "../model/index.js";
5
6
  import { createObligationEvent, obligationRule } from "./obligations.js";
6
7
  import { createResource, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
7
- import { getDataRecordHistoryIndex, getFileAtRevision, runGitCommandSync } from "./git.js";
8
+ import { getDataRecordHistoryIndex, getFileAtRevision, getFilesAtRevisions, runGitCommandSync } from "./git.js";
8
9
  import { markdownEntries } from "./resource-markdown.js";
9
10
  import { loadWorkspace } from "./workspace.js";
10
11
  import { serializeWorkspaceMutation } from "./mutation.js";
11
12
  import { currentCalendarDate } from "./time.js";
13
+ import { assertHistoricalRecordValid } from "./validate.js";
12
14
 
13
15
  const TRANSITIONS = {
14
16
  person: [
@@ -95,6 +97,27 @@ const TRANSITIONS = {
95
97
  message: "Confirm whether this Asset disposal needs sanitization and evidence work."
96
98
  }]
97
99
  };
100
+ const MAX_RECONCILIATION_HISTORY_BYTES = 32 * 1024 * 1024;
101
+ const MAX_RECONCILIATION_HISTORY_REQUESTS = 20_000;
102
+ const MAX_MERGE_IDENTITY_CHECKS = 100_000;
103
+ const MAX_RECONCILIATION_MATCH_EVALUATIONS = 100_000;
104
+ const MAX_RECONCILIATION_SNAPSHOT_RECORDS = 100_000;
105
+ const RECORD_STATE_CHECKPOINT_INTERVAL = 64;
106
+ const RECONCILIATION_HISTORY_TYPES = new Set([
107
+ ...Object.keys(TRANSITIONS),
108
+ "action-item",
109
+ "obligation",
110
+ "obligation-event",
111
+ "obligation-rule",
112
+ "reconciliation-dismissal"
113
+ ]);
114
+
115
+ function allowsHistoricalTypeTransition(parentModel, currentModel, beforeType, afterType) {
116
+ return parentModel?.modelVersion === "3"
117
+ && currentModel?.modelVersion === "4"
118
+ && beforeType === "system"
119
+ && afterType === "component";
120
+ }
98
121
 
99
122
  export async function planReconciliation(input = process.cwd(), options = {}) {
100
123
  const loaded = input?.entries && input?.resources && input?.model
@@ -109,71 +132,107 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
109
132
  message: "Direct-file transition reconciliation is available after migrating this workspace to model v3."
110
133
  };
111
134
  }
112
- const baseRevision = gitRevision(loaded.root);
135
+ const historyDeadline = performance.now() + 10_000;
136
+ const baseRevision = gitRevision(loaded.root, historyDeadline);
113
137
  const currentByPath = new Map(loaded.entries.map((entry) => [
114
138
  `data/${entry.relativePath}`,
115
139
  entry
116
140
  ]));
117
- const workingChanges = pairWorkingRecordRenames(
118
- loaded.root,
119
- gitChangedEntries(loaded.root),
120
- currentByPath
121
- );
122
- const changedPaths = [...new Set(workingChanges.flatMap(({ beforePath, afterPath }) => (
123
- [beforePath, afterPath].filter(Boolean)
124
- )))].sort();
141
+ const rawWorkingChanges = gitChangedEntries(loaded.root, historyDeadline, baseRevision);
125
142
  if (!baseRevision) {
126
143
  return {
127
144
  contractVersion: 1,
128
145
  gitRevision: null,
129
- changedPaths,
146
+ changedPaths: [...new Set(rawWorkingChanges.flatMap(({ beforePath, afterPath }) => (
147
+ [beforePath, afterPath].filter(Boolean)
148
+ )))].sort(),
130
149
  candidates: [],
131
150
  message: "Commit the initial workspace before reconciling later direct-file transitions."
132
151
  };
133
152
  }
134
- const reconciliationHistory = reconciliationCommits(loaded.root);
135
- const historicalRecords = reconciliationHistory.length
136
- ? recordsAtRevision(loaded.root, `${reconciliationHistory[0]}^`)
137
- : new Map();
138
- const historyIndex = getDataRecordHistoryIndex(loaded.root);
153
+ const historyIndex = getDataRecordHistoryIndex(loaded.root, { deadline: historyDeadline, head: baseRevision });
154
+ if (!historyIndex.available) {
155
+ throw new Error(`Git history is unavailable for reconciliation${historyIndex.error?.message ? `: ${historyIndex.error.message}` : "."}`);
156
+ }
157
+ const workingChanges = pairWorkingRecordRenames(loaded.root, rawWorkingChanges, currentByPath, baseRevision);
158
+ const changedPaths = [...new Set(workingChanges.flatMap(({ beforePath, afterPath }) => (
159
+ [beforePath, afterPath].filter(Boolean)
160
+ )))].sort();
161
+ const historyContext = { ...historyIndex, recordStatesByCommit: new Map() };
162
+ for (const commit of historyIndex.commits) recordStateAtCommit(historyContext, loaded.model, commit);
163
+ validateMergeIdentities(historyContext, loaded.model);
164
+ const { commits: reconciliationHistory } = reconciliationCommits(historyContext, loaded.model);
139
165
  const committedRecordIds = new Set(historyIndex.historiesById.keys());
166
+ const currentTransitionIndex = indexTransitionResources(loaded.resources);
167
+ const transitionIndexes = new WeakMap([[loaded.resources, currentTransitionIndex]]);
168
+ let transitionMatchEvaluations = 0;
169
+ const consumeMatchEvaluation = () => {
170
+ transitionMatchEvaluations += 1;
171
+ if (transitionMatchEvaluations > MAX_RECONCILIATION_MATCH_EVALUATIONS) {
172
+ throw new Error(`Git reconciliation history exceeds the ${MAX_RECONCILIATION_MATCH_EVALUATIONS}-match safety limit.`);
173
+ }
174
+ };
140
175
  const eventRecordSnapshots = new Map();
176
+ let snapshotRecordsProcessed = 0;
141
177
  const recordsForEvent = (event) => {
142
178
  if (eventRecordSnapshots.has(event.id)) return eventRecordSnapshots.get(event.id);
143
179
  const history = historyIndex.historiesById.get(event.id) || [];
144
180
  const firstCommit = history.at(-1)?.commit;
145
181
  const records = firstCommit
146
- ? [...recordsAtRevision(loaded.root, firstCommit).values()].map(({ record }) => record)
182
+ ? materializeRecordState(recordStateAtCommit(historyContext, loaded.model, firstCommit)).map(({ record }) => record)
147
183
  : loaded.resources;
184
+ snapshotRecordsProcessed += records.length;
185
+ if (snapshotRecordsProcessed > MAX_RECONCILIATION_SNAPSHOT_RECORDS) {
186
+ throw new Error(`Git reconciliation history exceeds the ${MAX_RECONCILIATION_SNAPSHOT_RECORDS}-record snapshot safety limit.`);
187
+ }
148
188
  eventRecordSnapshots.set(event.id, records);
189
+ while (eventRecordSnapshots.size > 4) eventRecordSnapshots.delete(eventRecordSnapshots.keys().next().value);
149
190
  return records;
150
191
  };
151
192
  const currentMarkdownOwners = new Map();
152
193
  for (const entry of loaded.entries) {
153
194
  for (const markdown of markdownEntries(loaded.model, entry.record)) currentMarkdownOwners.set(`data/${markdown.path}`, entry);
154
195
  }
196
+ const workingMarkdownPathsByRecordId = new Map();
197
+ for (const changed of changedPaths) {
198
+ const ownerId = currentMarkdownOwners.get(changed)?.record.id;
199
+ if (!ownerId) continue;
200
+ if (!workingMarkdownPathsByRecordId.has(ownerId)) workingMarkdownPathsByRecordId.set(ownerId, []);
201
+ workingMarkdownPathsByRecordId.get(ownerId).push(changed);
202
+ }
155
203
  const rawCandidates = [];
156
204
  const filteredCandidates = [];
157
205
  const examined = new Set();
158
-
206
+ const committedContexts = [];
207
+ const historicalRequests = new Map();
159
208
  for (const commit of reconciliationHistory) {
160
- const markdownOwnersBefore = historicalMarkdownOwners(loaded.model, historicalRecords);
161
- for (const change of committedChangedEntries(loaded.root, commit)) {
162
- if (change.beforePath?.endsWith(".json") && !change.afterPath) {
163
- const removed = readRevisionRecord(loaded.root, `${commit}^`, change.beforePath);
164
- if (removed?.id) historicalRecords.delete(removed.id);
165
- }
166
- }
167
- for (const [id, item] of historyIndex.recordsByCommit.get(commit) || []) historicalRecords.set(id, item);
168
- const markdownOwners = historicalMarkdownOwners(loaded.model, historicalRecords);
169
- const commitChanges = pairCommittedRecordRenames(loaded.root, commit, committedChangedEntries(loaded.root, commit));
209
+ const parents = historyContext.parentsByCommit.get(commit) || [];
210
+ const parent = parents[0] || null;
211
+ const recordsBefore = parent ? recordStateAtCommit(historyContext, loaded.model, parent) : null;
212
+ const recordsAfter = recordStateAtCommit(historyContext, loaded.model, commit);
213
+ const changes = historyContext.changesByCommit.get(commit) || [];
214
+ const commitChanges = pairCommittedRecordRenames(changes, recordsBefore, recordsAfter);
170
215
  const commitPaths = [...new Set(commitChanges.flatMap(({ beforePath, afterPath }) => (
171
216
  [beforePath, afterPath].filter(Boolean)
172
217
  )))];
218
+ const changedMarkdownPathsByRecordId = new Map();
219
+ for (const changed of commitPaths.filter((path) => path.endsWith(".md"))) {
220
+ const owners = new Set([
221
+ markdownOwnerAtPath(recordsAfter, changed)?.record.id,
222
+ markdownOwnerAtPath(recordsBefore, changed)?.record.id
223
+ ].filter(Boolean));
224
+ for (const ownerId of owners) {
225
+ if (!changedMarkdownPathsByRecordId.has(ownerId)) changedMarkdownPathsByRecordId.set(ownerId, []);
226
+ changedMarkdownPathsByRecordId.get(ownerId).push(changed);
227
+ }
228
+ }
229
+ const changeContexts = [];
173
230
  const commitExamined = new Set();
174
231
  for (const change of commitChanges) {
175
232
  const path = change.afterPath || change.beforePath;
176
- const currentEntry = currentByPath.get(path) || markdownOwners.get(path) || markdownOwnersBefore.get(path);
233
+ const currentEntry = recordAtPath(recordsAfter, path)
234
+ || markdownOwnerAtPath(recordsAfter, path)
235
+ || markdownOwnerAtPath(recordsBefore, path);
177
236
  const previousRecordPath = change.beforePath?.endsWith(".json")
178
237
  ? change.beforePath
179
238
  : currentEntry ? `data/${currentEntry.relativePath}` : null;
@@ -181,28 +240,96 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
181
240
  ? change.afterPath
182
241
  : currentEntry ? `data/${currentEntry.relativePath}` : null;
183
242
  const recordPath = currentRecordPath || previousRecordPath;
184
- const previous = readRevisionRecord(loaded.root, `${commit}^`, previousRecordPath);
185
- const current = readRevisionRecord(loaded.root, commit, currentRecordPath);
243
+ const previous = recordAtPath(recordsBefore, previousRecordPath)?.record || null;
244
+ const current = recordAtPath(recordsAfter, currentRecordPath)?.record || null;
186
245
  const record = current || previous;
187
246
  if (!record || commitExamined.has(`${record.type}:${record.id}`)) continue;
188
247
  commitExamined.add(`${record.type}:${record.id}`);
189
- const changedMarkdownPaths = commitPaths.filter((changed) => (
190
- markdownOwners.get(changed)?.record.id === record.id
191
- || markdownOwnersBefore.get(changed)?.record.id === record.id
192
- ));
248
+ const changedMarkdownPaths = changedMarkdownPathsByRecordId.get(record.id) || [];
193
249
  const markdownChanged = changedMarkdownPaths.length > 0;
250
+ const alternateParents = parents.slice(1).map((alternateRevision) => {
251
+ const alternateState = recordStateAtCommit(historyContext, loaded.model, alternateRevision);
252
+ const alternateEntry = recordAtId(alternateState, record.id);
253
+ return {
254
+ revision: alternateRevision,
255
+ recordPath: alternateEntry?.path || null,
256
+ markdownPaths: changedMarkdownPaths
257
+ };
258
+ });
259
+ for (const [revision, sourcePath] of [
260
+ [commit, currentRecordPath],
261
+ [parent, previousRecordPath],
262
+ ...changedMarkdownPaths.flatMap((changed) => [[commit, changed], [parent, changed]]),
263
+ ...alternateParents.flatMap((alternate) => [
264
+ [alternate.revision, alternate.recordPath],
265
+ ...alternate.markdownPaths.map((markdownPath) => [alternate.revision, markdownPath])
266
+ ])
267
+ ]) {
268
+ if (revision && sourcePath) historicalRequests.set(`${revision}\0${sourcePath}`, { revision, relativePath: sourcePath });
269
+ }
270
+ changeContexts.push({
271
+ path,
272
+ record,
273
+ recordPath: currentRecordPath || previousRecordPath,
274
+ previous,
275
+ current,
276
+ currentRecordPath,
277
+ previousRecordPath,
278
+ changedMarkdownPaths,
279
+ markdownChanged,
280
+ alternateParents
281
+ });
282
+ }
283
+ committedContexts.push({ commit, parent, changeContexts });
284
+ }
285
+ const historicalRequestList = [...historicalRequests.values()];
286
+ const historicalSources = getFilesAtRevisions(loaded.root, historicalRequestList, {
287
+ batchSize: 512,
288
+ maxRequests: MAX_RECONCILIATION_HISTORY_REQUESTS,
289
+ maxTotalBytes: MAX_RECONCILIATION_HISTORY_BYTES,
290
+ deadline: historyDeadline
291
+ });
292
+ const sourceByRevisionAndPath = new Map(historicalRequestList.map((request, index) => [
293
+ `${request.revision}\0${request.relativePath}`,
294
+ historicalSources[index] || ""
295
+ ]));
296
+
297
+ for (const { commit, parent, changeContexts } of committedContexts) {
298
+ for (const change of changeContexts) {
299
+ const {
300
+ path,
301
+ record,
302
+ recordPath,
303
+ previous,
304
+ current,
305
+ currentRecordPath,
306
+ previousRecordPath,
307
+ changedMarkdownPaths,
308
+ markdownChanged,
309
+ alternateParents
310
+ } = change;
194
311
  const currentSource = [
195
- readRevisionSource(loaded.root, commit, currentRecordPath),
196
- ...changedMarkdownPaths.map((changed) => readRevisionSource(loaded.root, commit, changed))
312
+ historicalSource(sourceByRevisionAndPath, commit, currentRecordPath),
313
+ ...changedMarkdownPaths.map((changed) => historicalSource(sourceByRevisionAndPath, commit, changed))
197
314
  ].join("\n");
198
315
  const beforeSource = [
199
- readRevisionSource(loaded.root, `${commit}^`, previousRecordPath),
200
- ...changedMarkdownPaths.map((changed) => readRevisionSource(loaded.root, `${commit}^`, changed))
316
+ historicalSource(sourceByRevisionAndPath, parent, previousRecordPath),
317
+ ...changedMarkdownPaths.map((changed) => historicalSource(sourceByRevisionAndPath, parent, changed))
201
318
  ].join("\n");
319
+ const repeatsParentState = alternateParents.some((alternate) => {
320
+ const alternateSource = [
321
+ historicalSource(sourceByRevisionAndPath, alternate.revision, alternate.recordPath),
322
+ ...alternate.markdownPaths.map((markdownPath) => (
323
+ historicalSource(sourceByRevisionAndPath, alternate.revision, markdownPath)
324
+ ))
325
+ ].join("\n");
326
+ return alternateSource === currentSource;
327
+ });
328
+ if (repeatsParentState) continue;
202
329
  for (const transition of TRANSITIONS[record.type] || []) {
203
330
  if (!transition.applies(previous, current, { markdownChanged })) continue;
204
331
  const fingerprintInput = {
205
- baseRevision: parentRevision(loaded.root, commit),
332
+ baseRevision: parent || commit,
206
333
  eventType: transition.eventType,
207
334
  subjectId: record.id,
208
335
  path: recordPath,
@@ -215,7 +342,7 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
215
342
  const legacyWorkingFingerprint = transitionFingerprint({
216
343
  ...fingerprintInput,
217
344
  beforeSource: [previous ? JSON.stringify(previous) : "", ...changedMarkdownPaths.map((changed) => (
218
- readRevisionSource(loaded.root, `${commit}^`, changed)
345
+ readRevisionSource(loaded.root, `${commit}^`, changed, historyIndex)
219
346
  ))].join("\n")
220
347
  });
221
348
  const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
@@ -237,7 +364,13 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
237
364
  includeDismissed: options.includeDismissed,
238
365
  committedRecordIds,
239
366
  timezone: loaded.workspace.timezone,
240
- recordsForEvent
367
+ recordsForEvent,
368
+ currentIndex: currentTransitionIndex,
369
+ consumeMatchEvaluation,
370
+ indexFor: (records) => {
371
+ if (!transitionIndexes.has(records)) transitionIndexes.set(records, indexTransitionResources(records));
372
+ return transitionIndexes.get(records);
373
+ }
241
374
  })) filteredCandidates.push(candidate);
242
375
  }
243
376
  }
@@ -252,14 +385,12 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
252
385
  const currentRecordPath = change.afterPath?.endsWith(".json")
253
386
  ? change.afterPath
254
387
  : currentEntry ? `data/${currentEntry.relativePath}` : null;
255
- const previous = readHeadRecord(loaded.root, previousRecordPath);
388
+ const previous = readHeadRecord(loaded.root, previousRecordPath, baseRevision);
256
389
  const current = currentRecordPath ? currentByPath.get(currentRecordPath)?.record || currentEntry?.record || null : null;
257
390
  const record = current || previous;
258
391
  if (!record || examined.has(`${record.type}:${record.id}`)) continue;
259
392
  examined.add(`${record.type}:${record.id}`);
260
- const changedMarkdownPaths = changedPaths.filter((changed) => (
261
- currentMarkdownOwners.get(changed)?.record.id === record.id
262
- ));
393
+ const changedMarkdownPaths = workingMarkdownPathsByRecordId.get(record.id) || [];
263
394
  const markdownChanged = changedMarkdownPaths.length > 0;
264
395
  const currentMarkdown = await Promise.all(changedMarkdownPaths.map(async (changed) => {
265
396
  try {
@@ -268,9 +399,9 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
268
399
  return "";
269
400
  }
270
401
  }));
271
- const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed));
402
+ const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed, baseRevision));
272
403
  const currentSource = [currentEntry?.source || "", ...currentMarkdown].join("\n");
273
- const beforeSource = [readHeadSource(loaded.root, previousRecordPath), ...previousMarkdown].join("\n");
404
+ const beforeSource = [readHeadSource(loaded.root, previousRecordPath, baseRevision), ...previousMarkdown].join("\n");
274
405
  for (const transition of TRANSITIONS[record.type] || []) {
275
406
  if (!transition.applies(previous, current, { markdownChanged })) continue;
276
407
  const fingerprintInput = {
@@ -297,13 +428,19 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
297
428
  includeDismissed: options.includeDismissed,
298
429
  committedRecordIds,
299
430
  timezone: loaded.workspace.timezone,
300
- recordsForEvent
431
+ recordsForEvent,
432
+ currentIndex: currentTransitionIndex,
433
+ consumeMatchEvaluation,
434
+ indexFor: (records) => {
435
+ if (!transitionIndexes.has(records)) transitionIndexes.set(records, indexTransitionResources(records));
436
+ return transitionIndexes.get(records);
437
+ }
301
438
  })) filteredCandidates.push(candidate);
302
439
  }
303
440
  }
304
441
  const result = {
305
442
  contractVersion: 1,
306
- gitRevision: gitRevision(loaded.root),
443
+ gitRevision: baseRevision,
307
444
  changedPaths,
308
445
  candidates: (options.includeHandled ? rawCandidates : filteredCandidates)
309
446
  .sort((a, b) => a.id.localeCompare(b.id))
@@ -340,42 +477,27 @@ function reconciliationCandidate(loaded, transition, record, path, fingerprint,
340
477
  };
341
478
  }
342
479
 
343
- function reconciliationCommits(root) {
344
- const commits = lines(runGit(root, ["log", "--reverse", "--format=%H", "--", "data"]));
345
- const baseline = commits.findIndex((commit) => {
346
- const workspace = readRevisionRecord(root, commit, "data/workspace.json");
480
+ function reconciliationCommits(index, model) {
481
+ const commits = index.commits.filter((commit) => {
482
+ const parent = index.parentsByCommit.get(commit)?.[0] || null;
483
+ if (!parent) return false;
484
+ const workspace = recordAtId(recordStateAtCommit(index, model, parent), "workspace")?.record;
347
485
  return Number(workspace?.dataModelVersion) >= 3;
348
486
  });
349
- return baseline < 0 ? [] : commits.slice(baseline + 1);
350
- }
351
-
352
- function committedChangedEntries(root, commit) {
353
- return parseNameStatus(runGit(root, [
354
- "diff-tree",
355
- "--root",
356
- "--no-commit-id",
357
- "--name-status",
358
- "-M",
359
- "-r",
360
- commit,
361
- "--",
362
- "data"
363
- ])).filter(({ beforePath, afterPath }) => (
364
- beforePath?.startsWith("data/") || afterPath?.startsWith("data/")
365
- ));
487
+ return { commits };
366
488
  }
367
489
 
368
- function pairCommittedRecordRenames(root, commit, changes) {
490
+ function pairCommittedRecordRenames(changes, recordsBefore, recordsAfter) {
369
491
  const deleted = changes.filter(({ beforePath, afterPath }) => beforePath?.endsWith(".json") && !afterPath);
370
492
  const added = changes.filter(({ beforePath, afterPath }) => !beforePath && afterPath?.endsWith(".json"));
371
493
  const paired = new Set();
372
494
  const replacements = [];
373
495
  for (const removed of deleted) {
374
- const previous = readRevisionRecord(root, `${commit}^`, removed.beforePath);
496
+ const previous = recordAtPath(recordsBefore, removed.beforePath)?.record;
375
497
  if (!previous?.type || !previous?.id) continue;
376
498
  const addition = added.find((candidate) => {
377
499
  if (paired.has(candidate)) return false;
378
- const current = readRevisionRecord(root, commit, candidate.afterPath);
500
+ const current = recordAtPath(recordsAfter, candidate.afterPath)?.record;
379
501
  return current?.type === previous.type && current?.id === previous.id;
380
502
  });
381
503
  if (!addition) continue;
@@ -386,6 +508,302 @@ function pairCommittedRecordRenames(root, commit, changes) {
386
508
  return [...changes.filter((change) => !paired.has(change)), ...replacements];
387
509
  }
388
510
 
511
+ function recordStateAtCommit(index, model, commit) {
512
+ if (index.recordStatesByCommit.has(commit)) return index.recordStatesByCommit.get(commit);
513
+ const lineage = [];
514
+ let cursor = commit;
515
+ while (cursor && !index.recordStatesByCommit.has(cursor)) {
516
+ lineage.push(cursor);
517
+ cursor = index.parentsByCommit.get(cursor)?.[0] || null;
518
+ }
519
+ let state = cursor ? index.recordStatesByCommit.get(cursor) : null;
520
+ for (let position = lineage.length - 1; position >= 0; position -= 1) {
521
+ const changedAt = lineage[position];
522
+ state = extendRecordState(index, model, changedAt, state);
523
+ index.recordStatesByCommit.set(changedAt, state);
524
+ }
525
+ return index.recordStatesByCommit.get(commit);
526
+ }
527
+
528
+ function extendRecordState(index, model, commit, parentState) {
529
+ const changes = index.changesByCommit.get(commit) || [];
530
+ if (!changes.length) return parentState;
531
+ const state = {
532
+ parent: parentState,
533
+ depth: (parentState?.depth || 0) + 1,
534
+ recordsById: new Map(),
535
+ recordsByPath: new Map(),
536
+ markdownOwnersByPath: new Map(),
537
+ identitiesById: new Map(),
538
+ snapshot: null
539
+ };
540
+ const changedRecordsByPath = new Map(
541
+ [...(index.recordsByCommit.get(commit) || new Map()).values()].map((item) => [item.path, item])
542
+ );
543
+ const workspaceChanges = [...changedRecordsByPath.values()].filter(({ path, record }) => (
544
+ path === "data/workspace.json" || record.id === "workspace" || record.type === "workspace"
545
+ ));
546
+ if (workspaceChanges.length > 1) {
547
+ throw new Error(`Git history contains multiple Workspace records at ${commit.slice(0, 12)}.`);
548
+ }
549
+ const workspaceChange = workspaceChanges[0] || null;
550
+ let historicalModel = parentState ? parentState.model : model;
551
+ if (workspaceChange) {
552
+ if (
553
+ workspaceChange.path !== "data/workspace.json"
554
+ || workspaceChange.record.id !== "workspace"
555
+ || workspaceChange.record.type !== "workspace"
556
+ ) {
557
+ throw new Error(`Git history contains an invalid Workspace identity or location at ${commit.slice(0, 12)}.`);
558
+ }
559
+ if (!Object.hasOwn(workspaceChange.record, "dataModelVersion")) {
560
+ throw new Error(`Git history declares an unsupported data model at ${commit.slice(0, 12)}.`);
561
+ }
562
+ const version = workspaceChange.record.dataModelVersion;
563
+ if (typeof version !== "string" || (version !== "1" && !SUPPORTED_MODEL_VERSIONS.includes(version))) {
564
+ throw new Error(`Git history declares an unsupported data model at ${commit.slice(0, 12)}.`);
565
+ }
566
+ if (version === "1") {
567
+ historicalModel = null;
568
+ } else {
569
+ historicalModel = loadModel(version);
570
+ assertHistoricalRecordValid(
571
+ workspaceChange.record,
572
+ historicalModel,
573
+ workspaceChange.path.replace(/^data\//, "")
574
+ );
575
+ }
576
+ }
577
+ state.model = historicalModel;
578
+ const modelTransition = Boolean(
579
+ historicalModel
580
+ && parentState?.model?.modelVersion !== historicalModel.modelVersion
581
+ );
582
+ const removedPaths = new Set(changes.filter(({ beforePath, afterPath }) => (
583
+ beforePath?.endsWith(".json") && beforePath !== afterPath
584
+ )).map(({ beforePath }) => beforePath));
585
+ for (const change of changes) {
586
+ if (!change.beforePath?.endsWith(".json") && !change.afterPath?.endsWith(".json")) continue;
587
+ const previous = recordAtPath(parentState, change.beforePath);
588
+ if (previous) {
589
+ for (const markdown of parentState?.model ? markdownEntries(parentState.model, previous.record) : []) {
590
+ state.markdownOwnersByPath.set(`data/${markdown.path}`, null);
591
+ }
592
+ }
593
+ if (change.beforePath?.endsWith(".json") && change.beforePath !== change.afterPath) {
594
+ state.recordsByPath.set(change.beforePath, null);
595
+ if (previous?.record.id) {
596
+ const replacement = [...changedRecordsByPath.values()].find(({ record }) => record.id === previous.record.id);
597
+ if (!replacement) {
598
+ state.recordsById.set(previous.record.id, null);
599
+ const identity = identityAtId(parentState, previous.record.id);
600
+ if (identity) state.identitiesById.set(previous.record.id, { ...identity, active: false });
601
+ }
602
+ }
603
+ }
604
+ if (change.afterPath?.endsWith(".json")) {
605
+ const current = changedRecordsByPath.get(change.afterPath) || null;
606
+ if (!current || (historicalModel && !historicalModel.resources[current.record.type])) {
607
+ throw new Error(`Git history contains an unsupported data record at ${commit.slice(0, 12)}:${change.afterPath}.`);
608
+ }
609
+ if (historicalModel && RECONCILIATION_HISTORY_TYPES.has(current.record.type)) {
610
+ assertHistoricalRecordValid(current.record, historicalModel, change.afterPath.replace(/^data\//, ""));
611
+ }
612
+ const priorAtIdentity = previous || recordAtId(parentState, current.record.id);
613
+ const allowedTypeTransition = priorAtIdentity && allowsHistoricalTypeTransition(
614
+ parentState?.model,
615
+ historicalModel,
616
+ priorAtIdentity.record.type,
617
+ current.record.type
618
+ );
619
+ if (priorAtIdentity && priorAtIdentity.record.type !== current.record.type) {
620
+ if (!allowedTypeTransition) {
621
+ throw new Error(`Git history changes immutable record type for "${current.record.id}" at ${commit.slice(0, 12)}.`);
622
+ }
623
+ assertHistoricalRecordValid(
624
+ priorAtIdentity.record,
625
+ parentState.model,
626
+ priorAtIdentity.path.replace(/^data\//, "")
627
+ );
628
+ assertHistoricalRecordValid(
629
+ current.record,
630
+ historicalModel,
631
+ change.afterPath.replace(/^data\//, "")
632
+ );
633
+ }
634
+ if (previous && (
635
+ previous.record.id !== current.record.id
636
+ || (previous.record.type !== current.record.type && !allowedTypeTransition)
637
+ )) {
638
+ throw new Error(`Git history changes immutable record identity at ${commit.slice(0, 12)}:${change.afterPath}.`);
639
+ }
640
+ const existing = recordAtId(parentState, current.record.id);
641
+ const priorIdentity = identityAtId(parentState, current.record.id);
642
+ if (priorIdentity && !priorIdentity.active) {
643
+ const sameIdentityRestore = priorIdentity.path === change.afterPath
644
+ && priorIdentity.type === current.record.type;
645
+ if (!sameIdentityRestore) {
646
+ throw new Error(`Git history reuses deleted record ID "${current.record.id}" at ${commit.slice(0, 12)}:${change.afterPath}.`);
647
+ }
648
+ }
649
+ if (existing && existing.path !== change.afterPath && !removedPaths.has(existing.path)) {
650
+ throw new Error(`Git history reuses record ID "${current.record.id}" at ${commit.slice(0, 12)}:${change.afterPath}.`);
651
+ }
652
+ if (existing && existing.record.type !== current.record.type && !allowedTypeTransition) {
653
+ throw new Error(`Git history changes immutable record type for "${current.record.id}" at ${commit.slice(0, 12)}.`);
654
+ }
655
+ state.recordsByPath.set(change.afterPath, current);
656
+ if (current?.record.id) {
657
+ state.recordsById.set(current.record.id, current);
658
+ state.identitiesById.set(current.record.id, priorIdentity
659
+ ? { ...priorIdentity, type: current.record.type, record: current.record, path: change.afterPath, active: true }
660
+ : { type: current.record.type, introducedAt: commit, record: current.record, path: change.afterPath, active: true });
661
+ const entry = { ...current, relativePath: current.path.replace(/^data\//, "") };
662
+ for (const markdown of historicalModel ? markdownEntries(historicalModel, current.record) : []) {
663
+ state.markdownOwnersByPath.set(`data/${markdown.path}`, entry);
664
+ }
665
+ }
666
+ }
667
+ }
668
+ const activeWorkspace = recordAtPath(state, "data/workspace.json");
669
+ if (
670
+ !activeWorkspace
671
+ || activeWorkspace.record.id !== "workspace"
672
+ || activeWorkspace.record.type !== "workspace"
673
+ ) {
674
+ throw new Error(`Git history has no active canonical Workspace at ${commit.slice(0, 12)}.`);
675
+ }
676
+ if (modelTransition) {
677
+ for (const entry of materializeRecordState(state)) {
678
+ if (!RECONCILIATION_HISTORY_TYPES.has(entry.record.type)) continue;
679
+ assertHistoricalRecordValid(entry.record, historicalModel, entry.path.replace(/^data\//, ""));
680
+ }
681
+ }
682
+ if (state.depth % RECORD_STATE_CHECKPOINT_INTERVAL === 0) state.snapshot = snapshotRecordState(state);
683
+ return state;
684
+ }
685
+
686
+ function recordAtPath(state, path) {
687
+ if (!path) return null;
688
+ for (let current = state; current; current = current.parent) {
689
+ if (current.snapshot) {
690
+ const item = current.snapshot.recordsByPath.get(path);
691
+ return item ? { ...item, relativePath: path.replace(/^data\//, "") } : null;
692
+ }
693
+ if (!current.recordsByPath.has(path)) continue;
694
+ const item = current.recordsByPath.get(path);
695
+ return item ? { ...item, relativePath: path.replace(/^data\//, "") } : null;
696
+ }
697
+ return null;
698
+ }
699
+
700
+ function recordAtId(state, id) {
701
+ if (!id) return null;
702
+ for (let current = state; current; current = current.parent) {
703
+ if (current.snapshot) return current.snapshot.recordsById.get(id) || null;
704
+ if (!current.recordsById.has(id)) continue;
705
+ return current.recordsById.get(id);
706
+ }
707
+ return null;
708
+ }
709
+
710
+ function markdownOwnerAtPath(state, path) {
711
+ if (!path) return null;
712
+ for (let current = state; current; current = current.parent) {
713
+ if (current.snapshot) return current.snapshot.markdownOwnersByPath.get(path) || null;
714
+ if (current.markdownOwnersByPath.has(path)) return current.markdownOwnersByPath.get(path);
715
+ }
716
+ return null;
717
+ }
718
+
719
+ function identityAtId(state, id) {
720
+ if (!id) return null;
721
+ for (let current = state; current; current = current.parent) {
722
+ if (current.snapshot) return current.snapshot.identitiesById.get(id) || null;
723
+ if (current.identitiesById.has(id)) return current.identitiesById.get(id);
724
+ }
725
+ return null;
726
+ }
727
+
728
+ function snapshotRecordState(state) {
729
+ const snapshot = {
730
+ recordsById: new Map(),
731
+ recordsByPath: new Map(),
732
+ markdownOwnersByPath: new Map(),
733
+ identitiesById: new Map()
734
+ };
735
+ const seen = Object.fromEntries(Object.keys(snapshot).map((key) => [key, new Set()]));
736
+ for (let current = state; current; current = current.parent) {
737
+ for (const key of Object.keys(snapshot)) {
738
+ const source = current[key];
739
+ if (source) {
740
+ for (const [itemKey, value] of source) {
741
+ if (seen[key].has(itemKey)) continue;
742
+ seen[key].add(itemKey);
743
+ if (value) snapshot[key].set(itemKey, value);
744
+ }
745
+ }
746
+ if (current !== state && current.snapshot) {
747
+ for (const [itemKey, value] of current.snapshot[key]) {
748
+ if (seen[key].has(itemKey)) continue;
749
+ seen[key].add(itemKey);
750
+ if (value) snapshot[key].set(itemKey, value);
751
+ }
752
+ }
753
+ }
754
+ if (current !== state && current.snapshot) break;
755
+ }
756
+ return snapshot;
757
+ }
758
+
759
+ function validateMergeIdentities(index, model) {
760
+ const snapshots = new Map();
761
+ let checks = 0;
762
+ for (const [commit, parents] of index.parentsByCommit) {
763
+ if (parents.length < 2) continue;
764
+ const identities = new Map();
765
+ for (const parent of parents) {
766
+ const state = recordStateAtCommit(index, model, parent);
767
+ let snapshot = snapshots.get(state);
768
+ if (!snapshot) {
769
+ snapshot = state.snapshot || snapshotRecordState(state);
770
+ snapshots.set(state, snapshot);
771
+ }
772
+ checks += snapshot.identitiesById.size;
773
+ if (checks > MAX_MERGE_IDENTITY_CHECKS) {
774
+ throw new Error("Git merge identity history is too large to reconcile safely.");
775
+ }
776
+ for (const [id, identity] of snapshot.identitiesById) {
777
+ const previous = identities.get(id);
778
+ if (previous && (
779
+ previous.type !== identity.type
780
+ || previous.introducedAt !== identity.introducedAt
781
+ )) {
782
+ throw new Error(`Git history reuses record ID "${id}" across merge branches at ${commit.slice(0, 12)}.`);
783
+ }
784
+ identities.set(id, identity);
785
+ }
786
+ }
787
+ }
788
+ }
789
+
790
+ function materializeRecordState(state) {
791
+ const records = new Map();
792
+ const seen = new Set();
793
+ for (let current = state; current; current = current.parent) {
794
+ for (const [id, item] of current.recordsById) {
795
+ if (seen.has(id)) continue;
796
+ seen.add(id);
797
+ if (item) records.set(id, item);
798
+ }
799
+ }
800
+ return [...records.values()];
801
+ }
802
+
803
+ function historicalSource(sources, revision, path) {
804
+ return revision && path ? sources.get(`${revision}\0${path}`) || "" : "";
805
+ }
806
+
389
807
  function applicableEventObligationIds(records, eventType, riskLevel, occurredAt) {
390
808
  const byId = new Map(records.map((record) => [record.id, record]));
391
809
  return records.filter((record) => {
@@ -406,10 +824,13 @@ function readRevisionRecord(root, revision, path) {
406
824
  }
407
825
  }
408
826
 
409
- function readRevisionSource(root, revision, path) {
827
+ function readRevisionSource(root, revision, path, historyIndex = null) {
410
828
  if (!path) return "";
411
829
  try {
412
- const commit = runGit(root, ["rev-parse", `${revision}^{commit}`]);
830
+ const parentMatch = String(revision).match(/^([a-f0-9]{40})\^$/i);
831
+ const commit = parentMatch && historyIndex
832
+ ? historyIndex.parentsByCommit.get(parentMatch[1])?.[0] || null
833
+ : /^[a-f0-9]{40}$/i.test(String(revision)) ? String(revision) : runGit(root, ["rev-parse", `${revision}^{commit}`]);
413
834
  return commit ? getFileAtRevision(root, commit, path) || "" : "";
414
835
  } catch {
415
836
  return "";
@@ -492,30 +913,84 @@ export async function dismissReconciliation(input = process.cwd(), options = {})
492
913
  }
493
914
 
494
915
  function transitionHandled(resources, eventIds, fingerprints, transition) {
495
- return resources.some((item) => (
496
- item.type === "obligation-event"
497
- ? (() => {
498
- if (!eventIds.includes(item.id) && !fingerprints.includes(item.transitionFingerprint)) return false;
499
- const records = transition.recordsForEvent?.(item) || resources;
500
- const event = records.find((record) => record.id === item.id) || item;
501
- return (eventIds.includes(event.id) || fingerprints.includes(event.transitionFingerprint))
502
- && obligationEventHandlesTransition(event, records, transition.eventType, transition.subjectId, resources);
503
- })()
504
- : !transition.includeDismissed && reconciliationDismissalMatches(item, {
916
+ const currentIndex = transition.currentIndex || indexTransitionResources(resources);
917
+ const matchingEvents = new Set();
918
+ for (const eventId of eventIds) {
919
+ const event = currentIndex.eventsById.get(eventId);
920
+ if (event) matchingEvents.add(event);
921
+ }
922
+ for (const fingerprint of fingerprints) {
923
+ for (const event of currentIndex.eventsByFingerprint.get(fingerprint) || []) {
924
+ transition.consumeMatchEvaluation?.();
925
+ matchingEvents.add(event);
926
+ }
927
+ }
928
+ for (const item of matchingEvents) {
929
+ const records = transition.recordsForEvent?.(item) || resources;
930
+ const historicalIndex = transition.indexFor?.(records) || indexTransitionResources(records);
931
+ const event = historicalIndex.byId.get(item.id) || item;
932
+ if ((eventIds.includes(event.id) || fingerprints.includes(event.transitionFingerprint))
933
+ && obligationEventHandlesTransition(
934
+ event,
935
+ records,
936
+ transition.eventType,
937
+ transition.subjectId,
938
+ resources,
939
+ historicalIndex,
940
+ currentIndex
941
+ )) return true;
942
+ }
943
+ if (transition.includeDismissed) return false;
944
+ const dismissal = currentIndex.byId.get(`reconciliation-dismissal-${fingerprints[0]}`);
945
+ return Boolean(dismissal && reconciliationDismissalMatches(dismissal, {
505
946
  transitionFingerprint: fingerprints[0],
506
947
  eventType: transition.eventType,
507
948
  subject: { id: transition.subjectId }
508
949
  }, resources, {
509
- requireActiveReviewer: !transition.committedRecordIds.has(item.id),
510
- timezone: transition.timezone
950
+ requireActiveReviewer: !transition.committedRecordIds.has(dismissal.id),
951
+ timezone: transition.timezone,
952
+ peopleById: currentIndex.peopleById
511
953
  })
512
- ));
954
+ );
513
955
  }
514
956
 
515
- function obligationEventHandlesTransition(event, records, eventType, subjectId, currentRecords = records) {
957
+ function indexTransitionResources(resources) {
958
+ const index = {
959
+ byId: new Map(),
960
+ peopleById: new Map(),
961
+ eventsById: new Map(),
962
+ eventsByFingerprint: new Map(),
963
+ actionsBySourceAndObligation: new Map()
964
+ };
965
+ for (const record of resources) {
966
+ index.byId.set(record.id, record);
967
+ if (record.type === "person") index.peopleById.set(record.id, record);
968
+ if (record.type === "obligation-event") {
969
+ index.eventsById.set(record.id, record);
970
+ if (record.transitionFingerprint) {
971
+ if (!index.eventsByFingerprint.has(record.transitionFingerprint)) index.eventsByFingerprint.set(record.transitionFingerprint, []);
972
+ index.eventsByFingerprint.get(record.transitionFingerprint).push(record);
973
+ }
974
+ }
975
+ if (record.type === "action-item" && record.sourceResourceId && record.obligationId) {
976
+ index.actionsBySourceAndObligation.set(`${record.sourceResourceId}\0${record.obligationId}`, record);
977
+ }
978
+ }
979
+ return index;
980
+ }
981
+
982
+ function obligationEventHandlesTransition(
983
+ event,
984
+ records,
985
+ eventType,
986
+ subjectId,
987
+ currentRecords = records,
988
+ historicalIndex = indexTransitionResources(records),
989
+ currentIndex = indexTransitionResources(currentRecords)
990
+ ) {
516
991
  if (event?.type !== "obligation-event" || event.eventType !== eventType || event.status === "canceled") return false;
517
992
  if (!(event.subjectResourceIds || []).includes(subjectId)) return false;
518
- const currentEvent = currentRecords.find((record) => record.id === event.id);
993
+ const currentEvent = currentIndex.byId.get(event.id);
519
994
  if (
520
995
  currentEvent?.type !== "obligation-event"
521
996
  || currentEvent.eventType !== eventType
@@ -529,38 +1004,34 @@ function obligationEventHandlesTransition(event, records, eventType, subjectId,
529
1004
  const requiredObligationIds = applicableEventObligationIds(records, eventType, event.riskLevel, occurredAt);
530
1005
  if (!requiredObligationIds.length) return false;
531
1006
  return requiredObligationIds.every((obligationId) => {
532
- const historicalAction = records.find((record) => (
533
- record.type === "action-item"
534
- && record.sourceResourceId === event.id
535
- && record.obligationId === obligationId
536
- && record.status !== "canceled"
537
- ));
1007
+ const historicalAction = historicalIndex.actionsBySourceAndObligation.get(`${event.id}\0${obligationId}`);
1008
+ const currentAction = historicalAction ? currentIndex.byId.get(historicalAction.id) : null;
538
1009
  return (event.obligationIds || []).includes(obligationId)
539
1010
  && (currentEvent.obligationIds || []).includes(obligationId)
540
1011
  && historicalAction
541
- && currentRecords.some((record) => (
542
- record.type === "action-item"
543
- && record.id === historicalAction.id
544
- && record.sourceResourceId === event.id
545
- && record.obligationId === obligationId
546
- && record.status !== "canceled"
547
- ));
1012
+ && historicalAction.status !== "canceled"
1013
+ && currentAction?.type === "action-item"
1014
+ && currentAction.sourceResourceId === event.id
1015
+ && currentAction.obligationId === obligationId
1016
+ && currentAction.status !== "canceled";
548
1017
  });
549
1018
  }
550
1019
 
551
1020
  export function reconciliationDismissalMatches(record, candidate, resources, options = {}) {
552
1021
  if (record?.type !== "reconciliation-dismissal") return false;
553
1022
  const reviewers = Array.isArray(record.reviewedByIds) ? record.reviewedByIds : [];
1023
+ const reviewer = reviewers.length === 1
1024
+ ? options.peopleById?.get(reviewers[0]) || resources.find((item) => (
1025
+ item.type === "person" && item.id === reviewers[0]
1026
+ ))
1027
+ : null;
554
1028
  return record.id === `reconciliation-dismissal-${candidate.transitionFingerprint}`
555
1029
  && record.transitionFingerprint === candidate.transitionFingerprint
556
1030
  && record.eventType === candidate.eventType
557
1031
  && record.subjectResourceId === candidate.subject.id
558
1032
  && reviewers.length === 1
559
- && resources.some((item) => (
560
- item.type === "person"
561
- && item.id === reviewers[0]
562
- && (!options.requireActiveReviewer || item.status === "active")
563
- ))
1033
+ && reviewer
1034
+ && (!options.requireActiveReviewer || reviewer.status === "active")
564
1035
  && /^\d{4}-\d{2}-\d{2}$/.test(record.reviewedOn || "")
565
1036
  && (!options.requireActiveReviewer
566
1037
  || record.reviewedOn === currentCalendarDate(options.timezone || "UTC"))
@@ -572,12 +1043,14 @@ export async function validateReconciliationDismissals(loaded, diagnostics) {
572
1043
  const dismissals = loaded.entries.filter(({ record }) => record.type === "reconciliation-dismissal");
573
1044
  if (!dismissals.length) return;
574
1045
  const raw = await planReconciliation(loaded, { includeHandled: true });
1046
+ const candidateByFingerprint = new Map(raw.candidates.map((candidate) => [
1047
+ candidate.transitionFingerprint,
1048
+ candidate
1049
+ ]));
575
1050
  const committedRecordIds = new Set(getDataRecordHistoryIndex(loaded.root).historiesById.keys());
576
1051
  const seen = new Set();
577
1052
  for (const entry of dismissals) {
578
- const candidate = raw.candidates.find(({ transitionFingerprint }) => (
579
- transitionFingerprint === entry.record.transitionFingerprint
580
- ));
1053
+ const candidate = candidateByFingerprint.get(entry.record.transitionFingerprint);
581
1054
  if (
582
1055
  !candidate
583
1056
  || seen.has(entry.record.transitionFingerprint)
@@ -598,10 +1071,6 @@ export async function validateReconciliationDismissals(loaded, diagnostics) {
598
1071
  return raw;
599
1072
  }
600
1073
 
601
- function parentRevision(root, commit) {
602
- return runGit(root, ["rev-parse", `${commit}^`]) || commit;
603
- }
604
-
605
1074
  function findCandidate(plan, options) {
606
1075
  return plan.candidates.find(({ id, transitionFingerprint }) => (
607
1076
  id === options.candidateId
@@ -610,15 +1079,15 @@ function findCandidate(plan, options) {
610
1079
  ));
611
1080
  }
612
1081
 
613
- function gitChangedEntries(root) {
614
- const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", "HEAD", "--", "data"]));
615
- const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
1082
+ function gitChangedEntries(root, deadline, baseRevision = "HEAD") {
1083
+ const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", baseRevision, "--", "data"], deadline));
1084
+ const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"], deadline))
616
1085
  .map((path) => ({ beforePath: null, afterPath: path }));
617
1086
  return [...tracked, ...untracked]
618
1087
  .filter(({ beforePath, afterPath }) => beforePath?.startsWith("data/") || afterPath?.startsWith("data/"));
619
1088
  }
620
1089
 
621
- function pairWorkingRecordRenames(root, changes, currentByPath) {
1090
+ function pairWorkingRecordRenames(root, changes, currentByPath, baseRevision = null) {
622
1091
  const additions = new Map();
623
1092
  for (const change of changes) {
624
1093
  if (change.beforePath || !change.afterPath?.endsWith(".json")) continue;
@@ -629,7 +1098,7 @@ function pairWorkingRecordRenames(root, changes, currentByPath) {
629
1098
  const result = [];
630
1099
  for (const change of changes) {
631
1100
  if (!change.afterPath && change.beforePath?.endsWith(".json")) {
632
- const record = readHeadRecord(root, change.beforePath);
1101
+ const record = readHeadRecord(root, change.beforePath, baseRevision);
633
1102
  const addition = record?.type && record?.id ? additions.get(`${record.type}:${record.id}`) : null;
634
1103
  if (addition) {
635
1104
  paired.add(addition);
@@ -655,32 +1124,39 @@ function parseNameStatus(value) {
655
1124
  }).filter(Boolean);
656
1125
  }
657
1126
 
658
- function readHeadRecord(root, path) {
1127
+ function readHeadRecord(root, path, revision = null) {
659
1128
  if (!path) return null;
660
1129
  try {
661
- return JSON.parse(readHeadSource(root, path));
1130
+ return JSON.parse(readHeadSource(root, path, revision));
662
1131
  } catch {
663
1132
  return null;
664
1133
  }
665
1134
  }
666
1135
 
667
- function readHeadSource(root, path) {
1136
+ function readHeadSource(root, path, revision = null) {
668
1137
  try {
669
- const commit = gitRevision(root);
1138
+ const commit = revision || gitRevision(root);
670
1139
  return commit ? getFileAtRevision(root, commit, path) || "" : "";
671
1140
  } catch {
672
1141
  return "";
673
1142
  }
674
1143
  }
675
1144
 
676
- function gitRevision(root) {
677
- return runGit(root, ["rev-parse", "HEAD"]) || null;
1145
+ function gitRevision(root, deadline) {
1146
+ return runGit(root, ["rev-parse", "HEAD"], deadline) || null;
678
1147
  }
679
1148
 
680
- function runGit(root, args) {
1149
+ function runGit(root, args, deadline) {
681
1150
  try {
682
- return runGitCommandSync(root, args);
683
- } catch {
1151
+ const timeoutMs = deadline ? Math.floor(deadline - performance.now()) : undefined;
1152
+ if (deadline && timeoutMs <= 0) throw new Error("Git reconciliation history exceeded its cumulative deadline.");
1153
+ return runGitCommandSync(root, args, timeoutMs ? { timeoutMs } : {});
1154
+ } catch (error) {
1155
+ if (deadline && performance.now() >= deadline) {
1156
+ const timeoutError = new Error("Git reconciliation history exceeded its cumulative deadline.", { cause: error });
1157
+ timeoutError.code = "FILEGRC_HISTORY_DEADLINE";
1158
+ throw timeoutError;
1159
+ }
684
1160
  return "";
685
1161
  }
686
1162
  }
@@ -715,22 +1191,3 @@ function reconciliationCommand(eventType, subjectId, fingerprint, needsTimestamp
715
1191
  const riskFlag = eventType === "person-ended" ? " --risk-level normal|high" : "";
716
1192
  return `npx filegrc reconcile --apply --candidate ${fingerprint}${timeFlag}${riskFlag} --yes`;
717
1193
  }
718
-
719
- function historicalMarkdownOwners(model, recordsById) {
720
- const owners = new Map();
721
- for (const { record, path } of recordsById.values()) {
722
- const entry = { record, relativePath: path.replace(/^data\//, "") };
723
- for (const markdown of markdownEntries(model, record)) owners.set(`data/${markdown.path}`, entry);
724
- }
725
- return owners;
726
- }
727
-
728
- function recordsAtRevision(root, revision) {
729
- const records = new Map();
730
- for (const path of lines(runGit(root, ["ls-tree", "-r", "--name-only", revision, "--", "data"]))) {
731
- if (!path.endsWith(".json")) continue;
732
- const record = readRevisionRecord(root, revision, path);
733
- if (record?.id) records.set(record.id, { record, path });
734
- }
735
- return records;
736
- }