filegrc 0.7.0 → 0.8.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 +21 -4
- package/model/v4.json +97 -6
- package/model/v5.json +10233 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +635 -81
- package/src/audit-transition.js +8 -2
- package/src/batch-review.js +21 -11
- package/src/cli.js +117 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +145 -0
- package/src/evidence-packet.js +393 -106
- package/src/external-reviewer.js +5 -4
- package/src/files.js +194 -35
- package/src/git.js +106 -10
- package/src/index.js +10 -1
- package/src/model-migration.js +218 -7
- package/src/obligations.js +14 -11
- package/src/policy-library/information-security-policy-v2.md +290 -0
- package/src/policy-library.js +827 -0
- package/src/program-lifecycle.js +93 -3
- package/src/program-path.js +14 -9
- package/src/program-readiness.js +306 -75
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +29 -7
- package/src/setup.js +25 -5
- package/src/soc2.js +228 -0
- package/src/state.js +8 -0
- package/src/validate.js +168 -14
- package/src/web.js +257 -35
- package/src/workflow.js +107 -44
- package/src/workspace.js +5 -0
package/src/external-reviewer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { modelSupports } from "../model/index.js";
|
|
1
2
|
import { applyResourceBatch } from "./files.js";
|
|
2
3
|
import { createResourceId } from "./id.js";
|
|
3
4
|
import { currentCalendarDate } from "./time.js";
|
|
@@ -5,8 +6,8 @@ import { loadWorkspace } from "./workspace.js";
|
|
|
5
6
|
|
|
6
7
|
export async function scaffoldExternalReviewerGovernance(input = process.cwd()) {
|
|
7
8
|
const loaded = await loadWorkspace(input);
|
|
8
|
-
if (!
|
|
9
|
-
throw new Error("External reviewer setup requires a model v3 or
|
|
9
|
+
if (!modelSupports(loaded.model, "guided-workflow")) {
|
|
10
|
+
throw new Error("External reviewer setup requires a model v3 or newer workspace.");
|
|
10
11
|
}
|
|
11
12
|
return {
|
|
12
13
|
reviewerName: null,
|
|
@@ -22,8 +23,8 @@ export async function scaffoldExternalReviewerGovernance(input = process.cwd())
|
|
|
22
23
|
|
|
23
24
|
export async function planExternalReviewerGovernance(input = process.cwd(), options = {}) {
|
|
24
25
|
const loaded = await loadWorkspace(input);
|
|
25
|
-
if (!
|
|
26
|
-
throw new Error("External reviewer setup requires a model v3 or
|
|
26
|
+
if (!modelSupports(loaded.model, "guided-workflow")) {
|
|
27
|
+
throw new Error("External reviewer setup requires a model v3 or newer workspace.");
|
|
27
28
|
}
|
|
28
29
|
const name = required(options.reviewerName, "External reviewer name");
|
|
29
30
|
const startsOn = required(options.startsOn, "Appointment start date");
|
package/src/files.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
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
|
-
import { getResourceDefinition, loadModel } from "../model/index.js";
|
|
4
|
+
import { getResourceDefinition, loadModel, modelSupports } from "../model/index.js";
|
|
5
5
|
import { openPlaceholderCount } from "./content-readiness.js";
|
|
6
6
|
import { serializeWorkspaceMutation, workspaceValidationDeferred } from "./mutation.js";
|
|
7
7
|
import { isCanonicalDataPath, resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
8
|
+
import { documentIsAuditSpecific } from "./program-lifecycle.js";
|
|
8
9
|
import { markdownEntries } from "./resource-markdown.js";
|
|
9
10
|
import { measureTiming } from "./timing.js";
|
|
10
11
|
import { loadWorkspace } from "./workspace.js";
|
|
@@ -204,17 +205,42 @@ export async function applyResourceBatch(input, changes) {
|
|
|
204
205
|
return serializeWorkspaceMutation(input, (root) => applyResourceBatchUnlocked(root, changes));
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
async function
|
|
208
|
+
export async function applyDocumentActivationBatch(input, changes) {
|
|
209
|
+
return serializeWorkspaceMutation(input, (root) => (
|
|
210
|
+
applyResourceBatchUnlocked(root, changes, "document-activation")
|
|
211
|
+
));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function applyModelMigrationBatch(input, changes) {
|
|
215
|
+
return serializeWorkspaceMutation(input, (root) => (
|
|
216
|
+
applyResourceBatchUnlocked(root, changes, "model-migration")
|
|
217
|
+
));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function applyResourceBatchUnlocked(input, changes = {}, lifecycleOperation = null) {
|
|
208
221
|
const creates = changes.create || [];
|
|
209
222
|
const updates = changes.update || [];
|
|
210
223
|
const moves = changes.movePaths || [];
|
|
224
|
+
const contentUpdates = changes.contentUpdates || {};
|
|
211
225
|
const expectedRevisions = changes.expectedRevisions || {};
|
|
212
|
-
if (
|
|
213
|
-
|
|
226
|
+
if (
|
|
227
|
+
!Array.isArray(creates)
|
|
228
|
+
|| !Array.isArray(updates)
|
|
229
|
+
|| !Array.isArray(moves)
|
|
230
|
+
|| (!creates.length && !updates.length && !moves.length && !Object.keys(contentUpdates).length)
|
|
231
|
+
) {
|
|
232
|
+
throw new Error("A resource batch needs at least one create, update, or content update.");
|
|
214
233
|
}
|
|
215
234
|
if (Array.isArray(expectedRevisions) || typeof expectedRevisions !== "object") {
|
|
216
235
|
throw new Error("Batch expected revisions must be keyed by resource ID.");
|
|
217
236
|
}
|
|
237
|
+
if (Array.isArray(contentUpdates) || typeof contentUpdates !== "object") {
|
|
238
|
+
throw new Error("Batch content updates must be keyed by resource ID.");
|
|
239
|
+
}
|
|
240
|
+
const expectedContentRevisions = changes.expectedContentRevisions || {};
|
|
241
|
+
if (Array.isArray(expectedContentRevisions) || typeof expectedContentRevisions !== "object") {
|
|
242
|
+
throw new Error("Batch expected content revisions must be keyed by resource ID.");
|
|
243
|
+
}
|
|
218
244
|
const loaded = await loadWorkspace(input);
|
|
219
245
|
const workspaceUpdate = updates.find((record) => (
|
|
220
246
|
record.type === "workspace" && record.id === loaded.workspace?.id
|
|
@@ -240,14 +266,35 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
240
266
|
"A cross-model resource batch must validate the whole workspace and update its dataModelVersion to the target model."
|
|
241
267
|
);
|
|
242
268
|
}
|
|
269
|
+
if (lifecycleOperation === "model-migration" && !targetModelVersion) {
|
|
270
|
+
throw new Error("The model-migration lifecycle operation requires a cross-model resource batch.");
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
lifecycleOperation === "document-activation"
|
|
274
|
+
&& (targetModelVersion || changes.validateWholeWorkspace !== true)
|
|
275
|
+
) {
|
|
276
|
+
throw new Error("The document-activation lifecycle operation requires same-model whole-workspace validation.");
|
|
277
|
+
}
|
|
243
278
|
const writeModel = targetModelVersion ? loadModel(targetModelVersion) : loaded.model;
|
|
244
279
|
const deferValidation = workspaceValidationDeferred();
|
|
245
280
|
const before = deferValidation || changes.validateWholeWorkspace
|
|
246
281
|
? null
|
|
247
282
|
: await validateWorkspace(loaded);
|
|
248
283
|
const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
284
|
+
for (const record of updates) {
|
|
285
|
+
if (!record || Array.isArray(record) || typeof record !== "object" || typeof record.id !== "string") continue;
|
|
286
|
+
const existing = existingById.get(record.id);
|
|
287
|
+
if (!existing) continue;
|
|
288
|
+
assertRevision(
|
|
289
|
+
existing.source,
|
|
290
|
+
expectedRevisions[record.id] || existing.revision,
|
|
291
|
+
`Resource "${record.id}"`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
249
294
|
const ids = new Set();
|
|
250
295
|
const writes = [];
|
|
296
|
+
const contentWrites = [];
|
|
297
|
+
const preparedContentIds = new Set();
|
|
251
298
|
const allowedPathMoves = new Set();
|
|
252
299
|
for (const record of creates) {
|
|
253
300
|
validateBatchRecord(record, ids);
|
|
@@ -290,7 +337,30 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
290
337
|
if (error.code !== "ENOENT") throw error;
|
|
291
338
|
}
|
|
292
339
|
}
|
|
293
|
-
|
|
340
|
+
const hasContentUpdate = Object.hasOwn(contentUpdates, record.id);
|
|
341
|
+
const recordContentWrites = await prepareContentWrites(loaded, record, contentUpdates[record.id], {
|
|
342
|
+
expectedRevisions: expectedContentRevisions[record.id],
|
|
343
|
+
requireExpectedRevisions: hasContentUpdate
|
|
344
|
+
});
|
|
345
|
+
if (hasContentUpdate) preparedContentIds.add(record.id);
|
|
346
|
+
contentWrites.push(...recordContentWrites);
|
|
347
|
+
const nextRecord = hasContentUpdate
|
|
348
|
+
? await prepareApprovalBinding(loaded, record, recordContentWrites, existing.record)
|
|
349
|
+
: record;
|
|
350
|
+
assertDocumentLifecycleMutation(existing.record, nextRecord, loaded.model, lifecycleOperation);
|
|
351
|
+
writes.push({ operation: path === previousPath ? "update" : "move-update", path, previousPath, record: nextRecord, previous, fileMode: mode });
|
|
352
|
+
}
|
|
353
|
+
for (const resourceId of Object.keys(contentUpdates)) {
|
|
354
|
+
if (preparedContentIds.has(resourceId)) continue;
|
|
355
|
+
const existing = existingById.get(resourceId);
|
|
356
|
+
if (!existing) throw new Error(`Resource "${resourceId}" was not found.`);
|
|
357
|
+
if (approvalBound(existing.record)) {
|
|
358
|
+
throw new Error(`Batch content for approved or active resource "${resourceId}" needs a matching resource update and validation of its approval binding.`);
|
|
359
|
+
}
|
|
360
|
+
contentWrites.push(...await prepareContentWrites(loaded, existing.record, contentUpdates[resourceId], {
|
|
361
|
+
expectedRevisions: expectedContentRevisions[resourceId],
|
|
362
|
+
requireExpectedRevisions: true
|
|
363
|
+
}));
|
|
294
364
|
}
|
|
295
365
|
const pathMoves = [];
|
|
296
366
|
const seenMovePaths = new Set();
|
|
@@ -317,8 +387,13 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
317
387
|
pathMoves.push({ from, to, mode });
|
|
318
388
|
}
|
|
319
389
|
const written = [];
|
|
390
|
+
const writtenContent = [];
|
|
320
391
|
const moved = [];
|
|
321
392
|
try {
|
|
393
|
+
for (const item of contentWrites) {
|
|
394
|
+
await writeTextAtomic(item.path, item.source);
|
|
395
|
+
writtenContent.push(item);
|
|
396
|
+
}
|
|
322
397
|
for (const item of writes) {
|
|
323
398
|
await writeAtomic(item.path, item.record, { exclusive: item.operation === "create" });
|
|
324
399
|
written.push(item);
|
|
@@ -363,6 +438,14 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
363
438
|
rollbackErrors.push(rollbackError.message);
|
|
364
439
|
}
|
|
365
440
|
}
|
|
441
|
+
for (const item of writtenContent.reverse()) {
|
|
442
|
+
try {
|
|
443
|
+
if (item.previous === null) await rm(item.path, { force: true });
|
|
444
|
+
else await writeTextAtomic(item.path, item.previous);
|
|
445
|
+
} catch (rollbackError) {
|
|
446
|
+
rollbackErrors.push(rollbackError.message);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
366
449
|
if (rollbackErrors.length) {
|
|
367
450
|
throw new Error(`${error.message} FileGRC could not restore every file in the resource batch: ${rollbackErrors.join(" ")}`);
|
|
368
451
|
}
|
|
@@ -524,6 +607,7 @@ async function updateResourceUnlocked(input, type, id, record, options) {
|
|
|
524
607
|
});
|
|
525
608
|
const existing = loaded.entries.find(({ record: candidate }) => candidate.id === id)?.record;
|
|
526
609
|
const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites, existing);
|
|
610
|
+
assertDocumentLifecycleMutation(existing, nextRecord, loaded.model, null);
|
|
527
611
|
try {
|
|
528
612
|
for (const item of contentWrites) await writeTextAtomic(item.path, item.source);
|
|
529
613
|
await writeAtomic(path, nextRecord);
|
|
@@ -733,39 +817,97 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec
|
|
|
733
817
|
if (record.type === "attestation") {
|
|
734
818
|
return prepareAttestationBinding(loaded, record, previousRecord);
|
|
735
819
|
}
|
|
736
|
-
const
|
|
737
|
-
if (!
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
820
|
+
const bindingFields = contentBindingFields(record, loaded.model);
|
|
821
|
+
if (!bindingFields.length) return record;
|
|
822
|
+
if (
|
|
823
|
+
record.type === "document"
|
|
824
|
+
&& modelSupports(loaded.model, "governed-document-activation")
|
|
825
|
+
&& record.status === "active"
|
|
826
|
+
&& previousRecord?.status !== "active"
|
|
827
|
+
) {
|
|
828
|
+
const step = documentIsAuditSpecific(record, loaded.model) ? "Step 5" : "Step 3";
|
|
829
|
+
throw new Error(`Document "${record.id}" must use the dedicated ${step} Document activation operation after approval.`);
|
|
746
830
|
}
|
|
831
|
+
const nextRecord = structuredClone(record);
|
|
747
832
|
const proposed = new Map(contentWrites.map((item) => [item.dataRelativePath, item.source]));
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
try {
|
|
753
|
-
source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
754
|
-
} catch (error) {
|
|
755
|
-
if (error.code === "ENOENT") continue;
|
|
756
|
-
throw error;
|
|
757
|
-
}
|
|
833
|
+
for (const { field, bound, label } of bindingFields) {
|
|
834
|
+
if (!bound(record)) {
|
|
835
|
+
delete nextRecord[field];
|
|
836
|
+
continue;
|
|
758
837
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
838
|
+
if (bound(previousRecord) && previousRecord[field]) {
|
|
839
|
+
nextRecord[field] = structuredClone(previousRecord[field]);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
const revisions = {};
|
|
843
|
+
for (const item of markdownEntries(loaded.model, nextRecord)) {
|
|
844
|
+
let source = proposed.get(item.path);
|
|
845
|
+
if (source === undefined) {
|
|
846
|
+
try {
|
|
847
|
+
source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
848
|
+
} catch (error) {
|
|
849
|
+
if (error.code === "ENOENT") continue;
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const placeholders = openPlaceholderCount(source);
|
|
854
|
+
if (placeholders) {
|
|
855
|
+
throw new Error(`Cannot ${label} ${record.title} while its ${item.label} Markdown contains ${placeholders} open ${placeholders === 1 ? "placeholder" : "placeholders"}. Complete the facts and review the exact content first.`);
|
|
856
|
+
}
|
|
857
|
+
revisions[item.path] = contentRevision(source);
|
|
762
858
|
}
|
|
763
|
-
|
|
859
|
+
nextRecord[field] = revisions;
|
|
764
860
|
}
|
|
765
|
-
nextRecord[bindingField] = revisions;
|
|
766
861
|
return nextRecord;
|
|
767
862
|
}
|
|
768
863
|
|
|
864
|
+
function assertDocumentLifecycleMutation(previousRecord, nextRecord, model, lifecycleOperation) {
|
|
865
|
+
if (lifecycleOperation === "model-migration") return;
|
|
866
|
+
if (
|
|
867
|
+
!previousRecord
|
|
868
|
+
|| previousRecord.type !== "document"
|
|
869
|
+
|| nextRecord?.type !== "document"
|
|
870
|
+
|| !modelSupports(model, "governed-document-activation")
|
|
871
|
+
) return;
|
|
872
|
+
const approvedStatuses = new Set(["approved", "active", "superseded", "retired"]);
|
|
873
|
+
const activatedStatuses = new Set(["active", "superseded", "retired"]);
|
|
874
|
+
if (approvedStatuses.has(previousRecord.status) && approvedStatuses.has(nextRecord.status)) {
|
|
875
|
+
assertLifecycleFieldsUnchanged(previousRecord, nextRecord, [
|
|
876
|
+
"approverIds",
|
|
877
|
+
"approvedOn",
|
|
878
|
+
"approvedContentRevisions"
|
|
879
|
+
], "approval");
|
|
880
|
+
}
|
|
881
|
+
if (activatedStatuses.has(previousRecord.status) && activatedStatuses.has(nextRecord.status)) {
|
|
882
|
+
assertLifecycleFieldsUnchanged(previousRecord, nextRecord, [
|
|
883
|
+
"activationBasis",
|
|
884
|
+
"activatedByIds",
|
|
885
|
+
"activatedOn",
|
|
886
|
+
"activatedContentRevisions",
|
|
887
|
+
"effectiveOn"
|
|
888
|
+
], "activation");
|
|
889
|
+
}
|
|
890
|
+
if (
|
|
891
|
+
nextRecord.status === "active"
|
|
892
|
+
&& previousRecord.status !== "active"
|
|
893
|
+
&& lifecycleOperation !== "document-activation"
|
|
894
|
+
) {
|
|
895
|
+
const step = documentIsAuditSpecific(nextRecord, model) ? "Step 5" : "Step 3";
|
|
896
|
+
throw new Error(`Document "${nextRecord.id}" must use the dedicated ${step} Document activation operation after approval.`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function assertLifecycleFieldsUnchanged(previousRecord, nextRecord, fields, eventLabel) {
|
|
901
|
+
const changed = fields.filter((field) => (
|
|
902
|
+
JSON.stringify(previousRecord[field] ?? null) !== JSON.stringify(nextRecord[field] ?? null)
|
|
903
|
+
));
|
|
904
|
+
if (!changed.length) return;
|
|
905
|
+
throw new Error(
|
|
906
|
+
`Document "${nextRecord.id}" ${eventLabel} facts are immutable after the event: ${changed.join(", ")}. `
|
|
907
|
+
+ `Move the Document back to ${eventLabel === "approval" ? "draft" : "approved"} and record a new lifecycle event.`
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
|
|
769
911
|
async function prepareAttestationBinding(loaded, record, previousRecord = null) {
|
|
770
912
|
const nextRecord = structuredClone(record);
|
|
771
913
|
const bound = record.status === "completed" && record.attestationMethod === "git-approval";
|
|
@@ -803,17 +945,34 @@ function approvalBound(record) {
|
|
|
803
945
|
const statuses = record.type === "policy"
|
|
804
946
|
? ["approved", "active", "superseded", "retired"]
|
|
805
947
|
: record.type === "document"
|
|
806
|
-
? ["active", "superseded", "retired"]
|
|
948
|
+
? ["approved", "active", "superseded", "retired"]
|
|
807
949
|
: ["active", "retired"];
|
|
808
950
|
return statuses.includes(record.status);
|
|
809
951
|
}
|
|
810
952
|
|
|
811
|
-
function
|
|
812
|
-
|
|
953
|
+
function contentBindingFields(record, model) {
|
|
954
|
+
const fields = [];
|
|
955
|
+
if (["policy", "document"].includes(record?.type)) {
|
|
956
|
+
fields.push({
|
|
957
|
+
field: "approvedContentRevisions",
|
|
958
|
+
bound: approvalBound,
|
|
959
|
+
label: record.type === "policy" ? "approve or activate" : "approve"
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
if (record?.type === "document" && model.resources.document?.fields?.activatedContentRevisions) {
|
|
963
|
+
fields.push({
|
|
964
|
+
field: "activatedContentRevisions",
|
|
965
|
+
bound: (candidate) => (
|
|
966
|
+
["active", "superseded", "retired"].includes(candidate?.status)
|
|
967
|
+
&& candidate.activationBasis === "recorded"
|
|
968
|
+
),
|
|
969
|
+
label: "activate"
|
|
970
|
+
});
|
|
971
|
+
}
|
|
813
972
|
if (record?.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
|
|
814
|
-
|
|
973
|
+
fields.push({ field: "effectiveContentRevisions", bound: approvalBound, label: "approve or activate" });
|
|
815
974
|
}
|
|
816
|
-
return
|
|
975
|
+
return fields;
|
|
817
976
|
}
|
|
818
977
|
|
|
819
978
|
async function exclusiveContentFiles(loaded, record) {
|
package/src/git.js
CHANGED
|
@@ -6,7 +6,7 @@ import { relative, resolve, sep } from "node:path";
|
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
7
|
import { isSafeGitName } from "./git-name.js";
|
|
8
8
|
import { serializeWorkspaceMutation, withDeferredWorkspaceValidation } from "./mutation.js";
|
|
9
|
-
import { resolveWorkspaceRoot } from "./paths.js";
|
|
9
|
+
import { isCanonicalDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
10
10
|
import { measureTiming, measureTimingSync, recordTiming, timingEnabled } from "./timing.js";
|
|
11
11
|
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
12
12
|
import { loadWorkspace } from "./workspace.js";
|
|
@@ -75,6 +75,7 @@ export function getGitSummary(input = process.cwd()) {
|
|
|
75
75
|
|
|
76
76
|
export function getFileHistory(input, relativePath, limit = 50) {
|
|
77
77
|
const root = resolveWorkspaceRoot(input);
|
|
78
|
+
if (!isSafeDataGitPath(relativePath)) return null;
|
|
78
79
|
try {
|
|
79
80
|
const output = git(root, [
|
|
80
81
|
"log",
|
|
@@ -87,11 +88,11 @@ export function getFileHistory(input, relativePath, limit = 50) {
|
|
|
87
88
|
if (!output) return [];
|
|
88
89
|
return output.split("\n").map(parseLogLine);
|
|
89
90
|
} catch {
|
|
90
|
-
return
|
|
91
|
+
return null;
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12) {
|
|
95
|
+
export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, options = {}) {
|
|
95
96
|
const root = resolveWorkspaceRoot(input);
|
|
96
97
|
const wanted = new Set(relativePaths);
|
|
97
98
|
const histories = new Map([...wanted].map((path) => [path, []]));
|
|
@@ -99,10 +100,14 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12) {
|
|
|
99
100
|
const head = tryGit(root, ["rev-parse", "HEAD"]) || null;
|
|
100
101
|
const cached = workspaceHistoryCache.get(root);
|
|
101
102
|
if (cached?.head === head && cached.limitPerFile === limitPerFile) {
|
|
103
|
+
if (options.strict === true && cached.available === false) {
|
|
104
|
+
throw new Error("Git history is unavailable for the requested workspace files.");
|
|
105
|
+
}
|
|
102
106
|
for (const path of wanted) histories.set(path, cached.histories.get(path) ?? []);
|
|
103
107
|
return histories;
|
|
104
108
|
}
|
|
105
109
|
const allHistories = new Map();
|
|
110
|
+
let available = true;
|
|
106
111
|
try {
|
|
107
112
|
const output = git(root, ["log", "--relative", "--format=%x1e%H%x1f%aI%x1f%an%x1f%s", "--name-only", "--", "data"]);
|
|
108
113
|
for (const block of output.split("\x1e")) {
|
|
@@ -116,26 +121,112 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12) {
|
|
|
116
121
|
}
|
|
117
122
|
}
|
|
118
123
|
} catch {
|
|
119
|
-
|
|
124
|
+
available = false;
|
|
125
|
+
if (options.strict === true) {
|
|
126
|
+
throw new Error("Git history is unavailable for the requested workspace files.");
|
|
127
|
+
}
|
|
128
|
+
// Browser and workflow views tolerate an uncommitted workspace with no history yet.
|
|
120
129
|
}
|
|
121
|
-
workspaceHistoryCache.set(root, { head, limitPerFile, histories: allHistories });
|
|
130
|
+
workspaceHistoryCache.set(root, { head, limitPerFile, histories: allHistories, available });
|
|
122
131
|
for (const path of wanted) histories.set(path, allHistories.get(path) ?? []);
|
|
123
132
|
return histories;
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
export function getFileAtRevision(input, revision, relativePath) {
|
|
136
|
+
return getFilesAtRevisions(input, [{ revision, relativePath }])[0];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getFilesAtRevisions(input, requests) {
|
|
127
140
|
const root = resolveWorkspaceRoot(input);
|
|
128
|
-
|
|
141
|
+
const invalid = Array.isArray(requests) && requests.find(({ revision, relativePath } = {}) => (
|
|
142
|
+
!/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)
|
|
143
|
+
));
|
|
144
|
+
if (!Array.isArray(requests) || invalid) {
|
|
129
145
|
throw new Error("Historical file exports require a Git commit and a data/ path.");
|
|
130
146
|
}
|
|
147
|
+
if (!requests.length) return [];
|
|
148
|
+
try {
|
|
149
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
150
|
+
const workspacePrefix = relative(topLevel, root).split(sep).join("/");
|
|
151
|
+
if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return requests.map(() => null);
|
|
152
|
+
const results = [];
|
|
153
|
+
for (let offset = 0; offset < requests.length; offset += 4) {
|
|
154
|
+
const batch = requests.slice(offset, offset + 4);
|
|
155
|
+
const specifications = batch.map(({ revision, relativePath }) => {
|
|
156
|
+
const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
|
|
157
|
+
return `${revision}:${repositoryPath}`;
|
|
158
|
+
});
|
|
159
|
+
try {
|
|
160
|
+
const output = measureTimingSync("git-history-export", () => execFileSync("git", ["cat-file", "--batch"], {
|
|
161
|
+
cwd: root,
|
|
162
|
+
input: `${specifications.join("\n")}\n`,
|
|
163
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
164
|
+
timeout: 10_000,
|
|
165
|
+
maxBuffer: 80_000_000
|
|
166
|
+
}));
|
|
167
|
+
results.push(...parseBatchObjects(output, batch.length));
|
|
168
|
+
} catch {
|
|
169
|
+
results.push(...specifications.map((specification) => readHistoricalFile(root, specification)));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return results;
|
|
173
|
+
} catch {
|
|
174
|
+
return requests.map(() => null);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function readHistoricalFile(root, specification) {
|
|
131
179
|
try {
|
|
132
|
-
return execFileSync("git", ["show",
|
|
180
|
+
return measureTimingSync("git-history-export", () => execFileSync("git", ["show", specification], {
|
|
133
181
|
cwd: root,
|
|
134
182
|
encoding: "utf8",
|
|
135
183
|
stdio: ["ignore", "pipe", "ignore"],
|
|
136
184
|
timeout: 10_000,
|
|
137
185
|
maxBuffer: 20_000_000
|
|
138
|
-
});
|
|
186
|
+
}));
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseBatchObjects(output, expected) {
|
|
193
|
+
const results = [];
|
|
194
|
+
let offset = 0;
|
|
195
|
+
for (let index = 0; index < expected; index += 1) {
|
|
196
|
+
const headerEnd = output.indexOf(10, offset);
|
|
197
|
+
if (headerEnd < 0) throw new Error("Git returned an incomplete historical object header.");
|
|
198
|
+
const header = output.subarray(offset, headerEnd).toString("utf8");
|
|
199
|
+
offset = headerEnd + 1;
|
|
200
|
+
if (header.endsWith(" missing")) {
|
|
201
|
+
results.push(null);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const size = Number(header.split(" ").at(-1));
|
|
205
|
+
if (!Number.isSafeInteger(size) || size < 0 || offset + size >= output.length) {
|
|
206
|
+
throw new Error("Git returned an invalid historical object size.");
|
|
207
|
+
}
|
|
208
|
+
results.push(output.subarray(offset, offset + size).toString("utf8"));
|
|
209
|
+
offset += size;
|
|
210
|
+
if (output[offset++] !== 10) throw new Error("Git returned an incomplete historical object.");
|
|
211
|
+
}
|
|
212
|
+
return results;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isSafeDataGitPath(value) {
|
|
216
|
+
return isCanonicalDataPath(value)
|
|
217
|
+
&& !/[\r\n]/.test(value)
|
|
218
|
+
&& value.startsWith("data/")
|
|
219
|
+
&& value !== "data/";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function getChangedDataPathsSinceRevision(input, revision) {
|
|
223
|
+
if (!/^[a-f0-9]{40}$/i.test(String(revision))) return null;
|
|
224
|
+
const root = resolveWorkspaceRoot(input);
|
|
225
|
+
try {
|
|
226
|
+
return [...new Set([
|
|
227
|
+
...lines(git(root, ["diff", "--name-only", "--relative", revision, "--", "data"])),
|
|
228
|
+
...lines(git(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
|
|
229
|
+
])].filter((path) => path.startsWith("data/"));
|
|
139
230
|
} catch {
|
|
140
231
|
return null;
|
|
141
232
|
}
|
|
@@ -198,6 +289,7 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
|
|
|
198
289
|
available: true,
|
|
199
290
|
commit: parsed.commit,
|
|
200
291
|
shortCommit: parsed.commit?.slice(0, 8) ?? "no commits",
|
|
292
|
+
branch: parsed.branch,
|
|
201
293
|
clean: parsed.changePaths.length === 0,
|
|
202
294
|
changes: parsed.changePaths,
|
|
203
295
|
workspaceChangePaths: parsed.changePaths
|
|
@@ -1089,11 +1181,14 @@ function parsePorcelainV2(source, topLevel, root) {
|
|
|
1089
1181
|
function parseWorkspaceRevision(source) {
|
|
1090
1182
|
const fields = source.split("\0").filter(Boolean);
|
|
1091
1183
|
let commit = null;
|
|
1184
|
+
let branch = null;
|
|
1092
1185
|
const changePaths = [];
|
|
1093
1186
|
for (let index = 0; index < fields.length; index += 1) {
|
|
1094
1187
|
const field = fields[index];
|
|
1095
1188
|
if (field.startsWith("# branch.oid ")) {
|
|
1096
1189
|
commit = field.slice(13) === "(initial)" ? null : field.slice(13);
|
|
1190
|
+
} else if (field.startsWith("# branch.head ")) {
|
|
1191
|
+
branch = field.slice(14) === "(detached)" ? null : field.slice(14);
|
|
1097
1192
|
} else if (/^[12u?!] /.test(field)) {
|
|
1098
1193
|
changePaths.push(porcelainV2Path(field));
|
|
1099
1194
|
if (field.startsWith("2 ")) {
|
|
@@ -1103,6 +1198,7 @@ function parseWorkspaceRevision(source) {
|
|
|
1103
1198
|
}
|
|
1104
1199
|
return {
|
|
1105
1200
|
commit,
|
|
1201
|
+
branch,
|
|
1106
1202
|
changePaths: [...new Set(changePaths.filter(Boolean))].sort()
|
|
1107
1203
|
};
|
|
1108
1204
|
}
|
|
@@ -1258,13 +1354,13 @@ async function tryGitAsync(cwd, args, operation) {
|
|
|
1258
1354
|
}
|
|
1259
1355
|
|
|
1260
1356
|
function git(cwd, args) {
|
|
1261
|
-
return execFileSync("git", args, {
|
|
1357
|
+
return measureTimingSync("git-command-sync", () => execFileSync("git", args, {
|
|
1262
1358
|
cwd,
|
|
1263
1359
|
encoding: "utf8",
|
|
1264
1360
|
stdio: ["ignore", "pipe", "ignore"],
|
|
1265
1361
|
timeout: 10_000,
|
|
1266
1362
|
maxBuffer: 20_000_000
|
|
1267
|
-
}).trim();
|
|
1363
|
+
}).trim());
|
|
1268
1364
|
}
|
|
1269
1365
|
|
|
1270
1366
|
function tryGit(cwd, args) {
|
package/src/index.js
CHANGED
|
@@ -2,10 +2,12 @@ export {
|
|
|
2
2
|
ACTIVE_MODEL_VERSION,
|
|
3
3
|
getResourceDefinition,
|
|
4
4
|
loadModel,
|
|
5
|
+
MODEL_CAPABILITY_VERSIONS,
|
|
6
|
+
modelSupports,
|
|
5
7
|
SUPPORTED_MODEL_VERSIONS
|
|
6
8
|
} from "../model/index.js";
|
|
7
9
|
export { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldResourceMutation } from "./agent.js";
|
|
8
|
-
export { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
|
|
10
|
+
export { assessAuditDocumentActivations, assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
|
|
9
11
|
export { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
|
|
10
12
|
export {
|
|
11
13
|
applyApplicabilityReview,
|
|
@@ -52,6 +54,13 @@ export { generateModelDocumentation } from "./model-docs.js";
|
|
|
52
54
|
export { renderMarkdown } from "./markdown.js";
|
|
53
55
|
export { migrateModel, planModelMigration } from "./model-migration.js";
|
|
54
56
|
export { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from "./policy-activation.js";
|
|
57
|
+
export { activateDocuments, planDocumentActivation, scaffoldDocumentActivation } from "./document-activation.js";
|
|
58
|
+
export {
|
|
59
|
+
applyPolicyLibraryUpgrade,
|
|
60
|
+
assessPolicyLibraryUpgrades,
|
|
61
|
+
INFORMATION_SECURITY_LIBRARY_PROPOSAL_ID,
|
|
62
|
+
STRONG_AUTHENTICATION_LIBRARY_PROPOSAL_ID
|
|
63
|
+
} from "./policy-library.js";
|
|
55
64
|
export {
|
|
56
65
|
completeObligationAction,
|
|
57
66
|
completeObligationEvent,
|