filegrc 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/model/index.js +9 -5
- package/model/v10.json +12122 -0
- package/model/v9.json +11647 -0
- package/package.json +1 -1
- package/src/agent.js +3 -0
- package/src/audit-populations.js +88 -0
- package/src/audit-preparation.js +7 -2
- package/src/cli.js +186 -13
- package/src/collection-review-integrity.js +118 -0
- package/src/collection-review.js +71 -7
- package/src/collection-scope.js +8 -0
- package/src/evidence-packet.js +328 -46
- package/src/files.js +364 -6
- package/src/git.js +1064 -149
- package/src/index.js +17 -1
- package/src/model-migration.js +221 -5
- package/src/obligations.js +834 -66
- package/src/policy-library/information-security-policy-v2.md +1 -1
- package/src/policy-library.js +85 -8
- package/src/program-path.js +11 -5
- package/src/program-readiness.js +84 -6
- package/src/reconciliation.js +332 -81
- package/src/reporting-route-integrity.js +542 -0
- package/src/reporting-route-sets.js +745 -0
- package/src/server.js +160 -47
- package/src/state.js +30 -8
- package/src/time.js +55 -0
- package/src/validate.js +978 -4
- package/src/web.js +491 -49
- package/src/workflow-history-integrity.js +872 -0
- package/src/workflow.js +36 -10
package/src/reconciliation.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
1
|
import { createHash } from "node:crypto";
|
|
3
2
|
import { readFile } from "node:fs/promises";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import { modelSupports } from "../model/index.js";
|
|
6
5
|
import { createObligationEvent } from "./obligations.js";
|
|
6
|
+
import { getDataRecordHistoryIndex, getFileAtRevision, runGitCommandSync } from "./git.js";
|
|
7
7
|
import { markdownEntries } from "./resource-markdown.js";
|
|
8
8
|
import { loadWorkspace } from "./workspace.js";
|
|
9
|
+
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
9
10
|
|
|
10
11
|
const TRANSITIONS = {
|
|
11
12
|
person: [
|
|
@@ -94,53 +95,141 @@ const TRANSITIONS = {
|
|
|
94
95
|
};
|
|
95
96
|
|
|
96
97
|
export async function planReconciliation(input = process.cwd()) {
|
|
97
|
-
const loaded = input?.
|
|
98
|
+
const loaded = input?.entries && input?.resources && input?.model
|
|
98
99
|
? input
|
|
99
100
|
: await loadWorkspace(input);
|
|
100
|
-
const headRevision = gitRevision(loaded.root);
|
|
101
101
|
if (!modelSupports(loaded.model, "guided-workflow")) {
|
|
102
102
|
return {
|
|
103
103
|
contractVersion: 1,
|
|
104
|
-
gitRevision:
|
|
104
|
+
gitRevision: gitRevision(loaded.root),
|
|
105
105
|
changedPaths: [],
|
|
106
106
|
candidates: [],
|
|
107
|
-
message: "Direct-file transition reconciliation is available
|
|
107
|
+
message: "Direct-file transition reconciliation is available after migrating this workspace to model v3."
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
|
-
const
|
|
111
|
-
|
|
110
|
+
const baseRevision = gitRevision(loaded.root);
|
|
111
|
+
const currentByPath = new Map(loaded.entries.map((entry) => [
|
|
112
|
+
`data/${entry.relativePath}`,
|
|
113
|
+
entry
|
|
114
|
+
]));
|
|
115
|
+
const workingChanges = pairWorkingRecordRenames(
|
|
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();
|
|
123
|
+
if (!baseRevision) {
|
|
112
124
|
return {
|
|
113
125
|
contractVersion: 1,
|
|
114
126
|
gitRevision: null,
|
|
115
127
|
changedPaths,
|
|
116
128
|
candidates: [],
|
|
117
|
-
message: "Commit the initial workspace before
|
|
129
|
+
message: "Commit the initial workspace before reconciling later direct-file transitions."
|
|
118
130
|
};
|
|
119
131
|
}
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
132
|
+
const reconciliationHistory = reconciliationCommits(loaded.root);
|
|
133
|
+
const historicalRecords = reconciliationHistory.length
|
|
134
|
+
? recordsAtRevision(loaded.root, `${reconciliationHistory[0]}^`)
|
|
135
|
+
: new Map();
|
|
136
|
+
const historyIndex = getDataRecordHistoryIndex(loaded.root);
|
|
137
|
+
const currentMarkdownOwners = new Map();
|
|
125
138
|
for (const entry of loaded.entries) {
|
|
126
|
-
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
127
|
-
markdownOwners.set(`data/${markdown.path}`, entry);
|
|
128
|
-
}
|
|
139
|
+
for (const markdown of markdownEntries(loaded.model, entry.record)) currentMarkdownOwners.set(`data/${markdown.path}`, entry);
|
|
129
140
|
}
|
|
130
141
|
const candidates = [];
|
|
131
142
|
const examined = new Set();
|
|
132
143
|
|
|
133
|
-
for (const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
144
|
+
for (const commit of reconciliationHistory) {
|
|
145
|
+
const markdownOwnersBefore = historicalMarkdownOwners(loaded.model, historicalRecords);
|
|
146
|
+
for (const change of committedChangedEntries(loaded.root, commit)) {
|
|
147
|
+
if (change.beforePath?.endsWith(".json") && !change.afterPath) {
|
|
148
|
+
const removed = readRevisionRecord(loaded.root, `${commit}^`, change.beforePath);
|
|
149
|
+
if (removed?.id) historicalRecords.delete(removed.id);
|
|
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));
|
|
155
|
+
const commitPaths = [...new Set(commitChanges.flatMap(({ beforePath, afterPath }) => (
|
|
156
|
+
[beforePath, afterPath].filter(Boolean)
|
|
157
|
+
)))];
|
|
158
|
+
const commitExamined = new Set();
|
|
159
|
+
for (const change of commitChanges) {
|
|
160
|
+
const path = change.afterPath || change.beforePath;
|
|
161
|
+
const currentEntry = currentByPath.get(path) || markdownOwners.get(path) || markdownOwnersBefore.get(path);
|
|
162
|
+
const previousRecordPath = change.beforePath?.endsWith(".json")
|
|
163
|
+
? change.beforePath
|
|
164
|
+
: currentEntry ? `data/${currentEntry.relativePath}` : null;
|
|
165
|
+
const currentRecordPath = change.afterPath?.endsWith(".json")
|
|
166
|
+
? change.afterPath
|
|
167
|
+
: currentEntry ? `data/${currentEntry.relativePath}` : null;
|
|
168
|
+
const recordPath = currentRecordPath || previousRecordPath;
|
|
169
|
+
const previous = readRevisionRecord(loaded.root, `${commit}^`, previousRecordPath);
|
|
170
|
+
const current = readRevisionRecord(loaded.root, commit, currentRecordPath);
|
|
171
|
+
const record = current || previous;
|
|
172
|
+
if (!record || commitExamined.has(`${record.type}:${record.id}`)) continue;
|
|
173
|
+
commitExamined.add(`${record.type}:${record.id}`);
|
|
174
|
+
const changedMarkdownPaths = commitPaths.filter((changed) => (
|
|
175
|
+
markdownOwners.get(changed)?.record.id === record.id
|
|
176
|
+
|| markdownOwnersBefore.get(changed)?.record.id === record.id
|
|
177
|
+
));
|
|
178
|
+
const markdownChanged = changedMarkdownPaths.length > 0;
|
|
179
|
+
const currentSource = [
|
|
180
|
+
readRevisionSource(loaded.root, commit, currentRecordPath),
|
|
181
|
+
...changedMarkdownPaths.map((changed) => readRevisionSource(loaded.root, commit, changed))
|
|
182
|
+
].join("\n");
|
|
183
|
+
const beforeSource = [
|
|
184
|
+
readRevisionSource(loaded.root, `${commit}^`, previousRecordPath),
|
|
185
|
+
...changedMarkdownPaths.map((changed) => readRevisionSource(loaded.root, `${commit}^`, changed))
|
|
186
|
+
].join("\n");
|
|
187
|
+
for (const transition of TRANSITIONS[record.type] || []) {
|
|
188
|
+
if (!transition.applies(previous, current, { markdownChanged })) continue;
|
|
189
|
+
if (committedEventHandled(loaded.root, commit, commitPaths, transition.eventType, record.id, historicalRecords)) continue;
|
|
190
|
+
const fingerprint = transitionFingerprint({
|
|
191
|
+
baseRevision: commit,
|
|
192
|
+
eventType: transition.eventType,
|
|
193
|
+
subjectId: record.id,
|
|
194
|
+
path: recordPath,
|
|
195
|
+
beforeSource,
|
|
196
|
+
currentSource,
|
|
197
|
+
markdownChanged
|
|
198
|
+
});
|
|
199
|
+
const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
|
|
200
|
+
if (loaded.resources.some((item) => (
|
|
201
|
+
item.type === "obligation-event"
|
|
202
|
+
&& (item.id === eventId || item.transitionFingerprint === fingerprint)
|
|
203
|
+
))) continue;
|
|
204
|
+
candidates.push(reconciliationCandidate(
|
|
205
|
+
loaded,
|
|
206
|
+
transition,
|
|
207
|
+
record,
|
|
208
|
+
path,
|
|
209
|
+
fingerprint,
|
|
210
|
+
eventId,
|
|
211
|
+
{ committedRevision: commit }
|
|
212
|
+
));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const change of workingChanges) {
|
|
218
|
+
const path = change.afterPath || change.beforePath;
|
|
219
|
+
const currentEntry = currentByPath.get(path) || currentMarkdownOwners.get(path);
|
|
220
|
+
const previousRecordPath = change.beforePath?.endsWith(".json")
|
|
221
|
+
? change.beforePath
|
|
222
|
+
: currentEntry ? `data/${currentEntry.relativePath}` : null;
|
|
223
|
+
const currentRecordPath = change.afterPath?.endsWith(".json")
|
|
224
|
+
? change.afterPath
|
|
225
|
+
: currentEntry ? `data/${currentEntry.relativePath}` : null;
|
|
226
|
+
const previous = readHeadRecord(loaded.root, previousRecordPath);
|
|
227
|
+
const current = currentRecordPath ? currentByPath.get(currentRecordPath)?.record || currentEntry?.record || null : null;
|
|
139
228
|
const record = current || previous;
|
|
140
229
|
if (!record || examined.has(`${record.type}:${record.id}`)) continue;
|
|
141
230
|
examined.add(`${record.type}:${record.id}`);
|
|
142
231
|
const changedMarkdownPaths = changedPaths.filter((changed) => (
|
|
143
|
-
|
|
232
|
+
currentMarkdownOwners.get(changed)?.record.id === record.id
|
|
144
233
|
));
|
|
145
234
|
const markdownChanged = changedMarkdownPaths.length > 0;
|
|
146
235
|
const currentMarkdown = await Promise.all(changedMarkdownPaths.map(async (changed) => {
|
|
@@ -156,73 +245,221 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
156
245
|
for (const transition of TRANSITIONS[record.type] || []) {
|
|
157
246
|
if (!transition.applies(previous, current, { markdownChanged })) continue;
|
|
158
247
|
const fingerprint = transitionFingerprint({
|
|
248
|
+
baseRevision,
|
|
159
249
|
eventType: transition.eventType,
|
|
160
250
|
subjectId: record.id,
|
|
161
|
-
path:
|
|
251
|
+
path: currentRecordPath || previousRecordPath,
|
|
162
252
|
beforeSource,
|
|
163
253
|
currentSource,
|
|
164
254
|
markdownChanged
|
|
165
255
|
});
|
|
256
|
+
const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
|
|
166
257
|
if (loaded.resources.some((item) => (
|
|
167
258
|
item.type === "obligation-event"
|
|
168
|
-
&&
|
|
259
|
+
&& (
|
|
260
|
+
item.id === eventId
|
|
261
|
+
|| item.transitionFingerprint === fingerprint
|
|
262
|
+
)
|
|
169
263
|
))) continue;
|
|
170
|
-
candidates.push(
|
|
171
|
-
id: `reconcile-${fingerprint.slice(0, 16)}`,
|
|
172
|
-
transitionFingerprint: fingerprint,
|
|
173
|
-
eventType: transition.eventType,
|
|
174
|
-
subject: { type: record.type, id: record.id, title: record.title },
|
|
175
|
-
sourcePath: path,
|
|
176
|
-
state: "needs-confirmation",
|
|
177
|
-
message: transition.message,
|
|
178
|
-
requiredFacts: [
|
|
179
|
-
transition.eventType === "person-ended" ? "riskLevel" : null,
|
|
180
|
-
eventNeedsTimestamp(loaded, transition.eventType) ? "occurredAt" : "occurredOn"
|
|
181
|
-
].filter(Boolean),
|
|
182
|
-
action: {
|
|
183
|
-
kind: "command",
|
|
184
|
-
command: reconciliationCommand(transition.eventType, record.id, fingerprint)
|
|
185
|
-
}
|
|
186
|
-
});
|
|
264
|
+
candidates.push(reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId));
|
|
187
265
|
}
|
|
188
266
|
}
|
|
189
267
|
return {
|
|
190
268
|
contractVersion: 1,
|
|
191
|
-
gitRevision:
|
|
269
|
+
gitRevision: gitRevision(loaded.root),
|
|
192
270
|
changedPaths,
|
|
193
271
|
candidates: candidates.sort((a, b) => a.id.localeCompare(b.id))
|
|
194
272
|
};
|
|
195
273
|
}
|
|
196
274
|
|
|
275
|
+
function reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId, extra = {}) {
|
|
276
|
+
const needsTimestamp = eventNeedsTimestamp(loaded, transition.eventType);
|
|
277
|
+
return {
|
|
278
|
+
id: `reconcile-${fingerprint.slice(0, 16)}`,
|
|
279
|
+
eventId,
|
|
280
|
+
transitionFingerprint: fingerprint,
|
|
281
|
+
eventType: transition.eventType,
|
|
282
|
+
subject: { type: record.type, id: record.id, title: record.title },
|
|
283
|
+
sourcePath: path,
|
|
284
|
+
state: "needs-confirmation",
|
|
285
|
+
message: transition.message,
|
|
286
|
+
requiredFacts: [
|
|
287
|
+
transition.eventType === "person-ended" ? "riskLevel" : null,
|
|
288
|
+
needsTimestamp ? "occurredAt" : "occurredOn"
|
|
289
|
+
].filter(Boolean),
|
|
290
|
+
action: {
|
|
291
|
+
kind: "command",
|
|
292
|
+
command: reconciliationCommand(transition.eventType, record.id, fingerprint, needsTimestamp)
|
|
293
|
+
},
|
|
294
|
+
...extra
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function reconciliationCommits(root) {
|
|
299
|
+
const commits = lines(runGit(root, ["log", "--reverse", "--format=%H", "--", "data"]));
|
|
300
|
+
const baseline = commits.findIndex((commit) => {
|
|
301
|
+
const workspace = readRevisionRecord(root, commit, "data/workspace.json");
|
|
302
|
+
return Number(workspace?.dataModelVersion) >= 3;
|
|
303
|
+
});
|
|
304
|
+
return baseline < 0 ? [] : commits.slice(baseline + 1);
|
|
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
|
+
));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function pairCommittedRecordRenames(root, commit, changes) {
|
|
324
|
+
const deleted = changes.filter(({ beforePath, afterPath }) => beforePath?.endsWith(".json") && !afterPath);
|
|
325
|
+
const added = changes.filter(({ beforePath, afterPath }) => !beforePath && afterPath?.endsWith(".json"));
|
|
326
|
+
const paired = new Set();
|
|
327
|
+
const replacements = [];
|
|
328
|
+
for (const removed of deleted) {
|
|
329
|
+
const previous = readRevisionRecord(root, `${commit}^`, removed.beforePath);
|
|
330
|
+
if (!previous?.type || !previous?.id) continue;
|
|
331
|
+
const addition = added.find((candidate) => {
|
|
332
|
+
if (paired.has(candidate)) return false;
|
|
333
|
+
const current = readRevisionRecord(root, commit, candidate.afterPath);
|
|
334
|
+
return current?.type === previous.type && current?.id === previous.id;
|
|
335
|
+
});
|
|
336
|
+
if (!addition) continue;
|
|
337
|
+
paired.add(removed);
|
|
338
|
+
paired.add(addition);
|
|
339
|
+
replacements.push({ beforePath: removed.beforePath, afterPath: addition.afterPath });
|
|
340
|
+
}
|
|
341
|
+
return [...changes.filter((change) => !paired.has(change)), ...replacements];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function committedEventHandled(root, commit, commitPaths, eventType, subjectId, historicalRecords) {
|
|
345
|
+
const requiredObligationIds = applicableEventObligationIds(
|
|
346
|
+
[...historicalRecords.values()].map(({ record }) => record),
|
|
347
|
+
eventType
|
|
348
|
+
);
|
|
349
|
+
return commitPaths.some((path) => {
|
|
350
|
+
if (!path.endsWith(".json")) return false;
|
|
351
|
+
const record = readRevisionRecord(root, commit, path);
|
|
352
|
+
const previous = readRevisionRecord(root, `${commit}^`, path);
|
|
353
|
+
return !previous
|
|
354
|
+
&& record?.type === "obligation-event"
|
|
355
|
+
&& record.eventType === eventType
|
|
356
|
+
&& (record.subjectResourceIds || []).includes(subjectId)
|
|
357
|
+
&& requiredObligationIds.every((id) => (record.obligationIds || []).includes(id));
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function applicableEventObligationIds(records, eventType) {
|
|
362
|
+
const byId = new Map(records.map((record) => [record.id, record]));
|
|
363
|
+
return records.filter((record) => {
|
|
364
|
+
if (record.type !== "obligation" || record.status !== "active") return false;
|
|
365
|
+
const rule = byId.get(record.activeRuleId);
|
|
366
|
+
const schedule = rule?.type === "obligation-rule" && rule.status === "active" ? rule : record;
|
|
367
|
+
return schedule.recurrence?.mode === "event" && schedule.recurrence.eventType === eventType;
|
|
368
|
+
}).map(({ id }) => id);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function readRevisionRecord(root, revision, path) {
|
|
372
|
+
if (!path) return null;
|
|
373
|
+
try {
|
|
374
|
+
return JSON.parse(readRevisionSource(root, revision, path));
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function readRevisionSource(root, revision, path) {
|
|
381
|
+
if (!path) return "";
|
|
382
|
+
try {
|
|
383
|
+
const commit = runGit(root, ["rev-parse", `${revision}^{commit}`]);
|
|
384
|
+
return commit ? getFileAtRevision(root, commit, path) || "" : "";
|
|
385
|
+
} catch {
|
|
386
|
+
return "";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
197
390
|
export async function applyReconciliation(input = process.cwd(), options = {}) {
|
|
198
391
|
if (options.confirmed !== true) {
|
|
199
392
|
throw new Error("Reconciliation creates compliance records. Preview the candidate and confirm the write.");
|
|
200
393
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
394
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
395
|
+
const plan = await planReconciliation(root);
|
|
396
|
+
const candidate = plan.candidates.find(({ id, transitionFingerprint }) => (
|
|
397
|
+
id === options.candidateId || transitionFingerprint === options.transitionFingerprint
|
|
398
|
+
));
|
|
399
|
+
if (!candidate) {
|
|
400
|
+
throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
|
|
401
|
+
}
|
|
402
|
+
const loaded = await loadWorkspace(root);
|
|
403
|
+
const result = await createObligationEvent(root, {
|
|
404
|
+
allPrograms: true,
|
|
405
|
+
id: candidate.eventId,
|
|
406
|
+
eventType: candidate.eventType,
|
|
407
|
+
subjectResourceIds: [candidate.subject.id],
|
|
408
|
+
occurredOn: options.occurredOn,
|
|
409
|
+
occurredAt: options.occurredAt,
|
|
410
|
+
riskLevel: options.riskLevel,
|
|
411
|
+
title: options.title,
|
|
412
|
+
...(String(loaded.model.modelVersion) === "3"
|
|
413
|
+
? { transitionFingerprint: candidate.transitionFingerprint }
|
|
414
|
+
: {})
|
|
415
|
+
});
|
|
416
|
+
return { candidate, ...result };
|
|
216
417
|
});
|
|
217
|
-
return { candidate, ...result };
|
|
218
418
|
}
|
|
219
419
|
|
|
220
|
-
function
|
|
221
|
-
const tracked = runGit(root, ["diff", "--name-
|
|
222
|
-
const untracked = runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"])
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
.
|
|
420
|
+
function gitChangedEntries(root) {
|
|
421
|
+
const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", "HEAD", "--", "data"]));
|
|
422
|
+
const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
|
|
423
|
+
.map((path) => ({ beforePath: null, afterPath: path }));
|
|
424
|
+
return [...tracked, ...untracked]
|
|
425
|
+
.filter(({ beforePath, afterPath }) => beforePath?.startsWith("data/") || afterPath?.startsWith("data/"));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function pairWorkingRecordRenames(root, changes, currentByPath) {
|
|
429
|
+
const additions = new Map();
|
|
430
|
+
for (const change of changes) {
|
|
431
|
+
if (change.beforePath || !change.afterPath?.endsWith(".json")) continue;
|
|
432
|
+
const record = currentByPath.get(change.afterPath)?.record;
|
|
433
|
+
if (record?.type && record?.id) additions.set(`${record.type}:${record.id}`, change);
|
|
434
|
+
}
|
|
435
|
+
const paired = new Set();
|
|
436
|
+
const result = [];
|
|
437
|
+
for (const change of changes) {
|
|
438
|
+
if (!change.afterPath && change.beforePath?.endsWith(".json")) {
|
|
439
|
+
const record = readHeadRecord(root, change.beforePath);
|
|
440
|
+
const addition = record?.type && record?.id ? additions.get(`${record.type}:${record.id}`) : null;
|
|
441
|
+
if (addition) {
|
|
442
|
+
paired.add(addition);
|
|
443
|
+
result.push({ beforePath: change.beforePath, afterPath: addition.afterPath });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!paired.has(change)) result.push(change);
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseNameStatus(value) {
|
|
453
|
+
return lines(value).map((line) => {
|
|
454
|
+
const [status, first, second] = line.split("\t");
|
|
455
|
+
if (!status || !first) return null;
|
|
456
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
457
|
+
return { beforePath: first, afterPath: second || null };
|
|
458
|
+
}
|
|
459
|
+
if (status === "A") return { beforePath: null, afterPath: first };
|
|
460
|
+
if (status === "D") return { beforePath: first, afterPath: null };
|
|
461
|
+
return { beforePath: first, afterPath: first };
|
|
462
|
+
}).filter(Boolean);
|
|
226
463
|
}
|
|
227
464
|
|
|
228
465
|
function readHeadRecord(root, path) {
|
|
@@ -236,12 +473,8 @@ function readHeadRecord(root, path) {
|
|
|
236
473
|
|
|
237
474
|
function readHeadSource(root, path) {
|
|
238
475
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
encoding: "utf8",
|
|
242
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
243
|
-
timeout: 10_000
|
|
244
|
-
});
|
|
476
|
+
const commit = gitRevision(root);
|
|
477
|
+
return commit ? getFileAtRevision(root, commit, path) || "" : "";
|
|
245
478
|
} catch {
|
|
246
479
|
return "";
|
|
247
480
|
}
|
|
@@ -253,12 +486,7 @@ function gitRevision(root) {
|
|
|
253
486
|
|
|
254
487
|
function runGit(root, args) {
|
|
255
488
|
try {
|
|
256
|
-
return
|
|
257
|
-
cwd: root,
|
|
258
|
-
encoding: "utf8",
|
|
259
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
260
|
-
timeout: 10_000
|
|
261
|
-
}).trim();
|
|
489
|
+
return runGitCommandSync(root, args);
|
|
262
490
|
} catch {
|
|
263
491
|
return "";
|
|
264
492
|
}
|
|
@@ -277,16 +505,39 @@ function transitionFingerprint(value) {
|
|
|
277
505
|
}
|
|
278
506
|
|
|
279
507
|
function eventNeedsTimestamp(loaded, eventType) {
|
|
508
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
280
509
|
return loaded.resources.some((record) => (
|
|
281
510
|
record.type === "obligation"
|
|
282
511
|
&& record.status === "active"
|
|
283
|
-
&&
|
|
284
|
-
|
|
512
|
+
&& (() => {
|
|
513
|
+
const rule = byId.get(record.activeRuleId);
|
|
514
|
+
const schedule = rule?.type === "obligation-rule" && rule.status === "active" ? rule : record;
|
|
515
|
+
return schedule.recurrence?.eventType === eventType && schedule.window?.precision === "timestamp";
|
|
516
|
+
})()
|
|
285
517
|
));
|
|
286
518
|
}
|
|
287
519
|
|
|
288
|
-
function reconciliationCommand(eventType, subjectId, fingerprint) {
|
|
289
|
-
const timeFlag = " --occurred-on YYYY-MM-DD";
|
|
520
|
+
function reconciliationCommand(eventType, subjectId, fingerprint, needsTimestamp = false) {
|
|
521
|
+
const timeFlag = needsTimestamp ? " --occurred-at RFC3339" : " --occurred-on YYYY-MM-DD";
|
|
290
522
|
const riskFlag = eventType === "person-ended" ? " --risk-level normal|high" : "";
|
|
291
523
|
return `npx filegrc reconcile --apply --candidate ${fingerprint}${timeFlag}${riskFlag} --yes`;
|
|
292
524
|
}
|
|
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
|
+
}
|