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.
- package/package.json +1 -1
- package/src/git.js +398 -65
- package/src/reconciliation.js +607 -150
- package/src/validate.js +39 -7
package/src/reconciliation.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
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
|
-
?
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 =
|
|
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 =
|
|
185
|
-
const current =
|
|
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 =
|
|
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
|
-
|
|
196
|
-
...changedMarkdownPaths.map((changed) =>
|
|
312
|
+
historicalSource(sourceByRevisionAndPath, commit, currentRecordPath),
|
|
313
|
+
...changedMarkdownPaths.map((changed) => historicalSource(sourceByRevisionAndPath, commit, changed))
|
|
197
314
|
].join("\n");
|
|
198
315
|
const beforeSource = [
|
|
199
|
-
|
|
200
|
-
...changedMarkdownPaths.map((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:
|
|
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 =
|
|
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:
|
|
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(
|
|
344
|
-
const commits =
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
533
|
-
|
|
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
|
-
&&
|
|
542
|
-
|
|
543
|
-
&&
|
|
544
|
-
&&
|
|
545
|
-
&&
|
|
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
|
-
&&
|
|
560
|
-
|
|
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 =
|
|
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",
|
|
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
|
-
|
|
683
|
-
|
|
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
|
-
}
|