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.
@@ -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?.resources && input?.model && input?.entries
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: headRevision,
104
+ gitRevision: gitRevision(loaded.root),
105
105
  changedPaths: [],
106
106
  candidates: [],
107
- message: "Direct-file transition reconciliation is available in model v3 and newer workspaces."
107
+ message: "Direct-file transition reconciliation is available after migrating this workspace to model v3."
108
108
  };
109
109
  }
110
- const changedPaths = gitChangedPaths(loaded.root);
111
- if (!headRevision) {
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 FileGRC checks later direct-file changes for Policy Events."
129
+ message: "Commit the initial workspace before reconciling later direct-file transitions."
118
130
  };
119
131
  }
120
- const currentByPath = new Map(loaded.entries.map((entry) => [
121
- `data/${entry.relativePath}`,
122
- entry
123
- ]));
124
- const markdownOwners = new Map();
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 path of changedPaths) {
134
- const currentEntry = currentByPath.get(path) || markdownOwners.get(path);
135
- const previous = readHeadRecord(loaded.root, path.endsWith(".json")
136
- ? path
137
- : currentEntry ? `data/${currentEntry.relativePath}` : null);
138
- const current = currentEntry?.record || null;
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
- markdownOwners.get(changed)?.record.id === record.id
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: `data/${currentEntry?.relativePath || path.replace(/^data\//, "")}`,
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
- && item.transitionFingerprint === fingerprint
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: headRevision,
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
- const plan = await planReconciliation(input);
202
- const candidate = plan.candidates.find(({ id, transitionFingerprint }) => (
203
- id === options.candidateId || transitionFingerprint === options.transitionFingerprint
204
- ));
205
- if (!candidate) {
206
- throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
207
- }
208
- const result = await createObligationEvent(input, {
209
- eventType: candidate.eventType,
210
- subjectResourceIds: [candidate.subject.id],
211
- occurredOn: options.occurredOn,
212
- occurredAt: options.occurredAt,
213
- riskLevel: options.riskLevel,
214
- title: options.title,
215
- transitionFingerprint: candidate.transitionFingerprint
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 gitChangedPaths(root) {
221
- const tracked = runGit(root, ["diff", "--name-only", "HEAD", "--", "data"]);
222
- const untracked = runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]);
223
- return [...new Set([...lines(tracked), ...lines(untracked)])]
224
- .filter((path) => path.startsWith("data/"))
225
- .sort();
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
- return execFileSync("git", ["show", `HEAD:${path}`], {
240
- cwd: root,
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 execFileSync("git", args, {
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
- && record.recurrence?.eventType === eventType
284
- && record.window?.precision === "timestamp"
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
+ }