filegrc 0.7.1 → 0.9.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/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,7 +205,25 @@ 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 applyGovernedContentActivationBatch(input, changes) {
215
+ return serializeWorkspaceMutation(input, (root) => (
216
+ applyResourceBatchUnlocked(root, changes, "governed-content-activation")
217
+ ));
218
+ }
219
+
220
+ export async function applyModelMigrationBatch(input, changes) {
221
+ return serializeWorkspaceMutation(input, (root) => (
222
+ applyResourceBatchUnlocked(root, changes, "model-migration")
223
+ ));
224
+ }
225
+
226
+ async function applyResourceBatchUnlocked(input, changes = {}, lifecycleOperation = null) {
208
227
  const creates = changes.create || [];
209
228
  const updates = changes.update || [];
210
229
  const moves = changes.movePaths || [];
@@ -253,12 +272,31 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
253
272
  "A cross-model resource batch must validate the whole workspace and update its dataModelVersion to the target model."
254
273
  );
255
274
  }
275
+ if (lifecycleOperation === "model-migration" && !targetModelVersion) {
276
+ throw new Error("The model-migration lifecycle operation requires a cross-model resource batch.");
277
+ }
278
+ if (
279
+ ["document-activation", "governed-content-activation"].includes(lifecycleOperation)
280
+ && (targetModelVersion || changes.validateWholeWorkspace !== true)
281
+ ) {
282
+ throw new Error("The governed-content activation lifecycle operation requires same-model whole-workspace validation.");
283
+ }
256
284
  const writeModel = targetModelVersion ? loadModel(targetModelVersion) : loaded.model;
257
285
  const deferValidation = workspaceValidationDeferred();
258
286
  const before = deferValidation || changes.validateWholeWorkspace
259
287
  ? null
260
288
  : await validateWorkspace(loaded);
261
289
  const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
290
+ for (const record of updates) {
291
+ if (!record || Array.isArray(record) || typeof record !== "object" || typeof record.id !== "string") continue;
292
+ const existing = existingById.get(record.id);
293
+ if (!existing) continue;
294
+ assertRevision(
295
+ existing.source,
296
+ expectedRevisions[record.id] || existing.revision,
297
+ `Resource "${record.id}"`
298
+ );
299
+ }
262
300
  const ids = new Set();
263
301
  const writes = [];
264
302
  const contentWrites = [];
@@ -315,13 +353,14 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
315
353
  const nextRecord = hasContentUpdate
316
354
  ? await prepareApprovalBinding(loaded, record, recordContentWrites, existing.record)
317
355
  : record;
356
+ assertGovernedContentLifecycleMutation(existing.record, nextRecord, loaded.model, lifecycleOperation);
318
357
  writes.push({ operation: path === previousPath ? "update" : "move-update", path, previousPath, record: nextRecord, previous, fileMode: mode });
319
358
  }
320
359
  for (const resourceId of Object.keys(contentUpdates)) {
321
360
  if (preparedContentIds.has(resourceId)) continue;
322
361
  const existing = existingById.get(resourceId);
323
362
  if (!existing) throw new Error(`Resource "${resourceId}" was not found.`);
324
- if (approvalBound(existing.record)) {
363
+ if (approvalBound(existing.record, loaded.model)) {
325
364
  throw new Error(`Batch content for approved or active resource "${resourceId}" needs a matching resource update and validation of its approval binding.`);
326
365
  }
327
366
  contentWrites.push(...await prepareContentWrites(loaded, existing.record, contentUpdates[resourceId], {
@@ -574,6 +613,7 @@ async function updateResourceUnlocked(input, type, id, record, options) {
574
613
  });
575
614
  const existing = loaded.entries.find(({ record: candidate }) => candidate.id === id)?.record;
576
615
  const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites, existing);
616
+ assertGovernedContentLifecycleMutation(existing, nextRecord, loaded.model, null);
577
617
  try {
578
618
  for (const item of contentWrites) await writeTextAtomic(item.path, item.source);
579
619
  await writeAtomic(path, nextRecord);
@@ -783,39 +823,105 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec
783
823
  if (record.type === "attestation") {
784
824
  return prepareAttestationBinding(loaded, record, previousRecord);
785
825
  }
786
- const bindingField = approvalBindingField(record, loaded.model);
787
- if (!bindingField) return record;
788
- const nextRecord = structuredClone(record);
789
- if (!approvalBound(record)) {
790
- delete nextRecord[bindingField];
791
- return nextRecord;
792
- }
793
- if (approvalBound(previousRecord) && previousRecord[bindingField]) {
794
- nextRecord[bindingField] = structuredClone(previousRecord[bindingField]);
795
- return nextRecord;
826
+ const bindingFields = contentBindingFields(record, loaded.model);
827
+ if (!bindingFields.length) return record;
828
+ if (
829
+ (
830
+ record.type === "document" && modelSupports(loaded.model, "governed-document-activation")
831
+ || record.type === "training" && modelSupports(loaded.model, "governed-training-activation")
832
+ )
833
+ && record.status === "active"
834
+ && previousRecord?.status !== "active"
835
+ ) {
836
+ const step = record.type === "document" && documentIsAuditSpecific(record, loaded.model) ? "Step 5" : "Step 3";
837
+ const title = getResourceDefinition(loaded.model, record.type).title;
838
+ throw new Error(`${title} "${record.id}" must use the dedicated ${step} ${title} activation operation after approval.`);
796
839
  }
840
+ const nextRecord = structuredClone(record);
797
841
  const proposed = new Map(contentWrites.map((item) => [item.dataRelativePath, item.source]));
798
- const revisions = {};
799
- for (const item of markdownEntries(loaded.model, nextRecord)) {
800
- let source = proposed.get(item.path);
801
- if (source === undefined) {
802
- try {
803
- source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
804
- } catch (error) {
805
- if (error.code === "ENOENT") continue;
806
- throw error;
807
- }
842
+ for (const { field, bound, label } of bindingFields) {
843
+ if (!bound(record)) {
844
+ delete nextRecord[field];
845
+ continue;
846
+ }
847
+ if (bound(previousRecord) && previousRecord[field]) {
848
+ nextRecord[field] = structuredClone(previousRecord[field]);
849
+ continue;
808
850
  }
809
- const placeholders = openPlaceholderCount(source);
810
- if (placeholders) {
811
- 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.`);
851
+ const revisions = {};
852
+ for (const item of markdownEntries(loaded.model, nextRecord)) {
853
+ let source = proposed.get(item.path);
854
+ if (source === undefined) {
855
+ try {
856
+ source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
857
+ } catch (error) {
858
+ if (error.code === "ENOENT") continue;
859
+ throw error;
860
+ }
861
+ }
862
+ const placeholders = openPlaceholderCount(source);
863
+ if (placeholders) {
864
+ 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.`);
865
+ }
866
+ revisions[item.path] = contentRevision(source);
812
867
  }
813
- revisions[item.path] = contentRevision(source);
868
+ nextRecord[field] = revisions;
814
869
  }
815
- nextRecord[bindingField] = revisions;
816
870
  return nextRecord;
817
871
  }
818
872
 
873
+ function assertGovernedContentLifecycleMutation(previousRecord, nextRecord, model, lifecycleOperation) {
874
+ if (lifecycleOperation === "model-migration") return;
875
+ const governedTraining = previousRecord?.type === "training"
876
+ && nextRecord?.type === "training"
877
+ && modelSupports(model, "governed-training-activation");
878
+ const governedDocument = previousRecord?.type === "document"
879
+ && nextRecord?.type === "document"
880
+ && modelSupports(model, "governed-document-activation");
881
+ if (
882
+ !previousRecord
883
+ || (!governedDocument && !governedTraining)
884
+ ) return;
885
+ const title = getResourceDefinition(model, nextRecord.type).title;
886
+ const approvedStatuses = new Set(["approved", "active", "superseded", "retired"]);
887
+ const activatedStatuses = new Set(["active", "superseded", "retired"]);
888
+ if (approvedStatuses.has(previousRecord.status) && approvedStatuses.has(nextRecord.status)) {
889
+ assertLifecycleFieldsUnchanged(previousRecord, nextRecord, [
890
+ "approverIds",
891
+ "approvedOn",
892
+ "approvedContentRevisions"
893
+ ], "approval", title);
894
+ }
895
+ if (activatedStatuses.has(previousRecord.status) && activatedStatuses.has(nextRecord.status)) {
896
+ assertLifecycleFieldsUnchanged(previousRecord, nextRecord, [
897
+ "activationBasis",
898
+ "activatedByIds",
899
+ "activatedOn",
900
+ "activatedContentRevisions",
901
+ "effectiveOn"
902
+ ], "activation", title);
903
+ }
904
+ if (
905
+ nextRecord.status === "active"
906
+ && previousRecord.status !== "active"
907
+ && !["document-activation", "governed-content-activation"].includes(lifecycleOperation)
908
+ ) {
909
+ const step = governedDocument && documentIsAuditSpecific(nextRecord, model) ? "Step 5" : "Step 3";
910
+ throw new Error(`${title} "${nextRecord.id}" must use the dedicated ${step} ${title} activation operation after approval.`);
911
+ }
912
+ }
913
+
914
+ function assertLifecycleFieldsUnchanged(previousRecord, nextRecord, fields, eventLabel, resourceTitle) {
915
+ const changed = fields.filter((field) => (
916
+ JSON.stringify(previousRecord[field] ?? null) !== JSON.stringify(nextRecord[field] ?? null)
917
+ ));
918
+ if (!changed.length) return;
919
+ throw new Error(
920
+ `${resourceTitle} "${nextRecord.id}" ${eventLabel} facts are immutable after the event: ${changed.join(", ")}. `
921
+ + `Move the ${resourceTitle} back to ${eventLabel === "approval" ? "draft" : "approved"} and record a new lifecycle event.`
922
+ );
923
+ }
924
+
819
925
  async function prepareAttestationBinding(loaded, record, previousRecord = null) {
820
926
  const nextRecord = structuredClone(record);
821
927
  const bound = record.status === "completed" && record.attestationMethod === "git-approval";
@@ -848,22 +954,54 @@ async function prepareAttestationBinding(loaded, record, previousRecord = null)
848
954
  return nextRecord;
849
955
  }
850
956
 
851
- function approvalBound(record) {
957
+ function approvalBound(record, model) {
852
958
  if (!record || !["policy", "document", "training"].includes(record.type)) return false;
853
959
  const statuses = record.type === "policy"
854
960
  ? ["approved", "active", "superseded", "retired"]
855
961
  : record.type === "document"
856
- ? ["active", "superseded", "retired"]
857
- : ["active", "retired"];
962
+ ? ["approved", "active", "superseded", "retired"]
963
+ : modelSupports(model || 0, "governed-training-activation")
964
+ ? ["approved", "active", "superseded", "retired"]
965
+ : ["active", "retired"];
858
966
  return statuses.includes(record.status);
859
967
  }
860
968
 
861
- function approvalBindingField(record, model) {
862
- if (["policy", "document"].includes(record?.type)) return "approvedContentRevisions";
969
+ function contentBindingFields(record, model) {
970
+ const fields = [];
971
+ if (["policy", "document"].includes(record?.type)) {
972
+ fields.push({
973
+ field: "approvedContentRevisions",
974
+ bound: (candidate) => approvalBound(candidate, model),
975
+ label: record.type === "policy" ? "approve or activate" : "approve"
976
+ });
977
+ }
978
+ if (record?.type === "document" && model.resources.document?.fields?.activatedContentRevisions) {
979
+ fields.push({
980
+ field: "activatedContentRevisions",
981
+ bound: (candidate) => (
982
+ ["active", "superseded", "retired"].includes(candidate?.status)
983
+ && candidate.activationBasis === "recorded"
984
+ ),
985
+ label: "activate"
986
+ });
987
+ }
863
988
  if (record?.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
864
- return "effectiveContentRevisions";
989
+ fields.push({ field: "effectiveContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "approve or activate" });
990
+ }
991
+ if (record?.type === "training" && model.resources.training?.fields?.approvedContentRevisions) {
992
+ fields.push({ field: "approvedContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "approve" });
993
+ }
994
+ if (record?.type === "training" && model.resources.training?.fields?.activatedContentRevisions) {
995
+ fields.push({
996
+ field: "activatedContentRevisions",
997
+ bound: (candidate) => (
998
+ ["active", "superseded", "retired"].includes(candidate?.status)
999
+ && candidate.activationBasis === "recorded"
1000
+ ),
1001
+ label: "activate"
1002
+ });
865
1003
  }
866
- return null;
1004
+ return fields;
867
1005
  }
868
1006
 
869
1007
  async function exclusiveContentFiles(loaded, record) {
package/src/git.js CHANGED
@@ -133,29 +133,88 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
133
133
  }
134
134
 
135
135
  export function getFileAtRevision(input, revision, relativePath) {
136
+ return getFilesAtRevisions(input, [{ revision, relativePath }])[0];
137
+ }
138
+
139
+ export function getFilesAtRevisions(input, requests) {
136
140
  const root = resolveWorkspaceRoot(input);
137
- if (!/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)) {
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) {
138
145
  throw new Error("Historical file exports require a Git commit and a data/ path.");
139
146
  }
147
+ if (!requests.length) return [];
140
148
  try {
141
149
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
142
150
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
143
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return null;
144
- const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
145
- return execFileSync("git", ["show", `${revision}:${repositoryPath}`], {
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) {
179
+ try {
180
+ return measureTimingSync("git-history-export", () => execFileSync("git", ["show", specification], {
146
181
  cwd: root,
147
182
  encoding: "utf8",
148
183
  stdio: ["ignore", "pipe", "ignore"],
149
184
  timeout: 10_000,
150
185
  maxBuffer: 20_000_000
151
- });
186
+ }));
152
187
  } catch {
153
188
  return null;
154
189
  }
155
190
  }
156
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
+
157
215
  function isSafeDataGitPath(value) {
158
216
  return isCanonicalDataPath(value)
217
+ && !/[\r\n]/.test(value)
159
218
  && value.startsWith("data/")
160
219
  && value !== "data/";
161
220
  }
@@ -230,6 +289,7 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
230
289
  available: true,
231
290
  commit: parsed.commit,
232
291
  shortCommit: parsed.commit?.slice(0, 8) ?? "no commits",
292
+ branch: parsed.branch,
233
293
  clean: parsed.changePaths.length === 0,
234
294
  changes: parsed.changePaths,
235
295
  workspaceChangePaths: parsed.changePaths
@@ -1121,11 +1181,14 @@ function parsePorcelainV2(source, topLevel, root) {
1121
1181
  function parseWorkspaceRevision(source) {
1122
1182
  const fields = source.split("\0").filter(Boolean);
1123
1183
  let commit = null;
1184
+ let branch = null;
1124
1185
  const changePaths = [];
1125
1186
  for (let index = 0; index < fields.length; index += 1) {
1126
1187
  const field = fields[index];
1127
1188
  if (field.startsWith("# branch.oid ")) {
1128
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);
1129
1192
  } else if (/^[12u?!] /.test(field)) {
1130
1193
  changePaths.push(porcelainV2Path(field));
1131
1194
  if (field.startsWith("2 ")) {
@@ -1135,6 +1198,7 @@ function parseWorkspaceRevision(source) {
1135
1198
  }
1136
1199
  return {
1137
1200
  commit,
1201
+ branch,
1138
1202
  changePaths: [...new Set(changePaths.filter(Boolean))].sort()
1139
1203
  };
1140
1204
  }
@@ -1290,13 +1354,13 @@ async function tryGitAsync(cwd, args, operation) {
1290
1354
  }
1291
1355
 
1292
1356
  function git(cwd, args) {
1293
- return execFileSync("git", args, {
1357
+ return measureTimingSync("git-command-sync", () => execFileSync("git", args, {
1294
1358
  cwd,
1295
1359
  encoding: "utf8",
1296
1360
  stdio: ["ignore", "pipe", "ignore"],
1297
1361
  timeout: 10_000,
1298
1362
  maxBuffer: 20_000_000
1299
- }).trim();
1363
+ }).trim());
1300
1364
  }
1301
1365
 
1302
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,14 @@ 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 {
58
+ activateDocuments,
59
+ activateGovernedContent,
60
+ planDocumentActivation,
61
+ planGovernedContentActivation,
62
+ scaffoldDocumentActivation,
63
+ scaffoldGovernedContentActivation
64
+ } from "./document-activation.js";
55
65
  export {
56
66
  applyPolicyLibraryUpgrade,
57
67
  assessPolicyLibraryUpgrades,