filegrc 0.5.0 → 0.6.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 +14 -6
- package/model/index.js +4 -4
- package/model/v4.json +10052 -0
- package/package.json +2 -2
- package/src/agent.js +17 -5
- package/src/audit-preparation.js +17 -13
- package/src/audit-transition.js +7 -4
- package/src/batch-review.js +31 -8
- package/src/cli.js +43 -25
- package/src/collection-review.js +71 -25
- package/src/evidence-packet.js +76 -43
- package/src/external-reviewer.js +4 -4
- package/src/files.js +70 -6
- package/src/git.js +70 -13
- package/src/index.js +1 -0
- package/src/model-migration.js +631 -14
- package/src/obligations.js +14 -7
- package/src/program-path.js +49 -38
- package/src/program-readiness.js +94 -54
- package/src/program.js +52 -0
- package/src/reconciliation.js +2 -2
- package/src/server.js +50 -8
- package/src/setup.js +56 -21
- package/src/source-coverage.js +13 -9
- package/src/startup.js +1 -1
- package/src/state.js +17 -14
- package/src/timing.js +6 -0
- package/src/validate.js +100 -9
- package/src/web.js +709 -151
- package/src/workflow.js +47 -25
package/src/files.js
CHANGED
|
@@ -206,8 +206,9 @@ export async function applyResourceBatch(input, changes) {
|
|
|
206
206
|
async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
207
207
|
const creates = changes.create || [];
|
|
208
208
|
const updates = changes.update || [];
|
|
209
|
+
const moves = changes.movePaths || [];
|
|
209
210
|
const expectedRevisions = changes.expectedRevisions || {};
|
|
210
|
-
if (!Array.isArray(creates) || !Array.isArray(updates) || (!creates.length && !updates.length)) {
|
|
211
|
+
if (!Array.isArray(creates) || !Array.isArray(updates) || !Array.isArray(moves) || (!creates.length && !updates.length && !moves.length)) {
|
|
211
212
|
throw new Error("A resource batch needs at least one create or update.");
|
|
212
213
|
}
|
|
213
214
|
if (Array.isArray(expectedRevisions) || typeof expectedRevisions !== "object") {
|
|
@@ -246,6 +247,7 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
246
247
|
const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
247
248
|
const ids = new Set();
|
|
248
249
|
const writes = [];
|
|
250
|
+
const allowedPathMoves = new Set();
|
|
249
251
|
for (const record of creates) {
|
|
250
252
|
validateBatchRecord(record, ids);
|
|
251
253
|
if (existingById.has(record.id)) throw new Error(`Resource "${record.id}" already exists.`);
|
|
@@ -256,24 +258,75 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
256
258
|
validateBatchRecord(record, ids);
|
|
257
259
|
const existing = existingById.get(record.id);
|
|
258
260
|
if (!existing) throw new Error(`Resource "${record.id}" was not found.`);
|
|
259
|
-
if (existing.record.type !== record.type) {
|
|
261
|
+
if (existing.record.type !== record.type && !targetModelVersion) {
|
|
260
262
|
throw new Error(`Resource "${record.id}" cannot change type.`);
|
|
261
263
|
}
|
|
264
|
+
if (existing.record.type !== record.type) {
|
|
265
|
+
const targetMarkdownByName = new Map(
|
|
266
|
+
markdownEntries(writeModel, record).map((entry) => [entry.name, entry.path])
|
|
267
|
+
);
|
|
268
|
+
for (const sourceMarkdown of markdownEntries(loaded.model, existing.record)) {
|
|
269
|
+
const targetMarkdown = targetMarkdownByName.get(sourceMarkdown.name);
|
|
270
|
+
if (targetMarkdown && targetMarkdown !== sourceMarkdown.path) {
|
|
271
|
+
allowedPathMoves.add(`${sourceMarkdown.path}\0${targetMarkdown}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
262
275
|
const path = resourcePath(loaded.root, writeModel, record);
|
|
263
|
-
const
|
|
264
|
-
const
|
|
276
|
+
const previousPath = existing.path;
|
|
277
|
+
const previous = await readFile(previousPath, "utf8");
|
|
278
|
+
const mode = (await stat(previousPath)).mode & 0o777;
|
|
265
279
|
assertRevision(
|
|
266
280
|
previous,
|
|
267
281
|
expectedRevisions[record.id] || existing.revision,
|
|
268
282
|
`Resource "${record.id}"`
|
|
269
283
|
);
|
|
270
|
-
|
|
284
|
+
if (path !== previousPath) {
|
|
285
|
+
try {
|
|
286
|
+
await stat(path);
|
|
287
|
+
throw new Error(`Migration destination for resource "${record.id}" already exists.`);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (error.code !== "ENOENT") throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
writes.push({ operation: path === previousPath ? "update" : "move-update", path, previousPath, record, previous, fileMode: mode });
|
|
293
|
+
}
|
|
294
|
+
const pathMoves = [];
|
|
295
|
+
const seenMovePaths = new Set();
|
|
296
|
+
for (const move of moves) {
|
|
297
|
+
if (!move || Array.isArray(move) || typeof move !== "object") {
|
|
298
|
+
throw new Error("Every migration path move must name a companion Markdown source and destination.");
|
|
299
|
+
}
|
|
300
|
+
const moveKey = `${move.from}\0${move.to}`;
|
|
301
|
+
if (!targetModelVersion || !allowedPathMoves.has(moveKey)) {
|
|
302
|
+
throw new Error("A migration path move must match companion Markdown for a resource whose type changes.");
|
|
303
|
+
}
|
|
304
|
+
if (seenMovePaths.has(moveKey)) throw new Error("A migration path move may appear only once.");
|
|
305
|
+
seenMovePaths.add(moveKey);
|
|
306
|
+
const from = resolveDataPath(loaded.root, move.from);
|
|
307
|
+
const to = resolveDataPath(loaded.root, move.to);
|
|
308
|
+
if (from === to) continue;
|
|
309
|
+
const mode = (await stat(from)).mode & 0o777;
|
|
310
|
+
try {
|
|
311
|
+
await stat(to);
|
|
312
|
+
throw new Error(`Migration destination data/${move.to} already exists.`);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error.code !== "ENOENT") throw error;
|
|
315
|
+
}
|
|
316
|
+
pathMoves.push({ from, to, mode });
|
|
271
317
|
}
|
|
272
318
|
const written = [];
|
|
319
|
+
const moved = [];
|
|
273
320
|
try {
|
|
274
321
|
for (const item of writes) {
|
|
275
322
|
await writeAtomic(item.path, item.record, { exclusive: item.operation === "create" });
|
|
276
323
|
written.push(item);
|
|
324
|
+
if (item.operation === "move-update") await rm(item.previousPath);
|
|
325
|
+
}
|
|
326
|
+
for (const item of pathMoves) {
|
|
327
|
+
await mkdir(dirname(item.to), { recursive: true });
|
|
328
|
+
await rename(item.from, item.to);
|
|
329
|
+
moved.push(item);
|
|
277
330
|
}
|
|
278
331
|
let validation = null;
|
|
279
332
|
if (!deferValidation) {
|
|
@@ -290,10 +343,21 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
290
343
|
};
|
|
291
344
|
} catch (error) {
|
|
292
345
|
const rollbackErrors = [];
|
|
346
|
+
for (const item of moved.reverse()) {
|
|
347
|
+
try {
|
|
348
|
+
await mkdir(dirname(item.from), { recursive: true });
|
|
349
|
+
await rename(item.to, item.from);
|
|
350
|
+
} catch (rollbackError) {
|
|
351
|
+
rollbackErrors.push(rollbackError.message);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
293
354
|
for (const item of written.reverse()) {
|
|
294
355
|
try {
|
|
295
356
|
if (item.operation === "create") await rm(item.path, { force: true });
|
|
296
|
-
else
|
|
357
|
+
else if (item.operation === "move-update") {
|
|
358
|
+
await rm(item.path, { force: true });
|
|
359
|
+
await writeTextAtomic(item.previousPath, item.previous, { mode: item.fileMode });
|
|
360
|
+
} else await writeTextAtomic(item.path, item.previous, { mode: item.fileMode });
|
|
297
361
|
} catch (rollbackError) {
|
|
298
362
|
rollbackErrors.push(rollbackError.message);
|
|
299
363
|
}
|
package/src/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { existsSync } from "node:fs";
|
|
3
4
|
import { rm } from "node:fs/promises";
|
|
4
5
|
import { relative, resolve, sep } from "node:path";
|
|
@@ -6,13 +7,15 @@ import { performance } from "node:perf_hooks";
|
|
|
6
7
|
import { isSafeGitName } from "./git-name.js";
|
|
7
8
|
import { serializeWorkspaceMutation, withDeferredWorkspaceValidation } from "./mutation.js";
|
|
8
9
|
import { resolveWorkspaceRoot } from "./paths.js";
|
|
9
|
-
import { measureTiming, measureTimingSync, timingEnabled } from "./timing.js";
|
|
10
|
+
import { measureTiming, measureTimingSync, recordTiming, timingEnabled } from "./timing.js";
|
|
10
11
|
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
11
12
|
import { loadWorkspace } from "./workspace.js";
|
|
12
13
|
|
|
13
14
|
const lastSuccessfulSynchronizations = new Map();
|
|
14
15
|
const workspaceHistoryCache = new Map();
|
|
15
16
|
const backgroundSynchronizations = new Map();
|
|
17
|
+
const browserRemotePrefetches = new Map();
|
|
18
|
+
const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
|
|
16
19
|
export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
|
|
17
20
|
|
|
18
21
|
export function getGitSummary(input = process.cwd()) {
|
|
@@ -195,6 +198,34 @@ export async function runBrowserMutation(input, options, task) {
|
|
|
195
198
|
});
|
|
196
199
|
}
|
|
197
200
|
|
|
201
|
+
export async function prefetchBrowserRemote(input = process.cwd(), options = {}) {
|
|
202
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
203
|
+
const config = await getRepositoryConfig(root);
|
|
204
|
+
if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) {
|
|
205
|
+
return { status: "not-needed", token: null, fetchedAt: null, expiresAt: null };
|
|
206
|
+
}
|
|
207
|
+
measureTimingSync("git-preconditions", () => requireTrunkPreconditions(root, config));
|
|
208
|
+
await fetchConfiguredRemote(root, config.remote);
|
|
209
|
+
const summary = getGitSummary(root);
|
|
210
|
+
const repository = inspectTrunkRepository(root, config, summary);
|
|
211
|
+
const fetchedAt = new Date().toISOString();
|
|
212
|
+
const token = randomUUID();
|
|
213
|
+
browserRemotePrefetches.set(root, {
|
|
214
|
+
token,
|
|
215
|
+
remote: config.remote,
|
|
216
|
+
currentCommit: summary.commit,
|
|
217
|
+
upstreamCommit: repository.upstreamCommit,
|
|
218
|
+
fetchedAt: Date.parse(fetchedAt)
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
status: "checked",
|
|
222
|
+
token,
|
|
223
|
+
fetchedAt,
|
|
224
|
+
expiresAt: new Date(Date.parse(fetchedAt) + BROWSER_REMOTE_PREFETCH_MAX_AGE_MS).toISOString()
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
198
229
|
export async function retryBrowserSync(input = process.cwd(), options = {}) {
|
|
199
230
|
return serializeWorkspaceMutation(input, async (root) => {
|
|
200
231
|
const config = await getRepositoryConfig(root);
|
|
@@ -238,9 +269,12 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
|
|
|
238
269
|
}
|
|
239
270
|
|
|
240
271
|
async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
241
|
-
requireTrunkPreconditions(root, config);
|
|
242
|
-
|
|
243
|
-
|
|
272
|
+
const beforeFetch = measureTimingSync("git-preconditions", () => requireTrunkPreconditions(root, config));
|
|
273
|
+
let synchronized = beforeFetch;
|
|
274
|
+
if (!consumeFreshBrowserRemotePrefetch(root, config, options?.prefetchToken, beforeFetch)) {
|
|
275
|
+
await fetchConfiguredRemote(root, config.remote);
|
|
276
|
+
synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
277
|
+
}
|
|
244
278
|
if (synchronized.ahead > 0 && synchronized.behind > 0) {
|
|
245
279
|
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.");
|
|
246
280
|
}
|
|
@@ -259,16 +293,18 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
259
293
|
let subject;
|
|
260
294
|
let validationProof;
|
|
261
295
|
try {
|
|
262
|
-
result = await withDeferredWorkspaceValidation(() => task(root));
|
|
296
|
+
result = await measureTiming("write", () => withDeferredWorkspaceValidation(() => task(root)));
|
|
263
297
|
subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
|
|
264
298
|
const validation = await validateWorkspace(root);
|
|
265
299
|
if (!validation.ok) {
|
|
266
300
|
throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. The browser change was rolled back.`);
|
|
267
301
|
}
|
|
268
|
-
validationProof =
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
302
|
+
validationProof = options?.includeValidationProof === false
|
|
303
|
+
? null
|
|
304
|
+
: {
|
|
305
|
+
validation,
|
|
306
|
+
fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
|
|
307
|
+
};
|
|
272
308
|
assertNoOutsideWorktreeChanges(root);
|
|
273
309
|
} catch (error) {
|
|
274
310
|
try {
|
|
@@ -279,7 +315,8 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
279
315
|
throw error;
|
|
280
316
|
}
|
|
281
317
|
|
|
282
|
-
|
|
318
|
+
const changed = getGitSummary(root).changes.length > 0;
|
|
319
|
+
if (!changed && options?.allowNoChanges === true) {
|
|
283
320
|
return withValidationProof({
|
|
284
321
|
...result,
|
|
285
322
|
synchronization: {
|
|
@@ -292,13 +329,15 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
292
329
|
}
|
|
293
330
|
}, validationProof);
|
|
294
331
|
}
|
|
295
|
-
if (!
|
|
332
|
+
if (!changed) {
|
|
296
333
|
throw new Error("The browser action did not change any FileGRC workspace files.");
|
|
297
334
|
}
|
|
298
335
|
if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
|
|
299
336
|
throw new Error("Configure git user.name and git user.email before browser changes can be committed. The saved files remain uncommitted and later browser changes are blocked.");
|
|
300
337
|
}
|
|
301
|
-
|
|
338
|
+
measureTimingSync("stage", () => {
|
|
339
|
+
gitForWrite(root, ["add", "--all", "--", "."], "stage the FileGRC workspace change");
|
|
340
|
+
});
|
|
302
341
|
assertNoOutsideWorktreeChanges(root, false);
|
|
303
342
|
assertOnlyWorkspaceFilesStaged(root);
|
|
304
343
|
try {
|
|
@@ -325,12 +364,30 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
325
364
|
}
|
|
326
365
|
|
|
327
366
|
function withValidationProof(result, proof) {
|
|
328
|
-
if (result && typeof result === "object") {
|
|
367
|
+
if (proof && result && typeof result === "object") {
|
|
329
368
|
Object.defineProperty(result, BROWSER_VALIDATION, { value: proof });
|
|
330
369
|
}
|
|
331
370
|
return result;
|
|
332
371
|
}
|
|
333
372
|
|
|
373
|
+
function consumeFreshBrowserRemotePrefetch(root, config, token, repository) {
|
|
374
|
+
const prefetch = browserRemotePrefetches.get(root);
|
|
375
|
+
browserRemotePrefetches.delete(root);
|
|
376
|
+
if (
|
|
377
|
+
!token
|
|
378
|
+
|| !prefetch
|
|
379
|
+
|| prefetch.token !== token
|
|
380
|
+
|| prefetch.remote !== config.remote
|
|
381
|
+
|| Date.now() - prefetch.fetchedAt > BROWSER_REMOTE_PREFETCH_MAX_AGE_MS
|
|
382
|
+
) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
const reusable = repository.currentCommit === prefetch.currentCommit
|
|
386
|
+
&& repository.upstreamCommit === prefetch.upstreamCommit;
|
|
387
|
+
if (reusable) recordTiming("fetch-reused", 0);
|
|
388
|
+
return reusable;
|
|
389
|
+
}
|
|
390
|
+
|
|
334
391
|
function queueBackgroundPush(root, config, committed, delayMs = 0) {
|
|
335
392
|
backgroundSynchronizations.set(root, {
|
|
336
393
|
status: "syncing",
|