filegrc 0.9.2 → 0.10.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/cli.js CHANGED
@@ -223,6 +223,7 @@ export async function runCli(argv = process.argv.slice(2)) {
223
223
  : `Needs review: ${result.missing.length} missing values, ${result.conflicts.length} conflicts, ${result.manualActions.length} manual actions.`);
224
224
  } else {
225
225
  console.log(`Migrated workspace from model v${result.sourceModelVersion} to v${result.targetModelVersion}.`);
226
+ if (result.migrationReportPath) console.log(`Migration report: ${result.migrationReportPath}`);
226
227
  }
227
228
  return result;
228
229
  }
@@ -261,7 +262,10 @@ export async function runCli(argv = process.argv.slice(2)) {
261
262
  const audit = auditId ? loaded.resources.find(({ id, type }) => id === auditId && type === "audit") : null;
262
263
  const programId = flags.program || audit?.programId;
263
264
  const readiness = await assessProgramReadiness(loaded, { asOf: flags["as-of"], programId });
264
- const auditReadiness = auditId ? await assessAuditPreparation(loaded, { auditId }) : null;
265
+ const auditReadiness = auditId ? await assessAuditPreparation(loaded, {
266
+ auditId,
267
+ asOf: flags["as-of"]
268
+ }) : null;
265
269
  const result = buildProgramPathResult(loaded.model, readiness, auditReadiness);
266
270
  const output = selectProgramPathOutput(result, flags);
267
271
  if (flags.json) console.log(JSON.stringify(output, null, 2));
@@ -481,7 +485,10 @@ export async function runCli(argv = process.argv.slice(2)) {
481
485
  }
482
486
  if (command === "audit-readiness") {
483
487
  const loaded = await loadWorkspace(root);
484
- const result = await assessAuditPreparation(loaded, { auditId: positionals[0] || flags.audit });
488
+ const result = await assessAuditPreparation(loaded, {
489
+ auditId: positionals[0] || flags.audit,
490
+ asOf: flags["as-of"]
491
+ });
485
492
  if (flags.json) console.log(JSON.stringify(result, null, 2));
486
493
  else {
487
494
  console.log(`${result.status.toUpperCase()}: ${result.progress.complete} of ${result.progress.total} management items complete`);
@@ -1115,7 +1122,7 @@ Usage:
1115
1122
  filegrc build [root] [--output .filegrc/site]
1116
1123
  filegrc validate [root] [--json]
1117
1124
  filegrc model [--json|--write-docs|--check-docs]
1118
- filegrc migrate --to-model <2|3|4|5|6> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
1125
+ filegrc migrate --to-model <${SUPPORTED_MODEL_VERSIONS.join("|")}> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
1119
1126
  filegrc describe <resource-type>
1120
1127
  filegrc types [--json]
1121
1128
  filegrc guide [resource-type] [--id resource-id] [--program program-id] [--json]
@@ -1129,7 +1136,7 @@ Usage:
1129
1136
  filegrc obligations [--as-of YYYY-MM-DD] [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--now RFC3339] [--complete] [--json]
1130
1137
  filegrc program-readiness [--as-of YYYY-MM-DD] [--require-ready] [--summary] [--json]
1131
1138
  filegrc evidence-map [--as-of YYYY-MM-DD] [--json]
1132
- filegrc audit-readiness [audit-id] [--require-ready] [--json]
1139
+ filegrc audit-readiness [audit-id] [--as-of YYYY-MM-DD] [--require-ready] [--json]
1133
1140
  filegrc prepare-audit <audit-id> [--json]
1134
1141
  filegrc reconcile [--preview|--apply --candidate fingerprint (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --yes] [--risk-level normal|high] [--json]
1135
1142
  filegrc external-reviewer-setup --scaffold
@@ -1214,7 +1221,8 @@ workspaces migrate to v2 first. Continue one version at a time through v${ACTIVE
1214
1221
  the repository Workspace, management Program, bounded Systems, operational Components,
1215
1222
  specific Assets, Vendors, normalized information, and Evidence Artifacts. Model v5 separates
1216
1223
  Document approval from activation and records program-versus-engagement scope. Model v6 gives
1217
- Training the same approval and activation split and moves its schedule into Obligations. v3 migration
1224
+ Training the same approval and activation split and moves its schedule into Obligations. Model v7
1225
+ keeps issued historical Documents neutral and requires a current activation for legacy Training. v3 migration
1218
1226
  previews may require a decisions JSON file for ambiguous old Systems. v3 still creates planned
1219
1227
  core Appointments, removal of obsolete manual page state, classified review work,
1220
1228
  and dataModelVersion changed last. The command writes no Git commit.
@@ -1231,7 +1239,8 @@ Options:
1231
1239
  --help Show this help
1232
1240
 
1233
1241
  Start with:
1234
- npx filegrc migrate --to-model ${ACTIVE_MODEL_VERSION} --preview --json`);
1242
+ npx filegrc guide --json
1243
+ # Use the next model version shown by the guide.`);
1235
1244
  return;
1236
1245
  }
1237
1246
  if (command === "program-readiness") {
@@ -1398,6 +1407,10 @@ Options:
1398
1407
  }
1399
1408
 
1400
1409
  function agentOverview(model) {
1410
+ const currentModelIndex = SUPPORTED_MODEL_VERSIONS.indexOf(String(model.modelVersion));
1411
+ const nextModelVersion = currentModelIndex >= 0 && currentModelIndex < SUPPORTED_MODEL_VERSIONS.length - 1
1412
+ ? SUPPORTED_MODEL_VERSIONS[currentModelIndex + 1]
1413
+ : ACTIVE_MODEL_VERSION;
1401
1414
  const commands = {
1402
1415
  help: "filegrc help",
1403
1416
  version: "filegrc version",
@@ -1406,7 +1419,7 @@ function agentOverview(model) {
1406
1419
  build: "filegrc build [root]",
1407
1420
  validate: "filegrc validate [root] --json",
1408
1421
  model: "filegrc model --json",
1409
- migrate: "filegrc migrate --to-model 6 --preview --json",
1422
+ migrate: `filegrc migrate --to-model ${nextModelVersion} --preview --json`,
1410
1423
  describe: "filegrc describe <resource-type>",
1411
1424
  types: "filegrc types --json",
1412
1425
  guide: "filegrc guide [resource-type] --json",
@@ -1,6 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { modelSupports } from "../model/index.js";
3
- import { collectionRevision } from "./collection-revision.js";
3
+ import {
4
+ collectionRevision,
5
+ collectionRevisionMatches
6
+ } from "./collection-revision.js";
4
7
  import { scopedCollectionRecords } from "./collection-scope.js";
5
8
  import { applyResourceBatch } from "./files.js";
6
9
  import { getGitSummary } from "./git.js";
@@ -41,15 +44,25 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
41
44
  const allowsEmptyCollection = allowedDecisions.some((decision) => (
42
45
  decision === "zero-population" || decision === "externally-managed"
43
46
  ));
47
+ const revisionMatches = collectionRevisionMatches(
48
+ loaded,
49
+ resourceType,
50
+ review?.collectionRevision,
51
+ {
52
+ programId: program.id,
53
+ authoritativeSourceId,
54
+ currentRevision
55
+ }
56
+ );
44
57
  const complete = Boolean(
45
58
  review?.status === "active"
46
59
  && allowedDecisions.includes(review.decision)
47
- && review.collectionRevision === currentRevision
60
+ && revisionMatches
48
61
  );
49
62
  const stale = Boolean(
50
63
  review?.status === "active"
51
64
  && review.collectionRevision
52
- && review.collectionRevision !== currentRevision
65
+ && !revisionMatches
53
66
  );
54
67
  return {
55
68
  resourceType,
@@ -10,16 +10,36 @@ import { resolveProgram } from "./program.js";
10
10
  import { markdownEntries } from "./resource-markdown.js";
11
11
 
12
12
  export function collectionRevision(loaded, resourceType, options = {}) {
13
+ return calculateCollectionRevision(loaded, resourceType, options, false);
14
+ }
15
+
16
+ export function legacyCollectionRevision(loaded, resourceType, options = {}) {
17
+ return calculateCollectionRevision(loaded, resourceType, options, true);
18
+ }
19
+
20
+ export function collectionRevisionMatches(loaded, resourceType, storedRevision, options = {}) {
21
+ if (!storedRevision) return false;
22
+ const currentRevision = options.currentRevision
23
+ || collectionRevision(loaded, resourceType, options);
24
+ // Version 0.9.2 narrowed this hash basis. Keep unchanged 0.9.1 reviews valid
25
+ // until management records a new review on the current basis.
26
+ return storedRevision === currentRevision
27
+ || storedRevision === legacyCollectionRevision(loaded, resourceType, options);
28
+ }
29
+
30
+ function calculateCollectionRevision(loaded, resourceType, options, legacy) {
13
31
  const program = Object.hasOwn(options, "program")
14
32
  ? options.program
15
33
  : resolveProgram(loaded, options.programId);
16
- const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program)
34
+ const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, { legacy })
17
35
  .map((input) => [input.record.id, input]));
18
36
  const authoritativeSource = loaded.resources.find(({ id }) => id === options.authoritativeSourceId);
19
37
  if (authoritativeSource) {
20
38
  inputs.set(authoritativeSource.id, {
21
39
  record: authoritativeSource,
22
- value: authoritativeSourceRevisionValue(authoritativeSource),
40
+ value: legacy
41
+ ? authoritativeSource
42
+ : authoritativeSourceRevisionValue(authoritativeSource),
23
43
  includeContent: true
24
44
  });
25
45
  }
@@ -29,7 +49,7 @@ export function collectionRevision(loaded, resourceType, options = {}) {
29
49
  revision: createHash("sha256")
30
50
  .update(JSON.stringify(canonicalRecordValue(loaded.model, record.type, value)))
31
51
  .digest("hex"),
32
- contentRevisions: (includeContent ? markdownEntries(loaded.model, record) : []).flatMap(({ path }) => {
52
+ contentRevisions: (legacy || includeContent ? markdownEntries(loaded.model, record) : []).flatMap(({ path }) => {
33
53
  try {
34
54
  const content = readFileSync(resolveDataPath(loaded.root, path), "utf8");
35
55
  return [{ path, revision: createHash("sha256").update(content).digest("hex") }];
@@ -5,8 +5,27 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
5
5
  if (!modelSupports(loaded.model, "program-scope")) {
6
6
  return loaded.resources.filter((record) => record.type === resourceType);
7
7
  }
8
+ if (resourceType === "person") {
9
+ return scopedProgramPeople(loaded, program);
10
+ }
8
11
  if (resourceType === "vendor") {
9
- return loaded.resources.filter((record) => record.type === "vendor");
12
+ const scopedVendorIds = new Set(programComponents(loaded, program).map(({ vendorId }) => vendorId).filter(Boolean));
13
+ const auditVendorIds = new Set(loaded.resources
14
+ .filter((record) => record.type === "audit" && record.auditorVendorId)
15
+ .map(({ auditorVendorId }) => auditorVendorId));
16
+ const auditOnlyVendorIds = Number(loaded.model.modelVersion) >= 7
17
+ ? auditVendorIds
18
+ : new Set(loaded.resources
19
+ .filter((record) => (
20
+ record.type === "vendor"
21
+ && auditVendorIds.has(record.id)
22
+ && /(?:audit|accounting|cpa)/i.test(record.category || "")
23
+ ))
24
+ .map(({ id }) => id));
25
+ return loaded.resources.filter((record) => (
26
+ record.type === "vendor"
27
+ && (!auditOnlyVendorIds.has(record.id) || scopedVendorIds.has(record.id))
28
+ ));
10
29
  }
11
30
  const scopedProgram = program || {};
12
31
  const components = programComponents(loaded, scopedProgram);
@@ -36,7 +55,44 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
36
55
  ));
37
56
  }
38
57
 
39
- export function collectionRevisionInputs(loaded, resourceType, program) {
58
+ function scopedProgramPeople(loaded, program = {}) {
59
+ const personIds = new Set(loaded.resources.filter(({ type }) => type === "person").map(({ id }) => id));
60
+ const systemIds = new Set(program.systemIds || []);
61
+ const controlIds = new Set(program.controlIds || []);
62
+ const policyIds = new Set(loaded.resources
63
+ .filter(({ type, status, programRole }) => type === "policy" && status !== "retired" && programRole !== "reference")
64
+ .map(({ id }) => id));
65
+ const components = programComponents(loaded, program);
66
+ const componentIds = new Set(components.map(({ id }) => id));
67
+ const vendorIds = new Set([
68
+ ...components.map(({ vendorId }) => vendorId).filter(Boolean),
69
+ ...scopedCollectionRecords(loaded, "vendor", program).map(({ id }) => id)
70
+ ]);
71
+ const sources = loaded.resources.filter((record) => (
72
+ ["workspace", "program", "appointment", "team"].includes(record.type)
73
+ || record.type === "system" && systemIds.has(record.id)
74
+ || record.type === "component" && componentIds.has(record.id)
75
+ || record.type === "vendor" && vendorIds.has(record.id)
76
+ || record.type === "control" && controlIds.has(record.id)
77
+ || record.type === "policy" && policyIds.has(record.id)
78
+ || record.type === "document" && record.workflowScope !== "engagement"
79
+ || record.type === "obligation" && (
80
+ (record.controlIds || []).some((id) => controlIds.has(id))
81
+ || (record.policyIds || []).some((id) => policyIds.has(id))
82
+ )
83
+ ));
84
+ const selected = new Set();
85
+ const visit = (value) => {
86
+ if (typeof value === "string" && personIds.has(value)) selected.add(value);
87
+ else if (Array.isArray(value)) value.forEach(visit);
88
+ else if (value && typeof value === "object") Object.values(value).forEach(visit);
89
+ };
90
+ sources.forEach(visit);
91
+ return loaded.resources.filter((record) => record.type === "person" && selected.has(record.id));
92
+ }
93
+
94
+ export function collectionRevisionInputs(loaded, resourceType, program, options = {}) {
95
+ const legacy = options.legacy === true;
40
96
  const reviewed = scopedCollectionRecords(loaded, resourceType, program);
41
97
  if (!modelSupports(loaded.model, "program-scope")) {
42
98
  return reviewed.map((record) => ({ record, value: record, includeContent: true }));
@@ -90,8 +146,9 @@ export function collectionRevisionInputs(loaded, resourceType, program) {
90
146
  record,
91
147
  value: reviewedIds.has(record.id)
92
148
  ? record
93
- : dependencyRevisionValue(resourceType, record),
94
- includeContent: reviewedIds.has(record.id)
149
+ : dependencyRevisionValue(resourceType, record, legacy),
150
+ includeContent: legacy
151
+ || reviewedIds.has(record.id)
95
152
  || dependencyContentAffectsRevision(resourceType, record.type)
96
153
  }));
97
154
  }
@@ -167,7 +224,7 @@ const dependencyFields = {
167
224
  },
168
225
  component: {
169
226
  system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
170
- control: ["id", "type", "status", "systemIds", "componentIds", "evidenceSourceComponentIds"],
227
+ control: ["id", "type", "systemIds", "componentIds", "evidenceSourceComponentIds"],
171
228
  vendor: ["id", "type", "status", "category", "criticality", "description", "startDate", "endDate"],
172
229
  classification: ["id", "type", "status", "rank", "description", "handlingRequirements"],
173
230
  "information-type": ["id", "type", "status", "classificationId", "description"]
@@ -183,6 +240,21 @@ const dependencyFields = {
183
240
  }
184
241
  };
185
242
 
243
+ const legacyDependencyFieldOverrides = {
244
+ framework: {
245
+ system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "informationTypeIds", "classificationId", "internetExposed"]
246
+ },
247
+ component: {
248
+ control: ["id", "type", "status", "statement", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "componentIds", "evidenceSourceComponentIds"],
249
+ vendor: ["id", "type", "status", "category", "criticality", "description", "standardAgreement", "agreementDocumentId", "startDate", "endDate", "classificationId", "informationTypeIds"]
250
+ },
251
+ "complementary-control": {
252
+ system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "informationTypeIds", "classificationId", "internetExposed"],
253
+ control: ["id", "type", "status", "statement", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "componentIds", "evidenceSourceComponentIds"],
254
+ component: ["id", "type", "status", "componentKind", "description", "vendorId", "systemUses", "informationUses", "internetExposed"]
255
+ }
256
+ };
257
+
186
258
  const dependencyContentTypes = {
187
259
  framework: new Set(["system"]),
188
260
  vendor: new Set(["document"]),
@@ -190,8 +262,11 @@ const dependencyContentTypes = {
190
262
  "complementary-control": new Set(["system", "control", "document", "component"])
191
263
  };
192
264
 
193
- function dependencyRevisionValue(resourceType, record) {
194
- const fields = dependencyFields[resourceType]?.[record.type];
265
+ function dependencyRevisionValue(resourceType, record, legacy) {
266
+ const fields = legacy
267
+ ? legacyDependencyFieldOverrides[resourceType]?.[record.type]
268
+ || dependencyFields[resourceType]?.[record.type]
269
+ : dependencyFields[resourceType]?.[record.type];
195
270
  if (!fields) return { id: record.id, type: record.type };
196
271
  return Object.fromEntries(fields
197
272
  .filter((field) => record[field] !== undefined)
@@ -14,6 +14,13 @@ export async function scaffoldDocumentActivation(input = process.cwd(), options
14
14
  const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
15
15
  const today = currentCalendarDate(loaded.workspace.timezone);
16
16
  return {
17
+ available: candidates.length > 0,
18
+ message: candidates.length
19
+ ? `${candidates.length} approved ${candidates.length === 1 ? "Document is" : "Documents are"} ready to activate.`
20
+ : `No ${options.auditId ? "engagement Document" : "program Document"} is ready to activate. Review the current readiness actions first.`,
21
+ nextCommand: candidates.length ? null : options.auditId
22
+ ? `npx filegrc audit-readiness ${options.auditId} --json`
23
+ : "npx filegrc program-path --next --json",
17
24
  documentIds: candidates.filter(({ resourceType }) => resourceType === "document").map(({ resourceId }) => resourceId),
18
25
  ...(options.auditId ? { auditId: options.auditId, workflowScope: "engagement" } : { workflowScope: "program" }),
19
26
  activatedByIds: [],
@@ -141,12 +148,17 @@ export async function scaffoldGovernedContentActivation(input = process.cwd(), o
141
148
  const loaded = await loadWorkspace(input);
142
149
  requireDocumentLifecycle(loaded);
143
150
  if (!modelSupports(loaded.model, "governed-training-activation")) {
144
- throw new Error("Unified governed-content activation requires a model v6 workspace.");
151
+ throw new Error("Unified governed-content activation requires a model v6 or later workspace.");
145
152
  }
146
153
  const candidates = await activationCandidates(loaded, { ...options, auditId: undefined });
147
154
  const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
148
155
  const today = currentCalendarDate(loaded.workspace.timezone);
149
156
  return {
157
+ available: candidates.length > 0,
158
+ message: candidates.length
159
+ ? `${candidates.length} approved governed-content ${candidates.length === 1 ? "record is" : "records are"} ready to activate.`
160
+ : "No program Document or Training record is ready to activate. Review the current readiness actions first.",
161
+ nextCommand: candidates.length ? null : "npx filegrc program-path --next --json",
150
162
  resourceIds: candidates.map(({ resourceId }) => resourceId),
151
163
  documentIds: candidates.filter(({ resourceType }) => resourceType === "document").map(({ resourceId }) => resourceId),
152
164
  trainingIds: candidates.filter(({ resourceType }) => resourceType === "training").map(({ resourceId }) => resourceId),