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/README.md +16 -6
- package/model/index.js +37 -3
- package/model/v1.json +81 -47
- package/model/v2.json +8022 -0
- package/package.json +1 -1
- package/src/agent.js +36 -8
- package/src/audit-preparation.js +63 -60
- package/src/cli.js +168 -110
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +115 -75
- package/src/files.js +230 -28
- package/src/git.js +239 -41
- package/src/index.js +5 -5
- package/src/model-docs.js +88 -7
- package/src/model-migration.js +1463 -0
- package/src/mutation.js +42 -0
- package/src/obligations.js +108 -84
- package/src/parties.js +17 -2
- package/src/program-path.js +31 -58
- package/src/program-readiness.js +142 -106
- package/src/resource-status.js +17 -0
- package/src/server.js +110 -39
- package/src/setup.js +27 -28
- package/src/state.js +86 -25
- package/src/timing.js +41 -0
- package/src/validate.js +609 -43
- package/src/web.js +506 -129
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
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 {
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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:
|
|
317
|
+
status: "syncing",
|
|
290
318
|
commit: committed.commit,
|
|
291
319
|
shortCommit: committed.shortCommit,
|
|
292
|
-
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
|
|
416
|
-
const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch
|
|
417
|
-
const remote = cleanGitName(renderer?.repositoryRemote
|
|
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: !
|
|
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
|
-
|
|
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
|
-
|
|
706
|
+
async function pushConfiguredBranch(root, config, source = "HEAD") {
|
|
707
|
+
return measureTiming("push", () => gitForWriteAsync(
|
|
577
708
|
root,
|
|
578
|
-
["push", "--porcelain", "--", config.remote,
|
|
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
|
|
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,
|
|
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.
|
|
70
|
-
? `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description}
|
|
71
|
-
: `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description}
|
|
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")
|
|
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 ${
|
|
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
|
}
|