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/files.js
CHANGED
|
@@ -2,9 +2,10 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { getResourceDefinition } from "../model/index.js";
|
|
5
|
-
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
5
|
+
import { serializeWorkspaceMutation, workspaceValidationDeferred } from "./mutation.js";
|
|
6
6
|
import { isCanonicalDataPath, resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
7
7
|
import { markdownEntries } from "./resource-markdown.js";
|
|
8
|
+
import { measureTiming } from "./timing.js";
|
|
8
9
|
import { loadWorkspace } from "./workspace.js";
|
|
9
10
|
import { validateWorkspace } from "./validate.js";
|
|
10
11
|
|
|
@@ -198,6 +199,95 @@ export async function createResources(input, records) {
|
|
|
198
199
|
return serializeWorkspaceMutation(input, (root) => createResourcesUnlocked(root, records));
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
export async function applyResourceBatch(input, changes) {
|
|
203
|
+
return serializeWorkspaceMutation(input, (root) => applyResourceBatchUnlocked(root, changes));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
207
|
+
const creates = changes.create || [];
|
|
208
|
+
const updates = changes.update || [];
|
|
209
|
+
const expectedRevisions = changes.expectedRevisions || {};
|
|
210
|
+
if (!Array.isArray(creates) || !Array.isArray(updates) || (!creates.length && !updates.length)) {
|
|
211
|
+
throw new Error("A resource batch needs at least one create or update.");
|
|
212
|
+
}
|
|
213
|
+
if (Array.isArray(expectedRevisions) || typeof expectedRevisions !== "object") {
|
|
214
|
+
throw new Error("Batch expected revisions must be keyed by resource ID.");
|
|
215
|
+
}
|
|
216
|
+
const loaded = await loadWorkspace(input);
|
|
217
|
+
const deferValidation = workspaceValidationDeferred();
|
|
218
|
+
const before = deferValidation || changes.validateWholeWorkspace
|
|
219
|
+
? null
|
|
220
|
+
: await validateWorkspace(loaded);
|
|
221
|
+
const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
222
|
+
const ids = new Set();
|
|
223
|
+
const writes = [];
|
|
224
|
+
for (const record of creates) {
|
|
225
|
+
validateBatchRecord(record, ids);
|
|
226
|
+
if (existingById.has(record.id)) throw new Error(`Resource "${record.id}" already exists.`);
|
|
227
|
+
const path = resourcePath(loaded.root, loaded.model, record);
|
|
228
|
+
writes.push({ operation: "create", path, record, previous: null, fileMode: 0o666 });
|
|
229
|
+
}
|
|
230
|
+
for (const record of updates) {
|
|
231
|
+
validateBatchRecord(record, ids);
|
|
232
|
+
const existing = existingById.get(record.id);
|
|
233
|
+
if (!existing) throw new Error(`Resource "${record.id}" was not found.`);
|
|
234
|
+
if (existing.record.type !== record.type) {
|
|
235
|
+
throw new Error(`Resource "${record.id}" cannot change type.`);
|
|
236
|
+
}
|
|
237
|
+
const path = resourcePath(loaded.root, loaded.model, record);
|
|
238
|
+
const previous = await readFile(path, "utf8");
|
|
239
|
+
const mode = (await stat(path)).mode & 0o777;
|
|
240
|
+
assertRevision(
|
|
241
|
+
previous,
|
|
242
|
+
expectedRevisions[record.id] || existing.revision,
|
|
243
|
+
`Resource "${record.id}"`
|
|
244
|
+
);
|
|
245
|
+
writes.push({ operation: "update", path, record, previous, fileMode: mode });
|
|
246
|
+
}
|
|
247
|
+
const written = [];
|
|
248
|
+
try {
|
|
249
|
+
for (const item of writes) {
|
|
250
|
+
await writeAtomic(item.path, item.record, { exclusive: item.operation === "create" });
|
|
251
|
+
written.push(item);
|
|
252
|
+
}
|
|
253
|
+
let validation = null;
|
|
254
|
+
if (!deferValidation) {
|
|
255
|
+
validation = await validateWorkspace(loaded.root);
|
|
256
|
+
const errors = changes.validateWholeWorkspace
|
|
257
|
+
? validation.diagnostics.filter(({ severity }) => severity === "error")
|
|
258
|
+
: newErrors(validation, before);
|
|
259
|
+
if (errors.length) throw new Error(formatWriteFailure(errors, "resource batch"));
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
created: creates,
|
|
263
|
+
updated: updates,
|
|
264
|
+
validation
|
|
265
|
+
};
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const rollbackErrors = [];
|
|
268
|
+
for (const item of written.reverse()) {
|
|
269
|
+
try {
|
|
270
|
+
if (item.operation === "create") await rm(item.path, { force: true });
|
|
271
|
+
else await writeTextAtomic(item.path, item.previous, { mode: item.fileMode });
|
|
272
|
+
} catch (rollbackError) {
|
|
273
|
+
rollbackErrors.push(rollbackError.message);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (rollbackErrors.length) {
|
|
277
|
+
throw new Error(`${error.message} FileGRC could not restore every file in the resource batch: ${rollbackErrors.join(" ")}`);
|
|
278
|
+
}
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function validateBatchRecord(record, ids) {
|
|
284
|
+
if (!record || Array.isArray(record) || typeof record !== "object") {
|
|
285
|
+
throw new Error("Every resource in a batch must be a JSON object.");
|
|
286
|
+
}
|
|
287
|
+
if (ids.has(record.id)) throw new Error(`Resource "${record.id}" appears more than once in the batch.`);
|
|
288
|
+
ids.add(record.id);
|
|
289
|
+
}
|
|
290
|
+
|
|
201
291
|
export async function createResourceAndLink(input, record, linkTarget, options = {}) {
|
|
202
292
|
return serializeWorkspaceMutation(input, (root) => createResourceAndLinkUnlocked(root, record, linkTarget, options));
|
|
203
293
|
}
|
|
@@ -254,7 +344,8 @@ async function createResourceAndLinkUnlocked(input, record, linkTarget, options)
|
|
|
254
344
|
async function createResourcesUnlocked(input, records) {
|
|
255
345
|
if (!Array.isArray(records) || records.length === 0) throw new Error("At least one resource is required.");
|
|
256
346
|
const loaded = await loadWorkspace(input);
|
|
257
|
-
const
|
|
347
|
+
const deferValidation = workspaceValidationDeferred();
|
|
348
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
258
349
|
const ids = new Set();
|
|
259
350
|
const writes = [];
|
|
260
351
|
for (const record of records) {
|
|
@@ -276,9 +367,11 @@ async function createResourcesUnlocked(input, records) {
|
|
|
276
367
|
await writeAtomic(item.path, item.record, { exclusive: true });
|
|
277
368
|
written.push(item);
|
|
278
369
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
370
|
+
if (!deferValidation) {
|
|
371
|
+
const result = await validateWorkspace(loaded.root);
|
|
372
|
+
const introduced = newErrors(result, before);
|
|
373
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, "resource batch"));
|
|
374
|
+
}
|
|
282
375
|
} catch (error) {
|
|
283
376
|
for (const item of written.reverse()) await rm(item.path, { force: true });
|
|
284
377
|
throw error;
|
|
@@ -288,7 +381,8 @@ async function createResourcesUnlocked(input, records) {
|
|
|
288
381
|
|
|
289
382
|
async function createResourceUnlocked(input, record, options) {
|
|
290
383
|
const loaded = await loadWorkspace(input);
|
|
291
|
-
const
|
|
384
|
+
const deferValidation = workspaceValidationDeferred();
|
|
385
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
292
386
|
const path = resourcePath(loaded.root, loaded.model, record);
|
|
293
387
|
try {
|
|
294
388
|
await stat(path);
|
|
@@ -297,6 +391,7 @@ async function createResourceUnlocked(input, record, options) {
|
|
|
297
391
|
if (error.code !== "ENOENT") throw error;
|
|
298
392
|
}
|
|
299
393
|
const contentWrites = await prepareContentWrites(loaded, record, options.content, { exclusive: true });
|
|
394
|
+
const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites);
|
|
300
395
|
const written = [];
|
|
301
396
|
let recordWritten = false;
|
|
302
397
|
try {
|
|
@@ -304,17 +399,19 @@ async function createResourceUnlocked(input, record, options) {
|
|
|
304
399
|
await writeTextAtomic(item.path, item.source, { exclusive: true });
|
|
305
400
|
written.push(item);
|
|
306
401
|
}
|
|
307
|
-
await writeAtomic(path,
|
|
402
|
+
await writeAtomic(path, nextRecord, { exclusive: true });
|
|
308
403
|
recordWritten = true;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
404
|
+
if (!deferValidation) {
|
|
405
|
+
const result = await validateWorkspace(loaded.root);
|
|
406
|
+
const introduced = newErrors(result, before);
|
|
407
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, record.id));
|
|
408
|
+
}
|
|
312
409
|
} catch (error) {
|
|
313
410
|
if (recordWritten) await rm(path, { force: true });
|
|
314
411
|
for (const item of written) await rm(item.path, { force: true });
|
|
315
412
|
throw error;
|
|
316
413
|
}
|
|
317
|
-
return { record, path };
|
|
414
|
+
return { record: nextRecord, path };
|
|
318
415
|
}
|
|
319
416
|
|
|
320
417
|
export async function updateResource(input, type, id, record, options = {}) {
|
|
@@ -326,19 +423,25 @@ async function updateResourceUnlocked(input, type, id, record, options) {
|
|
|
326
423
|
throw new Error("The type and ID in the record must match the resource being updated.");
|
|
327
424
|
}
|
|
328
425
|
const loaded = await loadWorkspace(input);
|
|
329
|
-
const
|
|
426
|
+
const deferValidation = workspaceValidationDeferred();
|
|
427
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
330
428
|
const path = resourcePath(loaded.root, loaded.model, record);
|
|
331
429
|
const previous = await readFile(path, "utf8");
|
|
332
430
|
assertRevision(previous, options.expectedRevision, "The record");
|
|
333
431
|
const contentWrites = await prepareContentWrites(loaded, record, options.content, {
|
|
334
|
-
expectedRevisions: options.expectedContentRevisions
|
|
432
|
+
expectedRevisions: options.expectedContentRevisions,
|
|
433
|
+
requireExpectedRevisions: options.requireExpectedContentRevisions
|
|
335
434
|
});
|
|
435
|
+
const existing = loaded.entries.find(({ record: candidate }) => candidate.id === id)?.record;
|
|
436
|
+
const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites, existing);
|
|
336
437
|
try {
|
|
337
438
|
for (const item of contentWrites) await writeTextAtomic(item.path, item.source);
|
|
338
|
-
await writeAtomic(path,
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
439
|
+
await writeAtomic(path, nextRecord);
|
|
440
|
+
if (!deferValidation) {
|
|
441
|
+
const result = await validateWorkspace(loaded.root);
|
|
442
|
+
const introduced = newErrors(result, before);
|
|
443
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
|
|
444
|
+
}
|
|
342
445
|
} catch (error) {
|
|
343
446
|
await writeTextAtomic(path, previous);
|
|
344
447
|
for (const item of contentWrites) {
|
|
@@ -347,7 +450,7 @@ async function updateResourceUnlocked(input, type, id, record, options) {
|
|
|
347
450
|
}
|
|
348
451
|
throw error;
|
|
349
452
|
}
|
|
350
|
-
return { record, path };
|
|
453
|
+
return { record: nextRecord, path };
|
|
351
454
|
}
|
|
352
455
|
|
|
353
456
|
export async function updateContent(input, dataRelativePath, source, options = {}) {
|
|
@@ -368,8 +471,18 @@ async function updateContentUnlocked(input, dataRelativePath, source, options) {
|
|
|
368
471
|
const path = resolveDataPath(loaded.root, dataRelativePath);
|
|
369
472
|
const previous = await readFile(path, "utf8");
|
|
370
473
|
assertRevision(previous, options.expectedRevision, "The Markdown file");
|
|
371
|
-
|
|
372
|
-
|
|
474
|
+
const before = await validateWorkspace(loaded);
|
|
475
|
+
const nextSource = source.endsWith("\n") ? source : `${source}\n`;
|
|
476
|
+
await writeTextAtomic(path, nextSource);
|
|
477
|
+
try {
|
|
478
|
+
const result = await validateWorkspace(loaded.root);
|
|
479
|
+
const introduced = newErrors(result, before);
|
|
480
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, dataRelativePath));
|
|
481
|
+
return { path, dataRelativePath };
|
|
482
|
+
} catch (error) {
|
|
483
|
+
await writeTextAtomic(path, previous);
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
373
486
|
}
|
|
374
487
|
|
|
375
488
|
export async function deleteResource(input, type, id, options = {}) {
|
|
@@ -378,7 +491,8 @@ export async function deleteResource(input, type, id, options = {}) {
|
|
|
378
491
|
|
|
379
492
|
async function deleteResourceUnlocked(input, type, id, options) {
|
|
380
493
|
const loaded = await loadWorkspace(input);
|
|
381
|
-
const
|
|
494
|
+
const deferValidation = workspaceValidationDeferred();
|
|
495
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
382
496
|
const definition = getResourceDefinition(loaded.model, type);
|
|
383
497
|
if (definition.singleton) throw new Error("Singleton records cannot be deleted.");
|
|
384
498
|
const path = resourcePath(loaded.root, loaded.model, { type, id });
|
|
@@ -393,9 +507,11 @@ async function deleteResourceUnlocked(input, type, id, options) {
|
|
|
393
507
|
try {
|
|
394
508
|
await rm(path);
|
|
395
509
|
for (const item of contentFiles) await rm(item.path, { force: true });
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
510
|
+
if (!deferValidation) {
|
|
511
|
+
const result = await validateWorkspace(loaded.root);
|
|
512
|
+
const introduced = newErrors(result, before);
|
|
513
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
|
|
514
|
+
}
|
|
399
515
|
} catch (error) {
|
|
400
516
|
await writeTextAtomic(path, source, { mode });
|
|
401
517
|
for (const item of contentFiles) {
|
|
@@ -418,11 +534,17 @@ export function resourcePath(input, model, record) {
|
|
|
418
534
|
}
|
|
419
535
|
|
|
420
536
|
async function writeAtomic(path, value, options = {}) {
|
|
421
|
-
|
|
422
|
-
|
|
537
|
+
return measureTiming("writes", async () => {
|
|
538
|
+
const source = `${JSON.stringify(value, null, 2)}\n`;
|
|
539
|
+
await writeTextAtomicUnmeasured(path, source, options);
|
|
540
|
+
});
|
|
423
541
|
}
|
|
424
542
|
|
|
425
543
|
async function writeTextAtomic(path, source, options = {}) {
|
|
544
|
+
return measureTiming("writes", () => writeTextAtomicUnmeasured(path, source, options));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function writeTextAtomicUnmeasured(path, source, options = {}) {
|
|
426
548
|
await mkdir(dirname(path), { recursive: true });
|
|
427
549
|
const temp = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
428
550
|
let mode = options.mode ?? 0o666;
|
|
@@ -454,7 +576,9 @@ async function writeTextAtomic(path, source, options = {}) {
|
|
|
454
576
|
|
|
455
577
|
function newErrors(after, before) {
|
|
456
578
|
const existing = new Set(before.diagnostics.filter(({ severity }) => severity === "error").map(diagnosticKey));
|
|
457
|
-
return after.diagnostics
|
|
579
|
+
return after.diagnostics
|
|
580
|
+
.filter(({ severity }) => severity === "error")
|
|
581
|
+
.filter((item) => item.code === "unsupported-model" || !existing.has(diagnosticKey(item)));
|
|
458
582
|
}
|
|
459
583
|
|
|
460
584
|
function diagnosticKey(item) {
|
|
@@ -490,6 +614,12 @@ async function prepareContentWrites(loaded, record, content, options = {}) {
|
|
|
490
614
|
try {
|
|
491
615
|
previous = await readFile(path, "utf8");
|
|
492
616
|
if (options.exclusive) throw new Error(`Content already exists at data/${dataRelativePath}.`);
|
|
617
|
+
if (
|
|
618
|
+
options.requireExpectedRevisions
|
|
619
|
+
&& !Object.hasOwn(options.expectedRevisions ?? {}, dataRelativePath)
|
|
620
|
+
) {
|
|
621
|
+
throw new Error(`A content revision is required for existing content at data/${dataRelativePath}.`);
|
|
622
|
+
}
|
|
493
623
|
assertRevision(previous, options.expectedRevisions?.[dataRelativePath], `Content at data/${dataRelativePath}`);
|
|
494
624
|
} catch (error) {
|
|
495
625
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -505,10 +635,82 @@ function assertRevision(source, expected, label) {
|
|
|
505
635
|
}
|
|
506
636
|
}
|
|
507
637
|
|
|
508
|
-
function contentRevision(source) {
|
|
638
|
+
export function contentRevision(source) {
|
|
509
639
|
return createHash("sha256").update(source).digest("hex");
|
|
510
640
|
}
|
|
511
641
|
|
|
642
|
+
async function prepareApprovalBinding(loaded, record, contentWrites, previousRecord = null) {
|
|
643
|
+
if (record.type === "attestation") {
|
|
644
|
+
return prepareAttestationBinding(loaded, record, previousRecord);
|
|
645
|
+
}
|
|
646
|
+
if (!["policy", "document"].includes(record.type)) return record;
|
|
647
|
+
const nextRecord = structuredClone(record);
|
|
648
|
+
if (!approvalBound(record)) {
|
|
649
|
+
delete nextRecord.approvedContentRevisions;
|
|
650
|
+
return nextRecord;
|
|
651
|
+
}
|
|
652
|
+
if (approvalBound(previousRecord) && previousRecord.approvedContentRevisions) {
|
|
653
|
+
nextRecord.approvedContentRevisions = structuredClone(previousRecord.approvedContentRevisions);
|
|
654
|
+
return nextRecord;
|
|
655
|
+
}
|
|
656
|
+
const proposed = new Map(contentWrites.map((item) => [item.dataRelativePath, item.source]));
|
|
657
|
+
const revisions = {};
|
|
658
|
+
for (const item of markdownEntries(loaded.model, nextRecord)) {
|
|
659
|
+
let source = proposed.get(item.path);
|
|
660
|
+
if (source === undefined) {
|
|
661
|
+
try {
|
|
662
|
+
source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
663
|
+
} catch (error) {
|
|
664
|
+
if (error.code === "ENOENT") continue;
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
revisions[item.path] = contentRevision(source);
|
|
669
|
+
}
|
|
670
|
+
nextRecord.approvedContentRevisions = revisions;
|
|
671
|
+
return nextRecord;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function prepareAttestationBinding(loaded, record, previousRecord = null) {
|
|
675
|
+
const nextRecord = structuredClone(record);
|
|
676
|
+
const bound = record.status === "completed" && record.attestationMethod === "git-approval";
|
|
677
|
+
if (!bound) {
|
|
678
|
+
delete nextRecord.contentRevisions;
|
|
679
|
+
return nextRecord;
|
|
680
|
+
}
|
|
681
|
+
if (
|
|
682
|
+
previousRecord?.status === "completed"
|
|
683
|
+
&& previousRecord.attestationMethod === "git-approval"
|
|
684
|
+
&& previousRecord.contentRevisions
|
|
685
|
+
) {
|
|
686
|
+
nextRecord.contentRevisions = structuredClone(previousRecord.contentRevisions);
|
|
687
|
+
return nextRecord;
|
|
688
|
+
}
|
|
689
|
+
const revisions = {};
|
|
690
|
+
for (const id of record.subjectResourceIds || []) {
|
|
691
|
+
const subject = loaded.resources.find((candidate) => candidate.id === id);
|
|
692
|
+
if (!subject) continue;
|
|
693
|
+
for (const item of markdownEntries(loaded.model, subject)) {
|
|
694
|
+
try {
|
|
695
|
+
const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
696
|
+
revisions[item.path] = contentRevision(source);
|
|
697
|
+
} catch (error) {
|
|
698
|
+
if (error.code !== "ENOENT") throw error;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
nextRecord.contentRevisions = revisions;
|
|
703
|
+
return nextRecord;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function approvalBound(record) {
|
|
707
|
+
if (!record || !["policy", "document"].includes(record.type)) return false;
|
|
708
|
+
const statuses = record.type === "policy"
|
|
709
|
+
? ["approved", "active", "superseded", "retired"]
|
|
710
|
+
: ["active", "superseded", "retired"];
|
|
711
|
+
return statuses.includes(record.status);
|
|
712
|
+
}
|
|
713
|
+
|
|
512
714
|
async function exclusiveContentFiles(loaded, record) {
|
|
513
715
|
const candidates = markdownEntries(loaded.model, record).map(({ path }) => path);
|
|
514
716
|
const files = [];
|