filegrc 0.12.2 → 0.12.4
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 +677 -106
- package/src/reconciliation.js +616 -154
- package/src/server.js +356 -52
- package/src/state.js +18 -10
- package/src/validate.js +112 -15
- package/src/web.js +75 -9
- package/src/workflow-history-integrity.js +19 -9
- package/src/workflow.js +7 -2
- package/src/workspace.js +3 -0
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) => {
|
|
@@ -401,17 +819,22 @@ function readRevisionRecord(root, revision, path) {
|
|
|
401
819
|
if (!path) return null;
|
|
402
820
|
try {
|
|
403
821
|
return JSON.parse(readRevisionSource(root, revision, path));
|
|
404
|
-
} catch {
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
|
|
405
824
|
return null;
|
|
406
825
|
}
|
|
407
826
|
}
|
|
408
827
|
|
|
409
|
-
function readRevisionSource(root, revision, path) {
|
|
828
|
+
function readRevisionSource(root, revision, path, historyIndex = null) {
|
|
410
829
|
if (!path) return "";
|
|
411
830
|
try {
|
|
412
|
-
const
|
|
831
|
+
const parentMatch = String(revision).match(/^([a-f0-9]{40})\^$/i);
|
|
832
|
+
const commit = parentMatch && historyIndex
|
|
833
|
+
? historyIndex.parentsByCommit.get(parentMatch[1])?.[0] || null
|
|
834
|
+
: /^[a-f0-9]{40}$/i.test(String(revision)) ? String(revision) : runGit(root, ["rev-parse", `${revision}^{commit}`]);
|
|
413
835
|
return commit ? getFileAtRevision(root, commit, path) || "" : "";
|
|
414
|
-
} catch {
|
|
836
|
+
} catch (error) {
|
|
837
|
+
if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
|
|
415
838
|
return "";
|
|
416
839
|
}
|
|
417
840
|
}
|
|
@@ -492,30 +915,84 @@ export async function dismissReconciliation(input = process.cwd(), options = {})
|
|
|
492
915
|
}
|
|
493
916
|
|
|
494
917
|
function transitionHandled(resources, eventIds, fingerprints, transition) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
918
|
+
const currentIndex = transition.currentIndex || indexTransitionResources(resources);
|
|
919
|
+
const matchingEvents = new Set();
|
|
920
|
+
for (const eventId of eventIds) {
|
|
921
|
+
const event = currentIndex.eventsById.get(eventId);
|
|
922
|
+
if (event) matchingEvents.add(event);
|
|
923
|
+
}
|
|
924
|
+
for (const fingerprint of fingerprints) {
|
|
925
|
+
for (const event of currentIndex.eventsByFingerprint.get(fingerprint) || []) {
|
|
926
|
+
transition.consumeMatchEvaluation?.();
|
|
927
|
+
matchingEvents.add(event);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
for (const item of matchingEvents) {
|
|
931
|
+
const records = transition.recordsForEvent?.(item) || resources;
|
|
932
|
+
const historicalIndex = transition.indexFor?.(records) || indexTransitionResources(records);
|
|
933
|
+
const event = historicalIndex.byId.get(item.id) || item;
|
|
934
|
+
if ((eventIds.includes(event.id) || fingerprints.includes(event.transitionFingerprint))
|
|
935
|
+
&& obligationEventHandlesTransition(
|
|
936
|
+
event,
|
|
937
|
+
records,
|
|
938
|
+
transition.eventType,
|
|
939
|
+
transition.subjectId,
|
|
940
|
+
resources,
|
|
941
|
+
historicalIndex,
|
|
942
|
+
currentIndex
|
|
943
|
+
)) return true;
|
|
944
|
+
}
|
|
945
|
+
if (transition.includeDismissed) return false;
|
|
946
|
+
const dismissal = currentIndex.byId.get(`reconciliation-dismissal-${fingerprints[0]}`);
|
|
947
|
+
return Boolean(dismissal && reconciliationDismissalMatches(dismissal, {
|
|
505
948
|
transitionFingerprint: fingerprints[0],
|
|
506
949
|
eventType: transition.eventType,
|
|
507
950
|
subject: { id: transition.subjectId }
|
|
508
951
|
}, resources, {
|
|
509
|
-
requireActiveReviewer: !transition.committedRecordIds.has(
|
|
510
|
-
timezone: transition.timezone
|
|
952
|
+
requireActiveReviewer: !transition.committedRecordIds.has(dismissal.id),
|
|
953
|
+
timezone: transition.timezone,
|
|
954
|
+
peopleById: currentIndex.peopleById
|
|
511
955
|
})
|
|
512
|
-
)
|
|
956
|
+
);
|
|
513
957
|
}
|
|
514
958
|
|
|
515
|
-
function
|
|
959
|
+
function indexTransitionResources(resources) {
|
|
960
|
+
const index = {
|
|
961
|
+
byId: new Map(),
|
|
962
|
+
peopleById: new Map(),
|
|
963
|
+
eventsById: new Map(),
|
|
964
|
+
eventsByFingerprint: new Map(),
|
|
965
|
+
actionsBySourceAndObligation: new Map()
|
|
966
|
+
};
|
|
967
|
+
for (const record of resources) {
|
|
968
|
+
index.byId.set(record.id, record);
|
|
969
|
+
if (record.type === "person") index.peopleById.set(record.id, record);
|
|
970
|
+
if (record.type === "obligation-event") {
|
|
971
|
+
index.eventsById.set(record.id, record);
|
|
972
|
+
if (record.transitionFingerprint) {
|
|
973
|
+
if (!index.eventsByFingerprint.has(record.transitionFingerprint)) index.eventsByFingerprint.set(record.transitionFingerprint, []);
|
|
974
|
+
index.eventsByFingerprint.get(record.transitionFingerprint).push(record);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (record.type === "action-item" && record.sourceResourceId && record.obligationId) {
|
|
978
|
+
index.actionsBySourceAndObligation.set(`${record.sourceResourceId}\0${record.obligationId}`, record);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return index;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function obligationEventHandlesTransition(
|
|
985
|
+
event,
|
|
986
|
+
records,
|
|
987
|
+
eventType,
|
|
988
|
+
subjectId,
|
|
989
|
+
currentRecords = records,
|
|
990
|
+
historicalIndex = indexTransitionResources(records),
|
|
991
|
+
currentIndex = indexTransitionResources(currentRecords)
|
|
992
|
+
) {
|
|
516
993
|
if (event?.type !== "obligation-event" || event.eventType !== eventType || event.status === "canceled") return false;
|
|
517
994
|
if (!(event.subjectResourceIds || []).includes(subjectId)) return false;
|
|
518
|
-
const currentEvent =
|
|
995
|
+
const currentEvent = currentIndex.byId.get(event.id);
|
|
519
996
|
if (
|
|
520
997
|
currentEvent?.type !== "obligation-event"
|
|
521
998
|
|| currentEvent.eventType !== eventType
|
|
@@ -529,38 +1006,34 @@ function obligationEventHandlesTransition(event, records, eventType, subjectId,
|
|
|
529
1006
|
const requiredObligationIds = applicableEventObligationIds(records, eventType, event.riskLevel, occurredAt);
|
|
530
1007
|
if (!requiredObligationIds.length) return false;
|
|
531
1008
|
return requiredObligationIds.every((obligationId) => {
|
|
532
|
-
const historicalAction =
|
|
533
|
-
|
|
534
|
-
&& record.sourceResourceId === event.id
|
|
535
|
-
&& record.obligationId === obligationId
|
|
536
|
-
&& record.status !== "canceled"
|
|
537
|
-
));
|
|
1009
|
+
const historicalAction = historicalIndex.actionsBySourceAndObligation.get(`${event.id}\0${obligationId}`);
|
|
1010
|
+
const currentAction = historicalAction ? currentIndex.byId.get(historicalAction.id) : null;
|
|
538
1011
|
return (event.obligationIds || []).includes(obligationId)
|
|
539
1012
|
&& (currentEvent.obligationIds || []).includes(obligationId)
|
|
540
1013
|
&& historicalAction
|
|
541
|
-
&&
|
|
542
|
-
|
|
543
|
-
&&
|
|
544
|
-
&&
|
|
545
|
-
&&
|
|
546
|
-
&& record.status !== "canceled"
|
|
547
|
-
));
|
|
1014
|
+
&& historicalAction.status !== "canceled"
|
|
1015
|
+
&& currentAction?.type === "action-item"
|
|
1016
|
+
&& currentAction.sourceResourceId === event.id
|
|
1017
|
+
&& currentAction.obligationId === obligationId
|
|
1018
|
+
&& currentAction.status !== "canceled";
|
|
548
1019
|
});
|
|
549
1020
|
}
|
|
550
1021
|
|
|
551
1022
|
export function reconciliationDismissalMatches(record, candidate, resources, options = {}) {
|
|
552
1023
|
if (record?.type !== "reconciliation-dismissal") return false;
|
|
553
1024
|
const reviewers = Array.isArray(record.reviewedByIds) ? record.reviewedByIds : [];
|
|
1025
|
+
const reviewer = reviewers.length === 1
|
|
1026
|
+
? options.peopleById?.get(reviewers[0]) || resources.find((item) => (
|
|
1027
|
+
item.type === "person" && item.id === reviewers[0]
|
|
1028
|
+
))
|
|
1029
|
+
: null;
|
|
554
1030
|
return record.id === `reconciliation-dismissal-${candidate.transitionFingerprint}`
|
|
555
1031
|
&& record.transitionFingerprint === candidate.transitionFingerprint
|
|
556
1032
|
&& record.eventType === candidate.eventType
|
|
557
1033
|
&& record.subjectResourceId === candidate.subject.id
|
|
558
1034
|
&& reviewers.length === 1
|
|
559
|
-
&&
|
|
560
|
-
|
|
561
|
-
&& item.id === reviewers[0]
|
|
562
|
-
&& (!options.requireActiveReviewer || item.status === "active")
|
|
563
|
-
))
|
|
1035
|
+
&& reviewer
|
|
1036
|
+
&& (!options.requireActiveReviewer || reviewer.status === "active")
|
|
564
1037
|
&& /^\d{4}-\d{2}-\d{2}$/.test(record.reviewedOn || "")
|
|
565
1038
|
&& (!options.requireActiveReviewer
|
|
566
1039
|
|| record.reviewedOn === currentCalendarDate(options.timezone || "UTC"))
|
|
@@ -572,12 +1045,14 @@ export async function validateReconciliationDismissals(loaded, diagnostics) {
|
|
|
572
1045
|
const dismissals = loaded.entries.filter(({ record }) => record.type === "reconciliation-dismissal");
|
|
573
1046
|
if (!dismissals.length) return;
|
|
574
1047
|
const raw = await planReconciliation(loaded, { includeHandled: true });
|
|
1048
|
+
const candidateByFingerprint = new Map(raw.candidates.map((candidate) => [
|
|
1049
|
+
candidate.transitionFingerprint,
|
|
1050
|
+
candidate
|
|
1051
|
+
]));
|
|
575
1052
|
const committedRecordIds = new Set(getDataRecordHistoryIndex(loaded.root).historiesById.keys());
|
|
576
1053
|
const seen = new Set();
|
|
577
1054
|
for (const entry of dismissals) {
|
|
578
|
-
const candidate =
|
|
579
|
-
transitionFingerprint === entry.record.transitionFingerprint
|
|
580
|
-
));
|
|
1055
|
+
const candidate = candidateByFingerprint.get(entry.record.transitionFingerprint);
|
|
581
1056
|
if (
|
|
582
1057
|
!candidate
|
|
583
1058
|
|| seen.has(entry.record.transitionFingerprint)
|
|
@@ -598,10 +1073,6 @@ export async function validateReconciliationDismissals(loaded, diagnostics) {
|
|
|
598
1073
|
return raw;
|
|
599
1074
|
}
|
|
600
1075
|
|
|
601
|
-
function parentRevision(root, commit) {
|
|
602
|
-
return runGit(root, ["rev-parse", `${commit}^`]) || commit;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
1076
|
function findCandidate(plan, options) {
|
|
606
1077
|
return plan.candidates.find(({ id, transitionFingerprint }) => (
|
|
607
1078
|
id === options.candidateId
|
|
@@ -610,15 +1081,15 @@ function findCandidate(plan, options) {
|
|
|
610
1081
|
));
|
|
611
1082
|
}
|
|
612
1083
|
|
|
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"]))
|
|
1084
|
+
function gitChangedEntries(root, deadline, baseRevision = "HEAD") {
|
|
1085
|
+
const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", baseRevision, "--", "data"], deadline));
|
|
1086
|
+
const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"], deadline))
|
|
616
1087
|
.map((path) => ({ beforePath: null, afterPath: path }));
|
|
617
1088
|
return [...tracked, ...untracked]
|
|
618
1089
|
.filter(({ beforePath, afterPath }) => beforePath?.startsWith("data/") || afterPath?.startsWith("data/"));
|
|
619
1090
|
}
|
|
620
1091
|
|
|
621
|
-
function pairWorkingRecordRenames(root, changes, currentByPath) {
|
|
1092
|
+
function pairWorkingRecordRenames(root, changes, currentByPath, baseRevision = null) {
|
|
622
1093
|
const additions = new Map();
|
|
623
1094
|
for (const change of changes) {
|
|
624
1095
|
if (change.beforePath || !change.afterPath?.endsWith(".json")) continue;
|
|
@@ -629,7 +1100,7 @@ function pairWorkingRecordRenames(root, changes, currentByPath) {
|
|
|
629
1100
|
const result = [];
|
|
630
1101
|
for (const change of changes) {
|
|
631
1102
|
if (!change.afterPath && change.beforePath?.endsWith(".json")) {
|
|
632
|
-
const record = readHeadRecord(root, change.beforePath);
|
|
1103
|
+
const record = readHeadRecord(root, change.beforePath, baseRevision);
|
|
633
1104
|
const addition = record?.type && record?.id ? additions.get(`${record.type}:${record.id}`) : null;
|
|
634
1105
|
if (addition) {
|
|
635
1106
|
paired.add(addition);
|
|
@@ -655,32 +1126,42 @@ function parseNameStatus(value) {
|
|
|
655
1126
|
}).filter(Boolean);
|
|
656
1127
|
}
|
|
657
1128
|
|
|
658
|
-
function readHeadRecord(root, path) {
|
|
1129
|
+
function readHeadRecord(root, path, revision = null) {
|
|
659
1130
|
if (!path) return null;
|
|
660
1131
|
try {
|
|
661
|
-
return JSON.parse(readHeadSource(root, path));
|
|
662
|
-
} catch {
|
|
1132
|
+
return JSON.parse(readHeadSource(root, path, revision));
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
|
|
663
1135
|
return null;
|
|
664
1136
|
}
|
|
665
1137
|
}
|
|
666
1138
|
|
|
667
|
-
function readHeadSource(root, path) {
|
|
1139
|
+
function readHeadSource(root, path, revision = null) {
|
|
668
1140
|
try {
|
|
669
|
-
const commit = gitRevision(root);
|
|
1141
|
+
const commit = revision || gitRevision(root);
|
|
670
1142
|
return commit ? getFileAtRevision(root, commit, path) || "" : "";
|
|
671
|
-
} catch {
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
|
|
672
1145
|
return "";
|
|
673
1146
|
}
|
|
674
1147
|
}
|
|
675
1148
|
|
|
676
|
-
function gitRevision(root) {
|
|
677
|
-
return runGit(root, ["rev-parse", "HEAD"]) || null;
|
|
1149
|
+
function gitRevision(root, deadline) {
|
|
1150
|
+
return runGit(root, ["rev-parse", "HEAD"], deadline) || null;
|
|
678
1151
|
}
|
|
679
1152
|
|
|
680
|
-
function runGit(root, args) {
|
|
1153
|
+
function runGit(root, args, deadline) {
|
|
681
1154
|
try {
|
|
682
|
-
|
|
683
|
-
|
|
1155
|
+
const timeoutMs = deadline ? Math.floor(deadline - performance.now()) : undefined;
|
|
1156
|
+
if (deadline && timeoutMs <= 0) throw new Error("Git reconciliation history exceeded its cumulative deadline.");
|
|
1157
|
+
return runGitCommandSync(root, args, timeoutMs ? { timeoutMs } : {});
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
|
|
1160
|
+
if (deadline && performance.now() >= deadline) {
|
|
1161
|
+
const timeoutError = new Error("Git reconciliation history exceeded its cumulative deadline.", { cause: error });
|
|
1162
|
+
timeoutError.code = "FILEGRC_HISTORY_DEADLINE";
|
|
1163
|
+
throw timeoutError;
|
|
1164
|
+
}
|
|
684
1165
|
return "";
|
|
685
1166
|
}
|
|
686
1167
|
}
|
|
@@ -715,22 +1196,3 @@ function reconciliationCommand(eventType, subjectId, fingerprint, needsTimestamp
|
|
|
715
1196
|
const riskFlag = eventType === "person-ended" ? " --risk-level normal|high" : "";
|
|
716
1197
|
return `npx filegrc reconcile --apply --candidate ${fingerprint}${timeFlag}${riskFlag} --yes`;
|
|
717
1198
|
}
|
|
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
|
-
}
|