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.
@@ -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 (!["3", "4"].includes(String(loaded.model.modelVersion))) {
9
- throw new Error("External reviewer setup requires a model v3 or v4 workspace.");
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 (!["3", "4"].includes(String(loaded.model.modelVersion))) {
26
- throw new Error("External reviewer setup requires a model v3 or v4 workspace.");
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 applyResourceBatchUnlocked(input, changes = {}) {
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 (!Array.isArray(creates) || !Array.isArray(updates) || !Array.isArray(moves) || (!creates.length && !updates.length && !moves.length)) {
213
- throw new Error("A resource batch needs at least one create or update.");
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
- writes.push({ operation: path === previousPath ? "update" : "move-update", path, previousPath, record, previous, fileMode: mode });
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 bindingField = approvalBindingField(record, loaded.model);
737
- if (!bindingField) return record;
738
- const nextRecord = structuredClone(record);
739
- if (!approvalBound(record)) {
740
- delete nextRecord[bindingField];
741
- return nextRecord;
742
- }
743
- if (approvalBound(previousRecord) && previousRecord[bindingField]) {
744
- nextRecord[bindingField] = structuredClone(previousRecord[bindingField]);
745
- return nextRecord;
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 revisions = {};
749
- for (const item of markdownEntries(loaded.model, nextRecord)) {
750
- let source = proposed.get(item.path);
751
- if (source === undefined) {
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
- const placeholders = openPlaceholderCount(source);
760
- if (placeholders) {
761
- throw new Error(`Cannot approve or activate ${record.title} while its ${item.label} Markdown contains ${placeholders} open ${placeholders === 1 ? "placeholder" : "placeholders"}. Complete the facts and review the exact content first.`);
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
- revisions[item.path] = contentRevision(source);
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 approvalBindingField(record, model) {
812
- if (["policy", "document"].includes(record?.type)) return "approvedContentRevisions";
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
- return "effectiveContentRevisions";
973
+ fields.push({ field: "effectiveContentRevisions", bound: approvalBound, label: "approve or activate" });
815
974
  }
816
- return null;
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
- // An uncommitted workspace has no history yet.
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
- if (!/^[a-f0-9]{40}$/i.test(String(revision)) || typeof relativePath !== "string" || !relativePath.startsWith("data/")) {
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", `${revision}:${relativePath}`], {
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,