filegrc 0.3.4 → 0.4.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/src/git.js CHANGED
@@ -1,14 +1,19 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { rm } from "node:fs/promises";
4
4
  import { relative, resolve, sep } from "node:path";
5
+ import { performance } from "node:perf_hooks";
5
6
  import { isSafeGitName } from "./git-name.js";
6
- import { serializeWorkspaceMutation } from "./mutation.js";
7
+ import { serializeWorkspaceMutation, withDeferredWorkspaceValidation } from "./mutation.js";
7
8
  import { resolveWorkspaceRoot } from "./paths.js";
8
- import { validateWorkspace } from "./validate.js";
9
+ import { measureTiming, measureTimingSync, timingEnabled } from "./timing.js";
10
+ import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
9
11
  import { loadWorkspace } from "./workspace.js";
10
12
 
11
13
  const lastSuccessfulSynchronizations = new Map();
14
+ const workspaceHistoryCache = new Map();
15
+ const backgroundSynchronizations = new Map();
16
+ export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
12
17
 
13
18
  export function getGitSummary(input = process.cwd()) {
14
19
  const root = resolveWorkspaceRoot(input);
@@ -65,6 +70,13 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12) {
65
70
  const wanted = new Set(relativePaths);
66
71
  const histories = new Map([...wanted].map((path) => [path, []]));
67
72
  if (!wanted.size) return histories;
73
+ const head = tryGit(root, ["rev-parse", "HEAD"]) || null;
74
+ const cached = workspaceHistoryCache.get(root);
75
+ if (cached?.head === head && cached.limitPerFile === limitPerFile) {
76
+ for (const path of wanted) histories.set(path, cached.histories.get(path) ?? []);
77
+ return histories;
78
+ }
79
+ const allHistories = new Map();
68
80
  try {
69
81
  const output = git(root, ["log", "--relative", "--format=%x1e%H%x1f%aI%x1f%an%x1f%s", "--name-only", "--", "data"]);
70
82
  for (const block of output.split("\x1e")) {
@@ -72,13 +84,16 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12) {
72
84
  if (lines.length < 2) continue;
73
85
  const commit = parseLogLine(lines[0]);
74
86
  for (const path of lines.slice(1)) {
75
- const history = histories.get(path);
76
- if (history && history.length < limitPerFile) history.push(commit);
87
+ if (!allHistories.has(path)) allHistories.set(path, []);
88
+ const history = allHistories.get(path);
89
+ if (history.length < limitPerFile) history.push(commit);
77
90
  }
78
91
  }
79
92
  } catch {
80
93
  // An uncommitted workspace has no history yet.
81
94
  }
95
+ workspaceHistoryCache.set(root, { head, limitPerFile, histories: allHistories });
96
+ for (const path of wanted) histories.set(path, allHistories.get(path) ?? []);
82
97
  return histories;
83
98
  }
84
99
 
@@ -184,11 +199,14 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
184
199
  return serializeWorkspaceMutation(input, async (root) => {
185
200
  const config = await getRepositoryConfig(root);
186
201
  if (config.mode !== "trunk") throw new Error("Retry sync is available only in trunk repository mode.");
202
+ if (backgroundSynchronizations.get(root)?.status === "syncing") {
203
+ throw new Error("A FileGRC background push is already in progress. Wait for it to finish before retrying sync.");
204
+ }
187
205
  if (options.allowNonAuthoritativeWrites === true) {
188
206
  throw new Error("Retry sync is disabled while the development write override is active.");
189
207
  }
190
208
  const before = requireTrunkPreconditions(root, config, { allowAhead: true });
191
- fetchConfiguredRemote(root, config.remote);
209
+ await fetchConfiguredRemote(root, config.remote);
192
210
  const synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
193
211
  if (synchronized.behind > 0 && synchronized.ahead > 0) {
194
212
  throw new Error("The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.");
@@ -200,13 +218,14 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
200
218
  if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
201
219
  throw new Error("At least one commit ahead of upstream changes files outside this FileGRC workspace. FileGRC will not push it. Reconcile the repository with Git.");
202
220
  }
203
- if (ready.ahead > 0) pushConfiguredBranch(root, config);
221
+ if (ready.ahead > 0) await pushConfiguredBranch(root, config);
204
222
  const after = inspectTrunkRepository(root, config, getGitSummary(root));
205
223
  if (after.ahead !== 0 || after.behind !== 0) {
206
224
  throw new Error("The authoritative branch is still not synchronized. Reload the repository state before trying again.");
207
225
  }
208
226
  const synchronizedAt = new Date().toISOString();
209
227
  lastSuccessfulSynchronizations.set(root, synchronizedAt);
228
+ backgroundSynchronizations.delete(root);
210
229
  return {
211
230
  commit: after.currentCommit,
212
231
  shortCommit: after.currentCommit?.slice(0, 8) ?? null,
@@ -220,7 +239,7 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
220
239
 
221
240
  async function runTrunkMutationUnlocked(root, config, options, task) {
222
241
  requireTrunkPreconditions(root, config);
223
- fetchConfiguredRemote(root, config.remote);
242
+ await fetchConfiguredRemote(root, config.remote);
224
243
  let synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
225
244
  if (synchronized.ahead > 0 && synchronized.behind > 0) {
226
245
  throw new Error("The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.");
@@ -238,13 +257,18 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
238
257
 
239
258
  let result;
240
259
  let subject;
260
+ let validationProof;
241
261
  try {
242
- result = await task(root);
262
+ result = await withDeferredWorkspaceValidation(() => task(root));
243
263
  subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
244
264
  const validation = await validateWorkspace(root);
245
265
  if (!validation.ok) {
246
266
  throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. The browser change was rolled back.`);
247
267
  }
268
+ validationProof = {
269
+ validation,
270
+ fingerprint: (await fingerprintWorkspace(validation.loaded)).fingerprint
271
+ };
248
272
  assertNoOutsideWorktreeChanges(root);
249
273
  } catch (error) {
250
274
  try {
@@ -255,6 +279,19 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
255
279
  throw error;
256
280
  }
257
281
 
282
+ if (!getGitSummary(root).changes.length && options?.allowNoChanges === true) {
283
+ return withValidationProof({
284
+ ...result,
285
+ synchronization: {
286
+ status: "unchanged",
287
+ commit: synchronized.currentCommit,
288
+ shortCommit: synchronized.currentCommit?.slice(0, 8) ?? null,
289
+ upstream: synchronized.upstream,
290
+ synchronizedAt: lastSuccessfulSynchronizations.get(root) ?? null,
291
+ pushError: null
292
+ }
293
+ }, validationProof);
294
+ }
258
295
  if (!getGitSummary(root).changes.length) {
259
296
  throw new Error("The browser action did not change any FileGRC workspace files.");
260
297
  }
@@ -265,35 +302,100 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
265
302
  assertNoOutsideWorktreeChanges(root, false);
266
303
  assertOnlyWorkspaceFilesStaged(root);
267
304
  try {
268
- gitForWrite(root, ["commit", "-m", subject, "--", "."], "create the FileGRC browser commit");
305
+ measureTimingSync("commit", () => {
306
+ gitForWrite(root, ["commit", "-m", subject, "--", "."], "create the FileGRC browser commit");
307
+ });
269
308
  } catch (error) {
270
309
  throw new Error(`${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`);
271
310
  }
272
311
 
273
312
  const committed = getGitSummary(root);
274
- let pushError = null;
275
- try {
276
- pushConfiguredBranch(root, config);
277
- } catch (error) {
278
- pushError = `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`;
279
- }
280
- const after = inspectTrunkRepository(root, config, getGitSummary(root));
281
- let synchronizedAt = null;
282
- if (!pushError && after.ahead === 0 && after.behind === 0) {
283
- synchronizedAt = new Date().toISOString();
284
- lastSuccessfulSynchronizations.set(root, synchronizedAt);
285
- }
286
- return {
313
+ queueBackgroundPush(root, config, committed, options?.backgroundPushDelayMs);
314
+ return withValidationProof({
287
315
  ...result,
288
316
  synchronization: {
289
- status: pushError ? "not-synced" : "synced",
317
+ status: "syncing",
290
318
  commit: committed.commit,
291
319
  shortCommit: committed.shortCommit,
292
- upstream: after.upstream,
293
- synchronizedAt,
294
- pushError
320
+ upstream: synchronized.upstream,
321
+ synchronizedAt: null,
322
+ pushError: null
323
+ }
324
+ }, validationProof);
325
+ }
326
+
327
+ function withValidationProof(result, proof) {
328
+ if (result && typeof result === "object") {
329
+ Object.defineProperty(result, BROWSER_VALIDATION, { value: proof });
330
+ }
331
+ return result;
332
+ }
333
+
334
+ function queueBackgroundPush(root, config, committed, delayMs = 0) {
335
+ backgroundSynchronizations.set(root, {
336
+ status: "syncing",
337
+ commit: committed.commit,
338
+ shortCommit: committed.shortCommit,
339
+ startedAt: new Date().toISOString(),
340
+ error: null
341
+ });
342
+ const start = () => {
343
+ try {
344
+ const ready = requireTrunkPreconditions(root, config, { allowAhead: true });
345
+ if (ready.currentCommit !== committed.commit) {
346
+ throw new Error("The authoritative branch changed after FileGRC created its browser commit. FileGRC did not push it.");
347
+ }
348
+ if (ready.behind > 0) {
349
+ throw new Error("The authoritative branch changed upstream after FileGRC created its browser commit. FileGRC did not push it.");
350
+ }
351
+ if (ready.ahead < 1 || !ready.pendingCommitsFilegrcOnly) {
352
+ throw new Error("The pending commits are no longer limited to this FileGRC workspace. FileGRC did not push them.");
353
+ }
354
+ void finishBackgroundPush(root, config, committed);
355
+ } catch (error) {
356
+ recordBackgroundPushFailure(root, committed, error);
295
357
  }
296
358
  };
359
+ const delay = Math.max(0, Math.min(Number(delayMs) || 0, 30_000));
360
+ if (delay) setTimeout(start, delay);
361
+ else setImmediate(start);
362
+ }
363
+
364
+ async function finishBackgroundPush(root, config, committed) {
365
+ const started = performance.now();
366
+ let outcome = "failed";
367
+ try {
368
+ await pushConfiguredBranch(root, config, committed.commit);
369
+ const after = inspectTrunkRepository(root, config, getGitSummary(root), { ignoreBackground: true });
370
+ if (after.ahead !== 0 || after.behind !== 0) {
371
+ throw new Error("The authoritative branch is still not synchronized after the background push.");
372
+ }
373
+ const synchronizedAt = new Date().toISOString();
374
+ lastSuccessfulSynchronizations.set(root, synchronizedAt);
375
+ backgroundSynchronizations.delete(root);
376
+ outcome = "synced";
377
+ } catch (error) {
378
+ recordBackgroundPushFailure(root, committed, error);
379
+ } finally {
380
+ if (timingEnabled()) {
381
+ console.error(`[filegrc timing] ${JSON.stringify({
382
+ operation: "background-sync",
383
+ push: { count: 1, durationMs: performance.now() - started },
384
+ outcome
385
+ })}`);
386
+ }
387
+ }
388
+ }
389
+
390
+ function recordBackgroundPushFailure(root, committed, error) {
391
+ backgroundSynchronizations.set(root, {
392
+ status: "failed",
393
+ commit: committed.commit,
394
+ shortCommit: committed.shortCommit,
395
+ startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
396
+ finishedAt: new Date().toISOString(),
397
+ error: `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`
398
+ });
297
399
  }
298
400
 
299
401
  async function commitWorkspaceUnlocked(root, message) {
@@ -412,14 +514,16 @@ function syncReadySummary(root, action) {
412
514
  async function getRepositoryConfig(root) {
413
515
  const loaded = await loadWorkspace(root);
414
516
  const renderer = loaded.resources.find(({ type, id }) => type === "renderer-settings" && id === "renderer-settings");
415
- const mode = renderer?.repositoryMode === "trunk" ? "trunk" : "manual";
416
- const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch, "main");
417
- const remote = cleanGitName(renderer?.repositoryRemote, "origin");
517
+ const mode = renderer?.repositoryMode;
518
+ const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch);
519
+ const remote = cleanGitName(renderer?.repositoryRemote);
418
520
  return {
419
521
  mode,
420
522
  authoritativeBranch,
421
523
  remote,
422
- configurationError: !isSafeGitName(authoritativeBranch)
524
+ configurationError: !["trunk", "manual"].includes(mode)
525
+ ? "Repository mode is missing or invalid. Run the model migration or update renderer settings."
526
+ : !isSafeGitName(authoritativeBranch)
423
527
  ? "The configured authoritative branch is not a safe Git branch name. Update renderer settings before using browser writes."
424
528
  : !isSafeGitName(remote)
425
529
  ? "The configured repository remote is not a safe Git remote name. Update renderer settings before using browser writes."
@@ -427,7 +531,8 @@ async function getRepositoryConfig(root) {
427
531
  };
428
532
  }
429
533
 
430
- function inspectTrunkRepository(root, config, summary = getGitSummary(root)) {
534
+ function inspectTrunkRepository(root, config, summary = getGitSummary(root), options = {}) {
535
+ const background = options.ignoreBackground ? null : backgroundSynchronizations.get(root);
431
536
  const base = {
432
537
  mode: "trunk",
433
538
  authoritativeBranch: config.authoritativeBranch,
@@ -442,6 +547,14 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root)) {
442
547
  lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
443
548
  wholeWorktreeClean: summary.available ? wholeWorktreeClean(root) : null,
444
549
  operationInProgress: summary.available ? repositoryOperation(root) : null,
550
+ backgroundSynchronization: background ? {
551
+ status: background.status,
552
+ commit: background.commit,
553
+ shortCommit: background.shortCommit,
554
+ startedAt: background.startedAt,
555
+ finishedAt: background.finishedAt ?? null,
556
+ error: background.error
557
+ } : null,
445
558
  writesAllowed: false
446
559
  };
447
560
  if (config.configurationError) {
@@ -513,6 +626,16 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root)) {
513
626
  message: "The Git worktree has uncommitted changes. Commit, discard, or move them with Git before using browser writes."
514
627
  };
515
628
  }
629
+ if (background?.status === "syncing" && background.commit === summary.commit) {
630
+ return {
631
+ ...details,
632
+ status: "syncing",
633
+ label: "Syncing",
634
+ message: `The FileGRC commit ${background.shortCommit} is saved locally and is being pushed to ${expectedUpstream}.`,
635
+ writesAllowed: false,
636
+ retrySafe: false
637
+ };
638
+ }
516
639
  if (counts.ahead === null || counts.behind === null) {
517
640
  return {
518
641
  ...details,
@@ -523,19 +646,27 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root)) {
523
646
  }
524
647
  if (counts.ahead > 0 || counts.behind > 0) {
525
648
  const external = counts.ahead > 0 && !pendingCommitsFilegrcOnly;
649
+ const backgroundFailure = background?.status === "failed"
650
+ && background.commit === summary.commit
651
+ && counts.ahead > 0
652
+ && counts.behind === 0
653
+ && pendingCommitsFilegrcOnly
654
+ ? background.error
655
+ : null;
526
656
  return {
527
657
  ...details,
528
658
  status: "not-synced",
529
659
  label: "Not synced",
530
- message: external
660
+ message: backgroundFailure || (external
531
661
  ? "A commit ahead of upstream changes files outside this FileGRC workspace. Reconcile it with Git. FileGRC will not push it."
532
662
  : counts.ahead > 0 && counts.behind > 0
533
663
  ? "The authoritative branch has diverged from upstream. Reconcile it with Git. FileGRC will not merge or rebase it."
534
664
  : counts.ahead > 0
535
665
  ? "FileGRC-only commits are waiting to be pushed. Use Retry sync."
536
- : "The authoritative branch is behind upstream. The next browser mutation will fast-forward before writing.",
666
+ : "The authoritative branch is behind upstream. The next browser mutation will fast-forward before writing."),
537
667
  writesAllowed: counts.ahead === 0 && counts.behind > 0,
538
- retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly
668
+ retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly,
669
+ backgroundSyncError: backgroundFailure
539
670
  };
540
671
  }
541
672
  return {
@@ -564,20 +695,20 @@ function requireTrunkPreconditions(root, config, options = {}) {
564
695
  return state;
565
696
  }
566
697
 
567
- function fetchConfiguredRemote(root, remote) {
568
- gitForWrite(root, ["fetch", "--prune", "--", remote], `fetch ${remote}`);
698
+ async function fetchConfiguredRemote(root, remote) {
699
+ return measureTiming("fetch", () => gitForWriteAsync(root, ["fetch", "--prune", "--", remote], `fetch ${remote}`));
569
700
  }
570
701
 
571
702
  function fastForwardConfiguredBranch(root, upstream) {
572
703
  gitForWrite(root, ["merge", "--ff-only", "--", upstream], `fast-forward from ${upstream}`);
573
704
  }
574
705
 
575
- function pushConfiguredBranch(root, config) {
576
- gitForWrite(
706
+ async function pushConfiguredBranch(root, config, source = "HEAD") {
707
+ return measureTiming("push", () => gitForWriteAsync(
577
708
  root,
578
- ["push", "--porcelain", "--", config.remote, `HEAD:refs/heads/${config.authoritativeBranch}`],
709
+ ["push", "--porcelain", "--", config.remote, `${source}:refs/heads/${config.authoritativeBranch}`],
579
710
  `push ${config.authoritativeBranch} to ${config.remote}`
580
- );
711
+ ));
581
712
  }
582
713
 
583
714
  function wholeWorktreeClean(root) {
@@ -772,6 +903,73 @@ function gitForWrite(cwd, args, action = "create the commit") {
772
903
  }
773
904
  }
774
905
 
906
+ async function gitForWriteAsync(cwd, args, action = "update the repository") {
907
+ return new Promise((resolve, reject) => {
908
+ const child = spawn("git", args, {
909
+ cwd,
910
+ stdio: ["ignore", "pipe", "pipe"],
911
+ detached: process.platform !== "win32",
912
+ env: {
913
+ ...process.env,
914
+ GIT_TERMINAL_PROMPT: "0",
915
+ GIT_MERGE_AUTOEDIT: "no"
916
+ }
917
+ });
918
+ const stdout = [];
919
+ const stderr = [];
920
+ let size = 0;
921
+ let timedOut = false;
922
+ let forceKillTimer = null;
923
+ const terminate = (signal) => {
924
+ try {
925
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
926
+ else child.kill(signal);
927
+ } catch {
928
+ try {
929
+ child.kill(signal);
930
+ } catch {
931
+ // The process may have exited between the timeout and termination.
932
+ }
933
+ }
934
+ };
935
+ const timer = setTimeout(() => {
936
+ timedOut = true;
937
+ terminate("SIGTERM");
938
+ forceKillTimer = setTimeout(() => terminate("SIGKILL"), 2_000);
939
+ }, 30_000);
940
+ child.stdout.on("data", (chunk) => {
941
+ size += chunk.length;
942
+ if (size <= 20_000_000) stdout.push(chunk);
943
+ });
944
+ child.stderr.on("data", (chunk) => {
945
+ size += chunk.length;
946
+ if (size <= 20_000_000) stderr.push(chunk);
947
+ });
948
+ child.once("error", (error) => {
949
+ clearTimeout(timer);
950
+ clearTimeout(forceKillTimer);
951
+ reject(new Error(`Git could not ${action}. ${sanitizeGitErrorMessage(error.message)}`));
952
+ });
953
+ child.once("close", (code) => {
954
+ clearTimeout(timer);
955
+ clearTimeout(forceKillTimer);
956
+ const output = Buffer.concat(stdout).toString("utf8").trim();
957
+ const errorOutput = Buffer.concat(stderr).toString("utf8").trim();
958
+ if (code === 0 && !timedOut && size <= 20_000_000) {
959
+ resolve(output);
960
+ return;
961
+ }
962
+ const detail = size > 20_000_000
963
+ ? "Git output exceeded 20 MB."
964
+ : timedOut
965
+ ? "Git timed out after 30 seconds."
966
+ : errorOutput || output || `Git exited with status ${code}.`;
967
+ const message = sanitizeGitErrorMessage(detail);
968
+ reject(new Error(`Git could not ${action}. ${message}`));
969
+ });
970
+ });
971
+ }
972
+
775
973
  export function sanitizeGitErrorMessage(value) {
776
974
  return String(value || "Git returned no error detail.")
777
975
  .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, "$1[redacted]@")
package/src/index.js CHANGED
@@ -3,9 +3,9 @@ export { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldRes
3
3
  export { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
4
4
  export { buildWorkspace } from "./build.js";
5
5
  export { generateEvidencePacket, prepareEvidencePacket, writeEvidencePacket } from "./evidence-packet.js";
6
- export { ensureEvidenceTestDrafts, planEvidenceTestDrafts } from "./evidence-tests.js";
7
6
  export {
8
7
  addEvidenceAttachment,
8
+ applyResourceBatch,
9
9
  createResource,
10
10
  createResourceAndLink,
11
11
  createResources,
@@ -29,6 +29,7 @@ export {
29
29
  } from "./git.js";
30
30
  export { generateModelDocumentation } from "./model-docs.js";
31
31
  export { renderMarkdown } from "./markdown.js";
32
+ export { migrateModel, planModelMigration } from "./model-migration.js";
32
33
  export {
33
34
  completeObligationAction,
34
35
  completeObligationEvent,
@@ -36,11 +37,9 @@ export {
36
37
  createObligationEvent,
37
38
  planObligations
38
39
  } from "./obligations.js";
39
- export { assessProgramReadiness } from "./program-readiness.js";
40
+ export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
40
41
  export {
41
42
  buildAgentProgramPath,
42
- policyEventName,
43
- POLICY_EVENT_NAMES,
44
43
  PROGRAM_PATH,
45
44
  RESOURCE_INSTRUCTIONS,
46
45
  resourceProgramContext
@@ -53,9 +52,10 @@ export {
53
52
  nextCalendarOccurrence
54
53
  } from "./recurrence.js";
55
54
  export { searchResources, searchableValues } from "./search.js";
55
+ export { effectiveResourceStatus } from "./resource-status.js";
56
56
  export { createFilegrcServer, serveWorkspace } from "./server.js";
57
57
  export { normalizeSetupPayload, planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js";
58
- export { createAppState } from "./state.js";
58
+ export { createAppState, createResourceDetail } from "./state.js";
59
59
  export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js";
60
60
  export { validateWorkspace } from "./validate.js";
61
61
  export { indexResources, loadWorkspace } from "./workspace.js";
package/src/model-docs.js CHANGED
@@ -15,7 +15,7 @@ export function generateModelDocumentation(model) {
15
15
  "",
16
16
  "## Program path",
17
17
  "",
18
- "The renderer, CLI, generated agent instructions, and this reference use the same six-step lifecycle:",
18
+ "The renderer, CLI, generated agent instructions, and this reference use the same five-step lifecycle:",
19
19
  "",
20
20
  ...programPath.flatMap((stage) => [
21
21
  `### Step ${stage.number}. ${stage.title}`,
@@ -35,6 +35,52 @@ export function generateModelDocumentation(model) {
35
35
  ...stage.commands.map((command) => `- \`${command}\``),
36
36
  ""
37
37
  ]),
38
+ "## Relation groups",
39
+ "",
40
+ "Relationship fields use the named groups below. The registry expands each group to explicit resource types, so no relationship accepts an unrestricted wildcard.",
41
+ "",
42
+ "| Group | Resource types |",
43
+ "| --- | --- |",
44
+ ...Object.entries(model.relationGroups || {}).map(([name, types]) => (
45
+ `| \`${name}\` | ${types.map((type) => `\`${type}\``).join(", ")} |`
46
+ )),
47
+ "",
48
+ "## Relationship constraints",
49
+ "",
50
+ "Relationship constraints prevent cycles and duplicate active authority or access records.",
51
+ "",
52
+ ...((model.relationshipConstraints?.acyclic || []).map((constraint) => (
53
+ `- \`${constraint.resourceType}.${constraint.field}\` must be acyclic.`
54
+ ))),
55
+ ...((model.relationshipConstraints?.unique || []).map((constraint) => (
56
+ `- \`${constraint.resourceType}\` records in ${constraint.statuses.map((status) => `\`${status}\``).join(", ")} must be unique by ${constraint.fields.map((field) => `\`${field}\``).join(", ")}.`
57
+ ))),
58
+ "",
59
+ "## Obligation activities",
60
+ "",
61
+ "The model owns each activity name, allowed recurrence modes and scope types, its default completion record, and every record type that can prove completion.",
62
+ "",
63
+ "| Activity | Title | Recurrence | Scope resource types | Default completion | Accepted completion records |",
64
+ "| --- | --- | --- | --- | --- | --- |",
65
+ ...Object.entries(model.obligationActivities || {}).map(([name, activity]) => (
66
+ `| \`${name}\` | ${escapeCell(activity.title)} | ${activity.recurrenceModes.map((mode) => `\`${mode}\``).join(", ")} | ${activity.scopeResourceTypes.map((type) => `\`${type}\``).join(", ")} | \`${activity.completionType}\` | ${activity.completionResourceTypes.map((type) => `\`${type}\``).join(", ")} |`
67
+ )),
68
+ "",
69
+ "## Policy events",
70
+ "",
71
+ "The model owns each event title and the minimum and maximum count for each subject resource type.",
72
+ "",
73
+ "| Event | Title | Subject rules |",
74
+ "| --- | --- | --- |",
75
+ ...Object.entries(model.policyEvents || {}).map(([name, event]) => (
76
+ `| \`${name}\` | ${escapeCell(event.title)} | ${event.subjectRules.map(({ resourceType, minimum = 0, maximum }) => `\`${resourceType}\` ${minimum}..${Number.isInteger(maximum) ? maximum : "*"}`).join(", ")} |`
77
+ )),
78
+ "",
79
+ "## Nested object schemas",
80
+ "",
81
+ "Named object schemas reject unknown keys unless the schema explicitly allows a typed map or arbitrary JSON. Conditional properties are valid only for the selected discriminator.",
82
+ "",
83
+ ...Object.entries(model.objectTypes || {}).flatMap(([name, schema]) => objectTypeDocumentation(name, schema)),
38
84
  "## Common fields",
39
85
  "",
40
86
  "| Field | Type | Required | Meaning |",
@@ -53,7 +99,7 @@ export function generateModelDocumentation(model) {
53
99
  "",
54
100
  "## Program and audit readiness defaults",
55
101
  "",
56
- "Program Readiness checks management scope, policy adoption, control implementation, authoritative source configuration, and verified test captures without requiring an audit record. Audit Readiness starts after a CPA firm is engaged and uses the defaults below to prepare Type 1 and Type 2 fieldwork.",
102
+ "Program Readiness checks management scope, policy adoption, control implementation, and authoritative evidence mapping without requiring an audit record. Audit Readiness starts after a CPA firm is engaged and uses the defaults below to prepare Type 1 and Type 2 fieldwork.",
57
103
  "",
58
104
  "Management documents:",
59
105
  "",
@@ -66,9 +112,9 @@ export function generateModelDocumentation(model) {
66
112
  "Authoritative systems of record:",
67
113
  "",
68
114
  ...model.evidenceSourceFamilies.map((item) => (
69
- item.collectionTestRequired === false
70
- ? `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} filegrc operating records: ${item.operationRecordTypes.map((type) => `\`${type}\``).join(", ")}. No separate collection test is required. ${item.timing}`
71
- : `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} Test external collection: ${item.testPrompt} ${item.timing}`
115
+ item.filegrcManaged === true
116
+ ? `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} FileGRC operating records: ${item.operationRecordTypes.map((type) => `\`${type}\``).join(", ")}. Expected evidence: ${item.evidencePrompt} ${item.timing}`
117
+ : `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} Expected evidence: ${item.evidencePrompt} ${item.timing}`
72
118
  )),
73
119
  "",
74
120
  "## Resource groups",
@@ -143,7 +189,12 @@ function choiceLabel(name) {
143
189
  }
144
190
 
145
191
  function fieldType(field) {
146
- if (field.type === "array") return `array of ${field.items ?? "values"}`;
192
+ if (field.type === "array") {
193
+ return field.itemObjectType
194
+ ? `array of object (\`${field.itemObjectType}\`)`
195
+ : `array of ${field.items ?? "values"}`;
196
+ }
197
+ if (field.type === "object" && field.objectType) return `object (\`${field.objectType}\`)`;
147
198
  return field.format && field.format !== field.type ? `${field.type} (${field.format})` : field.type;
148
199
  }
149
200
 
@@ -151,14 +202,44 @@ function fieldNotes(field) {
151
202
  return [
152
203
  field.label,
153
204
  field.values ? `Values: ${field.values.map((item) => `\`${item}\``).join(", ")}` : "",
205
+ field.registry ? `Values come from the \`${field.registry}\` registry.` : "",
206
+ field.relationGroup ? `Relation group: \`${field.relationGroup}\`.` : "",
154
207
  field.relation ? `References: ${field.relation.map((item) => `\`${item}\``).join(", ")}` : "",
155
208
  field.minimum !== undefined ? `Minimum: \`${field.minimum}\`.` : "",
156
209
  field.maximum !== undefined ? `Maximum: \`${field.maximum}\`.` : "",
210
+ field.managed ? "Managed by filegrc." : "",
157
211
  field.disjointFrom ? `Must not overlap \`${field.disjointFrom}\`.` : "",
158
- field.requiredWhen ? `Required when ${Object.entries(field.requiredWhen).map(([key, value]) => `\`${key}\` is \`${value}\``).join(" and ")}` : ""
212
+ field.requiredWhen ? `Required when ${conditionText(field.requiredWhen)}.` : "",
213
+ field.allowedWhen ? `Allowed when ${conditionText(field.allowedWhen)}.` : ""
159
214
  ].filter(Boolean).join(" ");
160
215
  }
161
216
 
217
+ function objectTypeDocumentation(name, schema) {
218
+ const lines = [`### \`${name}\``, ""];
219
+ const properties = Object.entries(schema.properties || {});
220
+ if (properties.length) {
221
+ const required = new Set(schema.required || []);
222
+ lines.push("| Property | Type | Required | Notes |", "| --- | --- | --- | --- |");
223
+ for (const [propertyName, property] of properties) {
224
+ const requiredLabel = required.has(propertyName)
225
+ ? "Yes"
226
+ : property.requiredWhen
227
+ ? "Conditional"
228
+ : "No";
229
+ lines.push(`| \`${propertyName}\` | ${fieldType(property)} | ${requiredLabel} | ${escapeCell(fieldNotes(property))} |`);
230
+ }
231
+ } else if (schema.additionalProperties === true) {
232
+ lines.push("Allows arbitrary JSON properties.");
233
+ } else if (schema.additionalProperties) {
234
+ lines.push(`Allows dynamic keys whose values are ${fieldType(schema.additionalProperties)}.`);
235
+ } else {
236
+ lines.push("Does not allow properties.");
237
+ }
238
+ if (schema.keyFormat) lines.push("", `Key format: \`${schema.keyFormat}\`.`);
239
+ lines.push("");
240
+ return lines;
241
+ }
242
+
162
243
  function escapeCell(value) {
163
244
  return String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
164
245
  }