filegrc 0.12.1 → 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/model/v10.json +67 -1
- package/package.json +1 -1
- package/src/cli.js +16 -4
- package/src/files.js +14 -2
- package/src/git.js +398 -65
- package/src/index.js +1 -1
- package/src/obligations.js +1 -1
- package/src/reconciliation.js +799 -149
- package/src/server.js +10 -1
- package/src/state.js +9 -0
- package/src/validate.js +45 -7
- package/src/web.js +59 -2
- package/src/workflow-history-integrity.js +31 -0
package/src/reconciliation.js
CHANGED
|
@@ -1,12 +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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
import { loadModel, modelSupports, SUPPORTED_MODEL_VERSIONS } from "../model/index.js";
|
|
6
|
+
import { createObligationEvent, obligationRule } from "./obligations.js";
|
|
7
|
+
import { createResource, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
|
|
8
|
+
import { getDataRecordHistoryIndex, getFileAtRevision, getFilesAtRevisions, runGitCommandSync } from "./git.js";
|
|
7
9
|
import { markdownEntries } from "./resource-markdown.js";
|
|
8
10
|
import { loadWorkspace } from "./workspace.js";
|
|
9
11
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
12
|
+
import { currentCalendarDate } from "./time.js";
|
|
13
|
+
import { assertHistoricalRecordValid } from "./validate.js";
|
|
10
14
|
|
|
11
15
|
const TRANSITIONS = {
|
|
12
16
|
person: [
|
|
@@ -93,8 +97,29 @@ const TRANSITIONS = {
|
|
|
93
97
|
message: "Confirm whether this Asset disposal needs sanitization and evidence work."
|
|
94
98
|
}]
|
|
95
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
|
+
]);
|
|
96
114
|
|
|
97
|
-
|
|
115
|
+
function allowsHistoricalTypeTransition(parentModel, currentModel, beforeType, afterType) {
|
|
116
|
+
return parentModel?.modelVersion === "3"
|
|
117
|
+
&& currentModel?.modelVersion === "4"
|
|
118
|
+
&& beforeType === "system"
|
|
119
|
+
&& afterType === "component";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function planReconciliation(input = process.cwd(), options = {}) {
|
|
98
123
|
const loaded = input?.entries && input?.resources && input?.model
|
|
99
124
|
? input
|
|
100
125
|
: await loadWorkspace(input);
|
|
@@ -107,58 +132,107 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
107
132
|
message: "Direct-file transition reconciliation is available after migrating this workspace to model v3."
|
|
108
133
|
};
|
|
109
134
|
}
|
|
110
|
-
const
|
|
135
|
+
const historyDeadline = performance.now() + 10_000;
|
|
136
|
+
const baseRevision = gitRevision(loaded.root, historyDeadline);
|
|
111
137
|
const currentByPath = new Map(loaded.entries.map((entry) => [
|
|
112
138
|
`data/${entry.relativePath}`,
|
|
113
139
|
entry
|
|
114
140
|
]));
|
|
115
|
-
const
|
|
116
|
-
loaded.root,
|
|
117
|
-
gitChangedEntries(loaded.root),
|
|
118
|
-
currentByPath
|
|
119
|
-
);
|
|
120
|
-
const changedPaths = [...new Set(workingChanges.flatMap(({ beforePath, afterPath }) => (
|
|
121
|
-
[beforePath, afterPath].filter(Boolean)
|
|
122
|
-
)))].sort();
|
|
141
|
+
const rawWorkingChanges = gitChangedEntries(loaded.root, historyDeadline, baseRevision);
|
|
123
142
|
if (!baseRevision) {
|
|
124
143
|
return {
|
|
125
144
|
contractVersion: 1,
|
|
126
145
|
gitRevision: null,
|
|
127
|
-
changedPaths,
|
|
146
|
+
changedPaths: [...new Set(rawWorkingChanges.flatMap(({ beforePath, afterPath }) => (
|
|
147
|
+
[beforePath, afterPath].filter(Boolean)
|
|
148
|
+
)))].sort(),
|
|
128
149
|
candidates: [],
|
|
129
150
|
message: "Commit the initial workspace before reconciling later direct-file transitions."
|
|
130
151
|
};
|
|
131
152
|
}
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
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);
|
|
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
|
+
};
|
|
175
|
+
const eventRecordSnapshots = new Map();
|
|
176
|
+
let snapshotRecordsProcessed = 0;
|
|
177
|
+
const recordsForEvent = (event) => {
|
|
178
|
+
if (eventRecordSnapshots.has(event.id)) return eventRecordSnapshots.get(event.id);
|
|
179
|
+
const history = historyIndex.historiesById.get(event.id) || [];
|
|
180
|
+
const firstCommit = history.at(-1)?.commit;
|
|
181
|
+
const records = firstCommit
|
|
182
|
+
? materializeRecordState(recordStateAtCommit(historyContext, loaded.model, firstCommit)).map(({ record }) => record)
|
|
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
|
+
}
|
|
188
|
+
eventRecordSnapshots.set(event.id, records);
|
|
189
|
+
while (eventRecordSnapshots.size > 4) eventRecordSnapshots.delete(eventRecordSnapshots.keys().next().value);
|
|
190
|
+
return records;
|
|
191
|
+
};
|
|
137
192
|
const currentMarkdownOwners = new Map();
|
|
138
193
|
for (const entry of loaded.entries) {
|
|
139
194
|
for (const markdown of markdownEntries(loaded.model, entry.record)) currentMarkdownOwners.set(`data/${markdown.path}`, entry);
|
|
140
195
|
}
|
|
141
|
-
const
|
|
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
|
+
}
|
|
203
|
+
const rawCandidates = [];
|
|
204
|
+
const filteredCandidates = [];
|
|
142
205
|
const examined = new Set();
|
|
143
|
-
|
|
206
|
+
const committedContexts = [];
|
|
207
|
+
const historicalRequests = new Map();
|
|
144
208
|
for (const commit of reconciliationHistory) {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
for (const [id, item] of historyIndex.recordsByCommit.get(commit) || []) historicalRecords.set(id, item);
|
|
153
|
-
const markdownOwners = historicalMarkdownOwners(loaded.model, historicalRecords);
|
|
154
|
-
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);
|
|
155
215
|
const commitPaths = [...new Set(commitChanges.flatMap(({ beforePath, afterPath }) => (
|
|
156
216
|
[beforePath, afterPath].filter(Boolean)
|
|
157
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 = [];
|
|
158
230
|
const commitExamined = new Set();
|
|
159
231
|
for (const change of commitChanges) {
|
|
160
232
|
const path = change.afterPath || change.beforePath;
|
|
161
|
-
const currentEntry =
|
|
233
|
+
const currentEntry = recordAtPath(recordsAfter, path)
|
|
234
|
+
|| markdownOwnerAtPath(recordsAfter, path)
|
|
235
|
+
|| markdownOwnerAtPath(recordsBefore, path);
|
|
162
236
|
const previousRecordPath = change.beforePath?.endsWith(".json")
|
|
163
237
|
? change.beforePath
|
|
164
238
|
: currentEntry ? `data/${currentEntry.relativePath}` : null;
|
|
@@ -166,42 +240,115 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
166
240
|
? change.afterPath
|
|
167
241
|
: currentEntry ? `data/${currentEntry.relativePath}` : null;
|
|
168
242
|
const recordPath = currentRecordPath || previousRecordPath;
|
|
169
|
-
const previous =
|
|
170
|
-
const current =
|
|
243
|
+
const previous = recordAtPath(recordsBefore, previousRecordPath)?.record || null;
|
|
244
|
+
const current = recordAtPath(recordsAfter, currentRecordPath)?.record || null;
|
|
171
245
|
const record = current || previous;
|
|
172
246
|
if (!record || commitExamined.has(`${record.type}:${record.id}`)) continue;
|
|
173
247
|
commitExamined.add(`${record.type}:${record.id}`);
|
|
174
|
-
const changedMarkdownPaths =
|
|
175
|
-
markdownOwners.get(changed)?.record.id === record.id
|
|
176
|
-
|| markdownOwnersBefore.get(changed)?.record.id === record.id
|
|
177
|
-
));
|
|
248
|
+
const changedMarkdownPaths = changedMarkdownPathsByRecordId.get(record.id) || [];
|
|
178
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;
|
|
179
311
|
const currentSource = [
|
|
180
|
-
|
|
181
|
-
...changedMarkdownPaths.map((changed) =>
|
|
312
|
+
historicalSource(sourceByRevisionAndPath, commit, currentRecordPath),
|
|
313
|
+
...changedMarkdownPaths.map((changed) => historicalSource(sourceByRevisionAndPath, commit, changed))
|
|
182
314
|
].join("\n");
|
|
183
315
|
const beforeSource = [
|
|
184
|
-
|
|
185
|
-
...changedMarkdownPaths.map((changed) =>
|
|
316
|
+
historicalSource(sourceByRevisionAndPath, parent, previousRecordPath),
|
|
317
|
+
...changedMarkdownPaths.map((changed) => historicalSource(sourceByRevisionAndPath, parent, changed))
|
|
186
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;
|
|
187
329
|
for (const transition of TRANSITIONS[record.type] || []) {
|
|
188
330
|
if (!transition.applies(previous, current, { markdownChanged })) continue;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
baseRevision: commit,
|
|
331
|
+
const fingerprintInput = {
|
|
332
|
+
baseRevision: parent || commit,
|
|
192
333
|
eventType: transition.eventType,
|
|
193
334
|
subjectId: record.id,
|
|
194
335
|
path: recordPath,
|
|
195
336
|
beforeSource,
|
|
196
337
|
currentSource,
|
|
197
338
|
markdownChanged
|
|
339
|
+
};
|
|
340
|
+
const fingerprint = transitionFingerprint(fingerprintInput);
|
|
341
|
+
const legacyCommittedFingerprint = transitionFingerprint({ ...fingerprintInput, baseRevision: commit });
|
|
342
|
+
const legacyWorkingFingerprint = transitionFingerprint({
|
|
343
|
+
...fingerprintInput,
|
|
344
|
+
beforeSource: [previous ? JSON.stringify(previous) : "", ...changedMarkdownPaths.map((changed) => (
|
|
345
|
+
readRevisionSource(loaded.root, `${commit}^`, changed, historyIndex)
|
|
346
|
+
))].join("\n")
|
|
198
347
|
});
|
|
199
348
|
const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
))) continue;
|
|
204
|
-
candidates.push(reconciliationCandidate(
|
|
349
|
+
const handledFingerprints = [fingerprint, legacyCommittedFingerprint, legacyWorkingFingerprint];
|
|
350
|
+
const handledEventIds = handledFingerprints.map((value) => `obligation-event-git-${value.slice(0, 16)}`);
|
|
351
|
+
const candidate = reconciliationCandidate(
|
|
205
352
|
loaded,
|
|
206
353
|
transition,
|
|
207
354
|
record,
|
|
@@ -209,7 +356,22 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
209
356
|
fingerprint,
|
|
210
357
|
eventId,
|
|
211
358
|
{ committedRevision: commit }
|
|
212
|
-
)
|
|
359
|
+
);
|
|
360
|
+
rawCandidates.push(candidate);
|
|
361
|
+
if (!transitionHandled(loaded.resources, handledEventIds, handledFingerprints, {
|
|
362
|
+
eventType: transition.eventType,
|
|
363
|
+
subjectId: record.id,
|
|
364
|
+
includeDismissed: options.includeDismissed,
|
|
365
|
+
committedRecordIds,
|
|
366
|
+
timezone: loaded.workspace.timezone,
|
|
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
|
+
}
|
|
374
|
+
})) filteredCandidates.push(candidate);
|
|
213
375
|
}
|
|
214
376
|
}
|
|
215
377
|
}
|
|
@@ -223,14 +385,12 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
223
385
|
const currentRecordPath = change.afterPath?.endsWith(".json")
|
|
224
386
|
? change.afterPath
|
|
225
387
|
: currentEntry ? `data/${currentEntry.relativePath}` : null;
|
|
226
|
-
const previous = readHeadRecord(loaded.root, previousRecordPath);
|
|
388
|
+
const previous = readHeadRecord(loaded.root, previousRecordPath, baseRevision);
|
|
227
389
|
const current = currentRecordPath ? currentByPath.get(currentRecordPath)?.record || currentEntry?.record || null : null;
|
|
228
390
|
const record = current || previous;
|
|
229
391
|
if (!record || examined.has(`${record.type}:${record.id}`)) continue;
|
|
230
392
|
examined.add(`${record.type}:${record.id}`);
|
|
231
|
-
const changedMarkdownPaths =
|
|
232
|
-
currentMarkdownOwners.get(changed)?.record.id === record.id
|
|
233
|
-
));
|
|
393
|
+
const changedMarkdownPaths = workingMarkdownPathsByRecordId.get(record.id) || [];
|
|
234
394
|
const markdownChanged = changedMarkdownPaths.length > 0;
|
|
235
395
|
const currentMarkdown = await Promise.all(changedMarkdownPaths.map(async (changed) => {
|
|
236
396
|
try {
|
|
@@ -239,12 +399,12 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
239
399
|
return "";
|
|
240
400
|
}
|
|
241
401
|
}));
|
|
242
|
-
const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed));
|
|
402
|
+
const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed, baseRevision));
|
|
243
403
|
const currentSource = [currentEntry?.source || "", ...currentMarkdown].join("\n");
|
|
244
|
-
const beforeSource = [
|
|
404
|
+
const beforeSource = [readHeadSource(loaded.root, previousRecordPath, baseRevision), ...previousMarkdown].join("\n");
|
|
245
405
|
for (const transition of TRANSITIONS[record.type] || []) {
|
|
246
406
|
if (!transition.applies(previous, current, { markdownChanged })) continue;
|
|
247
|
-
const
|
|
407
|
+
const fingerprintInput = {
|
|
248
408
|
baseRevision,
|
|
249
409
|
eventType: transition.eventType,
|
|
250
410
|
subjectId: record.id,
|
|
@@ -252,24 +412,46 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
252
412
|
beforeSource,
|
|
253
413
|
currentSource,
|
|
254
414
|
markdownChanged
|
|
415
|
+
};
|
|
416
|
+
const fingerprint = transitionFingerprint(fingerprintInput);
|
|
417
|
+
const legacyFingerprint = transitionFingerprint({
|
|
418
|
+
...fingerprintInput,
|
|
419
|
+
beforeSource: [previous ? JSON.stringify(previous) : "", ...previousMarkdown].join("\n")
|
|
255
420
|
});
|
|
256
421
|
const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
422
|
+
const legacyEventId = `obligation-event-git-${legacyFingerprint.slice(0, 16)}`;
|
|
423
|
+
const candidate = reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId);
|
|
424
|
+
rawCandidates.push(candidate);
|
|
425
|
+
if (!transitionHandled(loaded.resources, [eventId, legacyEventId], [fingerprint, legacyFingerprint], {
|
|
426
|
+
eventType: transition.eventType,
|
|
427
|
+
subjectId: record.id,
|
|
428
|
+
includeDismissed: options.includeDismissed,
|
|
429
|
+
committedRecordIds,
|
|
430
|
+
timezone: loaded.workspace.timezone,
|
|
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
|
+
}
|
|
438
|
+
})) filteredCandidates.push(candidate);
|
|
265
439
|
}
|
|
266
440
|
}
|
|
267
|
-
|
|
441
|
+
const result = {
|
|
268
442
|
contractVersion: 1,
|
|
269
|
-
gitRevision:
|
|
443
|
+
gitRevision: baseRevision,
|
|
270
444
|
changedPaths,
|
|
271
|
-
candidates:
|
|
445
|
+
candidates: (options.includeHandled ? rawCandidates : filteredCandidates)
|
|
446
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
272
447
|
};
|
|
448
|
+
if (options.includeHandled) {
|
|
449
|
+
Object.defineProperty(result, "filteredPlan", {
|
|
450
|
+
value: { ...result, candidates: filteredCandidates.sort((a, b) => a.id.localeCompare(b.id)) },
|
|
451
|
+
enumerable: false
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return result;
|
|
273
455
|
}
|
|
274
456
|
|
|
275
457
|
function reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId, extra = {}) {
|
|
@@ -295,42 +477,27 @@ function reconciliationCandidate(loaded, transition, record, path, fingerprint,
|
|
|
295
477
|
};
|
|
296
478
|
}
|
|
297
479
|
|
|
298
|
-
function reconciliationCommits(
|
|
299
|
-
const commits =
|
|
300
|
-
|
|
301
|
-
|
|
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;
|
|
302
485
|
return Number(workspace?.dataModelVersion) >= 3;
|
|
303
486
|
});
|
|
304
|
-
return
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function committedChangedEntries(root, commit) {
|
|
308
|
-
return parseNameStatus(runGit(root, [
|
|
309
|
-
"diff-tree",
|
|
310
|
-
"--root",
|
|
311
|
-
"--no-commit-id",
|
|
312
|
-
"--name-status",
|
|
313
|
-
"-M",
|
|
314
|
-
"-r",
|
|
315
|
-
commit,
|
|
316
|
-
"--",
|
|
317
|
-
"data"
|
|
318
|
-
])).filter(({ beforePath, afterPath }) => (
|
|
319
|
-
beforePath?.startsWith("data/") || afterPath?.startsWith("data/")
|
|
320
|
-
));
|
|
487
|
+
return { commits };
|
|
321
488
|
}
|
|
322
489
|
|
|
323
|
-
function pairCommittedRecordRenames(
|
|
490
|
+
function pairCommittedRecordRenames(changes, recordsBefore, recordsAfter) {
|
|
324
491
|
const deleted = changes.filter(({ beforePath, afterPath }) => beforePath?.endsWith(".json") && !afterPath);
|
|
325
492
|
const added = changes.filter(({ beforePath, afterPath }) => !beforePath && afterPath?.endsWith(".json"));
|
|
326
493
|
const paired = new Set();
|
|
327
494
|
const replacements = [];
|
|
328
495
|
for (const removed of deleted) {
|
|
329
|
-
const previous =
|
|
496
|
+
const previous = recordAtPath(recordsBefore, removed.beforePath)?.record;
|
|
330
497
|
if (!previous?.type || !previous?.id) continue;
|
|
331
498
|
const addition = added.find((candidate) => {
|
|
332
499
|
if (paired.has(candidate)) return false;
|
|
333
|
-
const current =
|
|
500
|
+
const current = recordAtPath(recordsAfter, candidate.afterPath)?.record;
|
|
334
501
|
return current?.type === previous.type && current?.id === previous.id;
|
|
335
502
|
});
|
|
336
503
|
if (!addition) continue;
|
|
@@ -341,30 +508,310 @@ function pairCommittedRecordRenames(root, commit, changes) {
|
|
|
341
508
|
return [...changes.filter((change) => !paired.has(change)), ...replacements];
|
|
342
509
|
}
|
|
343
510
|
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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])
|
|
348
542
|
);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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;
|
|
359
757
|
}
|
|
360
758
|
|
|
361
|
-
function
|
|
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
|
+
|
|
807
|
+
function applicableEventObligationIds(records, eventType, riskLevel, occurredAt) {
|
|
362
808
|
const byId = new Map(records.map((record) => [record.id, record]));
|
|
363
809
|
return records.filter((record) => {
|
|
364
810
|
if (record.type !== "obligation" || record.status !== "active") return false;
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
811
|
+
const schedule = obligationRule(record, byId, { now: occurredAt }) || record;
|
|
812
|
+
return schedule.recurrence?.mode === "event"
|
|
813
|
+
&& schedule.recurrence.eventType === eventType
|
|
814
|
+
&& (!Array.isArray(record.eventRiskLevels) || record.eventRiskLevels.includes(riskLevel));
|
|
368
815
|
}).map(({ id }) => id);
|
|
369
816
|
}
|
|
370
817
|
|
|
@@ -377,10 +824,13 @@ function readRevisionRecord(root, revision, path) {
|
|
|
377
824
|
}
|
|
378
825
|
}
|
|
379
826
|
|
|
380
|
-
function readRevisionSource(root, revision, path) {
|
|
827
|
+
function readRevisionSource(root, revision, path, historyIndex = null) {
|
|
381
828
|
if (!path) return "";
|
|
382
829
|
try {
|
|
383
|
-
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}`]);
|
|
384
834
|
return commit ? getFileAtRevision(root, commit, path) || "" : "";
|
|
385
835
|
} catch {
|
|
386
836
|
return "";
|
|
@@ -393,9 +843,7 @@ export async function applyReconciliation(input = process.cwd(), options = {}) {
|
|
|
393
843
|
}
|
|
394
844
|
return serializeWorkspaceMutation(input, async (root) => {
|
|
395
845
|
const plan = await planReconciliation(root);
|
|
396
|
-
const candidate = plan
|
|
397
|
-
id === options.candidateId || transitionFingerprint === options.transitionFingerprint
|
|
398
|
-
));
|
|
846
|
+
const candidate = findCandidate(plan, options);
|
|
399
847
|
if (!candidate) {
|
|
400
848
|
throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
|
|
401
849
|
}
|
|
@@ -417,15 +865,229 @@ export async function applyReconciliation(input = process.cwd(), options = {}) {
|
|
|
417
865
|
});
|
|
418
866
|
}
|
|
419
867
|
|
|
420
|
-
function
|
|
421
|
-
|
|
422
|
-
|
|
868
|
+
export async function dismissReconciliation(input = process.cwd(), options = {}) {
|
|
869
|
+
if (options.confirmed !== true) {
|
|
870
|
+
throw new Error("Dismissing a reconciliation candidate records a review decision. Preview the candidate and confirm the write.");
|
|
871
|
+
}
|
|
872
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
873
|
+
const plan = await planReconciliation(root);
|
|
874
|
+
const candidate = findCandidate(plan, options);
|
|
875
|
+
if (!candidate) {
|
|
876
|
+
throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
|
|
877
|
+
}
|
|
878
|
+
const loaded = await loadWorkspace(root);
|
|
879
|
+
if (!loaded.model.resources["reconciliation-dismissal"]) {
|
|
880
|
+
throw new Error("Reconciliation dismissals require data model v10 or newer.");
|
|
881
|
+
}
|
|
882
|
+
const reviewedById = String(options.reviewedById || "").trim();
|
|
883
|
+
const reviewedOn = String(options.reviewedOn || "").trim();
|
|
884
|
+
const rationale = String(options.rationale || "").trim();
|
|
885
|
+
if (!loaded.resources.some((record) => (
|
|
886
|
+
record.type === "person" && record.id === reviewedById && record.status === "active"
|
|
887
|
+
))) {
|
|
888
|
+
throw new Error("An active Person ID is required as the dismissal reviewer.");
|
|
889
|
+
}
|
|
890
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(reviewedOn)) {
|
|
891
|
+
throw new Error("The dismissal review date must use YYYY-MM-DD.");
|
|
892
|
+
}
|
|
893
|
+
if (reviewedOn !== currentCalendarDate(loaded.workspace.timezone)) {
|
|
894
|
+
throw new Error("The dismissal review date must be the current date in the workspace timezone.");
|
|
895
|
+
}
|
|
896
|
+
if (!rationale) throw new Error("A rationale is required to dismiss a reconciliation candidate.");
|
|
897
|
+
const record = {
|
|
898
|
+
id: `reconciliation-dismissal-${candidate.transitionFingerprint}`,
|
|
899
|
+
type: "reconciliation-dismissal",
|
|
900
|
+
title: `Dismiss ${candidate.eventType} for ${candidate.subject.title}`,
|
|
901
|
+
transitionFingerprint: candidate.transitionFingerprint,
|
|
902
|
+
eventType: candidate.eventType,
|
|
903
|
+
subjectResourceId: candidate.subject.id,
|
|
904
|
+
reviewedByIds: [reviewedById],
|
|
905
|
+
reviewedOn,
|
|
906
|
+
rationale
|
|
907
|
+
};
|
|
908
|
+
const result = await createResource(root, record, {
|
|
909
|
+
workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.reconciliationDismissal
|
|
910
|
+
});
|
|
911
|
+
return { candidate, dismissal: result.record, path: result.path };
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function transitionHandled(resources, eventIds, fingerprints, transition) {
|
|
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, {
|
|
946
|
+
transitionFingerprint: fingerprints[0],
|
|
947
|
+
eventType: transition.eventType,
|
|
948
|
+
subject: { id: transition.subjectId }
|
|
949
|
+
}, resources, {
|
|
950
|
+
requireActiveReviewer: !transition.committedRecordIds.has(dismissal.id),
|
|
951
|
+
timezone: transition.timezone,
|
|
952
|
+
peopleById: currentIndex.peopleById
|
|
953
|
+
})
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
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
|
+
) {
|
|
991
|
+
if (event?.type !== "obligation-event" || event.eventType !== eventType || event.status === "canceled") return false;
|
|
992
|
+
if (!(event.subjectResourceIds || []).includes(subjectId)) return false;
|
|
993
|
+
const currentEvent = currentIndex.byId.get(event.id);
|
|
994
|
+
if (
|
|
995
|
+
currentEvent?.type !== "obligation-event"
|
|
996
|
+
|| currentEvent.eventType !== eventType
|
|
997
|
+
|| currentEvent.status === "canceled"
|
|
998
|
+
|| !(currentEvent.subjectResourceIds || []).includes(subjectId)
|
|
999
|
+
|| currentEvent.riskLevel !== event.riskLevel
|
|
1000
|
+
|| currentEvent.occurredOn !== event.occurredOn
|
|
1001
|
+
|| currentEvent.occurredAt !== event.occurredAt
|
|
1002
|
+
) return false;
|
|
1003
|
+
const occurredAt = event.occurredAt || (event.occurredOn ? `${event.occurredOn}T23:59:59Z` : undefined);
|
|
1004
|
+
const requiredObligationIds = applicableEventObligationIds(records, eventType, event.riskLevel, occurredAt);
|
|
1005
|
+
if (!requiredObligationIds.length) return false;
|
|
1006
|
+
return requiredObligationIds.every((obligationId) => {
|
|
1007
|
+
const historicalAction = historicalIndex.actionsBySourceAndObligation.get(`${event.id}\0${obligationId}`);
|
|
1008
|
+
const currentAction = historicalAction ? currentIndex.byId.get(historicalAction.id) : null;
|
|
1009
|
+
return (event.obligationIds || []).includes(obligationId)
|
|
1010
|
+
&& (currentEvent.obligationIds || []).includes(obligationId)
|
|
1011
|
+
&& historicalAction
|
|
1012
|
+
&& historicalAction.status !== "canceled"
|
|
1013
|
+
&& currentAction?.type === "action-item"
|
|
1014
|
+
&& currentAction.sourceResourceId === event.id
|
|
1015
|
+
&& currentAction.obligationId === obligationId
|
|
1016
|
+
&& currentAction.status !== "canceled";
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export function reconciliationDismissalMatches(record, candidate, resources, options = {}) {
|
|
1021
|
+
if (record?.type !== "reconciliation-dismissal") return false;
|
|
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;
|
|
1028
|
+
return record.id === `reconciliation-dismissal-${candidate.transitionFingerprint}`
|
|
1029
|
+
&& record.transitionFingerprint === candidate.transitionFingerprint
|
|
1030
|
+
&& record.eventType === candidate.eventType
|
|
1031
|
+
&& record.subjectResourceId === candidate.subject.id
|
|
1032
|
+
&& reviewers.length === 1
|
|
1033
|
+
&& reviewer
|
|
1034
|
+
&& (!options.requireActiveReviewer || reviewer.status === "active")
|
|
1035
|
+
&& /^\d{4}-\d{2}-\d{2}$/.test(record.reviewedOn || "")
|
|
1036
|
+
&& (!options.requireActiveReviewer
|
|
1037
|
+
|| record.reviewedOn === currentCalendarDate(options.timezone || "UTC"))
|
|
1038
|
+
&& typeof record.rationale === "string"
|
|
1039
|
+
&& Boolean(record.rationale.trim());
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
export async function validateReconciliationDismissals(loaded, diagnostics) {
|
|
1043
|
+
const dismissals = loaded.entries.filter(({ record }) => record.type === "reconciliation-dismissal");
|
|
1044
|
+
if (!dismissals.length) return;
|
|
1045
|
+
const raw = await planReconciliation(loaded, { includeHandled: true });
|
|
1046
|
+
const candidateByFingerprint = new Map(raw.candidates.map((candidate) => [
|
|
1047
|
+
candidate.transitionFingerprint,
|
|
1048
|
+
candidate
|
|
1049
|
+
]));
|
|
1050
|
+
const committedRecordIds = new Set(getDataRecordHistoryIndex(loaded.root).historiesById.keys());
|
|
1051
|
+
const seen = new Set();
|
|
1052
|
+
for (const entry of dismissals) {
|
|
1053
|
+
const candidate = candidateByFingerprint.get(entry.record.transitionFingerprint);
|
|
1054
|
+
if (
|
|
1055
|
+
!candidate
|
|
1056
|
+
|| seen.has(entry.record.transitionFingerprint)
|
|
1057
|
+
|| !reconciliationDismissalMatches(entry.record, candidate, loaded.resources, {
|
|
1058
|
+
requireActiveReviewer: !committedRecordIds.has(entry.record.id),
|
|
1059
|
+
timezone: loaded.workspace.timezone
|
|
1060
|
+
})
|
|
1061
|
+
) {
|
|
1062
|
+
diagnostics.push({
|
|
1063
|
+
severity: "error",
|
|
1064
|
+
code: "invalid-reconciliation-dismissal",
|
|
1065
|
+
path: `data/${entry.relativePath}`,
|
|
1066
|
+
message: `Reconciliation dismissal "${entry.record.id}" must bind one current raw transition candidate to its exact fingerprint, event type, subject, and one active Person reviewer.`
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
seen.add(entry.record.transitionFingerprint);
|
|
1070
|
+
}
|
|
1071
|
+
return raw;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function findCandidate(plan, options) {
|
|
1075
|
+
return plan.candidates.find(({ id, transitionFingerprint }) => (
|
|
1076
|
+
id === options.candidateId
|
|
1077
|
+
|| transitionFingerprint === options.candidateId
|
|
1078
|
+
|| transitionFingerprint === options.transitionFingerprint
|
|
1079
|
+
));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
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))
|
|
423
1085
|
.map((path) => ({ beforePath: null, afterPath: path }));
|
|
424
1086
|
return [...tracked, ...untracked]
|
|
425
1087
|
.filter(({ beforePath, afterPath }) => beforePath?.startsWith("data/") || afterPath?.startsWith("data/"));
|
|
426
1088
|
}
|
|
427
1089
|
|
|
428
|
-
function pairWorkingRecordRenames(root, changes, currentByPath) {
|
|
1090
|
+
function pairWorkingRecordRenames(root, changes, currentByPath, baseRevision = null) {
|
|
429
1091
|
const additions = new Map();
|
|
430
1092
|
for (const change of changes) {
|
|
431
1093
|
if (change.beforePath || !change.afterPath?.endsWith(".json")) continue;
|
|
@@ -436,7 +1098,7 @@ function pairWorkingRecordRenames(root, changes, currentByPath) {
|
|
|
436
1098
|
const result = [];
|
|
437
1099
|
for (const change of changes) {
|
|
438
1100
|
if (!change.afterPath && change.beforePath?.endsWith(".json")) {
|
|
439
|
-
const record = readHeadRecord(root, change.beforePath);
|
|
1101
|
+
const record = readHeadRecord(root, change.beforePath, baseRevision);
|
|
440
1102
|
const addition = record?.type && record?.id ? additions.get(`${record.type}:${record.id}`) : null;
|
|
441
1103
|
if (addition) {
|
|
442
1104
|
paired.add(addition);
|
|
@@ -462,32 +1124,39 @@ function parseNameStatus(value) {
|
|
|
462
1124
|
}).filter(Boolean);
|
|
463
1125
|
}
|
|
464
1126
|
|
|
465
|
-
function readHeadRecord(root, path) {
|
|
1127
|
+
function readHeadRecord(root, path, revision = null) {
|
|
466
1128
|
if (!path) return null;
|
|
467
1129
|
try {
|
|
468
|
-
return JSON.parse(readHeadSource(root, path));
|
|
1130
|
+
return JSON.parse(readHeadSource(root, path, revision));
|
|
469
1131
|
} catch {
|
|
470
1132
|
return null;
|
|
471
1133
|
}
|
|
472
1134
|
}
|
|
473
1135
|
|
|
474
|
-
function readHeadSource(root, path) {
|
|
1136
|
+
function readHeadSource(root, path, revision = null) {
|
|
475
1137
|
try {
|
|
476
|
-
const commit = gitRevision(root);
|
|
1138
|
+
const commit = revision || gitRevision(root);
|
|
477
1139
|
return commit ? getFileAtRevision(root, commit, path) || "" : "";
|
|
478
1140
|
} catch {
|
|
479
1141
|
return "";
|
|
480
1142
|
}
|
|
481
1143
|
}
|
|
482
1144
|
|
|
483
|
-
function gitRevision(root) {
|
|
484
|
-
return runGit(root, ["rev-parse", "HEAD"]) || null;
|
|
1145
|
+
function gitRevision(root, deadline) {
|
|
1146
|
+
return runGit(root, ["rev-parse", "HEAD"], deadline) || null;
|
|
485
1147
|
}
|
|
486
1148
|
|
|
487
|
-
function runGit(root, args) {
|
|
1149
|
+
function runGit(root, args, deadline) {
|
|
488
1150
|
try {
|
|
489
|
-
|
|
490
|
-
|
|
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
|
+
}
|
|
491
1160
|
return "";
|
|
492
1161
|
}
|
|
493
1162
|
}
|
|
@@ -522,22 +1191,3 @@ function reconciliationCommand(eventType, subjectId, fingerprint, needsTimestamp
|
|
|
522
1191
|
const riskFlag = eventType === "person-ended" ? " --risk-level normal|high" : "";
|
|
523
1192
|
return `npx filegrc reconcile --apply --candidate ${fingerprint}${timeFlag}${riskFlag} --yes`;
|
|
524
1193
|
}
|
|
525
|
-
|
|
526
|
-
function historicalMarkdownOwners(model, recordsById) {
|
|
527
|
-
const owners = new Map();
|
|
528
|
-
for (const { record, path } of recordsById.values()) {
|
|
529
|
-
const entry = { record, relativePath: path.replace(/^data\//, "") };
|
|
530
|
-
for (const markdown of markdownEntries(model, record)) owners.set(`data/${markdown.path}`, entry);
|
|
531
|
-
}
|
|
532
|
-
return owners;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function recordsAtRevision(root, revision) {
|
|
536
|
-
const records = new Map();
|
|
537
|
-
for (const path of lines(runGit(root, ["ls-tree", "-r", "--name-only", revision, "--", "data"]))) {
|
|
538
|
-
if (!path.endsWith(".json")) continue;
|
|
539
|
-
const record = readRevisionRecord(root, revision, path);
|
|
540
|
-
if (record?.id) records.set(record.id, { record, path });
|
|
541
|
-
}
|
|
542
|
-
return records;
|
|
543
|
-
}
|