filegrc 0.5.0 → 0.6.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.
@@ -891,18 +891,21 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
891
891
  const sourceVersion = String(loaded.workspace?.dataModelVersion || "");
892
892
  const requestedTarget = options.targetModelVersion
893
893
  ? String(options.targetModelVersion)
894
- : sourceVersion === "1" ? V1_TARGET_MODEL_VERSION : ACTIVE_MODEL_VERSION;
894
+ : sourceVersion === "1" ? V1_TARGET_MODEL_VERSION : sourceVersion === "2" ? "3" : ACTIVE_MODEL_VERSION;
895
895
  if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
896
896
  if (sourceVersion === "1" && requestedTarget === "2") {
897
897
  return planV1ToV2Migration(input, options);
898
898
  }
899
- if (sourceVersion === "2" && requestedTarget === ACTIVE_MODEL_VERSION) {
899
+ if (sourceVersion === "2" && requestedTarget === "3") {
900
900
  return planV2ToV3Migration(loaded);
901
901
  }
902
+ if (sourceVersion === "3" && requestedTarget === ACTIVE_MODEL_VERSION) {
903
+ return planV3ToV4Migration(loaded, options);
904
+ }
902
905
  if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
903
906
  throw new Error(
904
907
  "Model v1 workspaces must migrate to model v2 first. "
905
- + "Preview and apply `npx filegrc migrate --to-model 2`, then migrate to model v3."
908
+ + "Preview and apply `npx filegrc migrate --to-model 2`, then migrate to model v3 and v4."
906
909
  );
907
910
  }
908
911
  throw new Error(`Model migration does not support v${sourceVersion} to v${requestedTarget}.`);
@@ -946,10 +949,10 @@ async function planV2ToV3Migration(loaded) {
946
949
  const unsupported = [];
947
950
  const missing = [];
948
951
  const manualActions = [];
949
- const targetModel = loadModel(ACTIVE_MODEL_VERSION);
952
+ const targetModel = loadModel("3");
950
953
  const workspace = {
951
954
  ...loaded.workspace,
952
- dataModelVersion: ACTIVE_MODEL_VERSION
955
+ dataModelVersion: "3"
953
956
  };
954
957
  automatic.push(classifiedChange(
955
958
  "automatic",
@@ -1172,7 +1175,7 @@ async function planV2ToV3Migration(loaded) {
1172
1175
  return {
1173
1176
  schemaVersion: 2,
1174
1177
  sourceModelVersion: "2",
1175
- targetModelVersion: ACTIVE_MODEL_VERSION,
1178
+ targetModelVersion: "3",
1176
1179
  ready: unsupported.length === 0,
1177
1180
  missing,
1178
1181
  conflicts: [],
@@ -1204,7 +1207,293 @@ async function planV2ToV3Migration(loaded) {
1204
1207
  updates.map(({ id }) => [id, revisionById.get(id)])
1205
1208
  ),
1206
1209
  validateWholeWorkspace: true,
1207
- targetModelVersion: ACTIVE_MODEL_VERSION
1210
+ targetModelVersion: "3"
1211
+ }
1212
+ };
1213
+ }
1214
+
1215
+ async function planV3ToV4Migration(loaded, options = {}) {
1216
+ if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
1217
+ const targetModel = loadModel("4");
1218
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
1219
+ const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
1220
+ const automatic = [];
1221
+ const reviewRequired = [];
1222
+ const unsupported = [];
1223
+ const missing = [];
1224
+ const manualActions = [];
1225
+ const creates = [];
1226
+ const updates = [];
1227
+ const movePaths = [];
1228
+ const usedIds = loaded.resources.map(({ id }) => id);
1229
+ const decisions = options.systemDecisions && typeof options.systemDecisions === "object"
1230
+ ? options.systemDecisions
1231
+ : {};
1232
+ const oldSystems = loaded.resources.filter(({ type }) => type === "system");
1233
+ const scopedIds = new Set(loaded.workspace.systemIds || []);
1234
+ const commitmentSystemIds = new Set(loaded.resources
1235
+ .filter(({ type, status }) => type === "commitment" && !["superseded", "retired"].includes(status))
1236
+ .flatMap(({ systemIds }) => systemIds || []));
1237
+ const componentKinds = new Set([
1238
+ "application", "infrastructure", "platform", "repository", "evidence-source",
1239
+ "software", "network", "physical", "external-system", "interconnection"
1240
+ ]);
1241
+ const classifications = migrateV4Classifications(loaded, creates, automatic, reviewRequired, usedIds);
1242
+ const informationTypes = migrateV4InformationTypes(
1243
+ loaded,
1244
+ creates,
1245
+ automatic,
1246
+ reviewRequired,
1247
+ usedIds,
1248
+ classifications
1249
+ );
1250
+ const systemKinds = new Map();
1251
+
1252
+ for (const record of oldSystems) {
1253
+ const explicit = normalizeSystemDecision(decisions[record.id]);
1254
+ if (explicit) {
1255
+ systemKinds.set(record.id, explicit.kind);
1256
+ reviewRequired.push(classifiedChange(
1257
+ "review-required",
1258
+ record.id,
1259
+ "type",
1260
+ `Use the supplied migration decision to keep this v3 System as a ${explicit.kind === "system" ? "bounded System" : "Component"}.`
1261
+ ));
1262
+ continue;
1263
+ }
1264
+ const rootCandidate = scopedIds.has(record.id)
1265
+ && !record.parentSystemId
1266
+ && !record.vendorId
1267
+ && (record.systemKind === "service" || commitmentSystemIds.has(record.id));
1268
+ const componentCandidate = Boolean(
1269
+ record.parentSystemId
1270
+ || record.vendorId
1271
+ || componentKinds.has(String(record.systemKind || "").toLowerCase())
1272
+ );
1273
+ if (rootCandidate !== componentCandidate) {
1274
+ systemKinds.set(record.id, rootCandidate ? "system" : "component");
1275
+ automatic.push(classifiedChange(
1276
+ "automatic",
1277
+ record.id,
1278
+ "type",
1279
+ rootCandidate
1280
+ ? "Keep the selected root service as a bounded System."
1281
+ : "Convert the operational, provider-supplied, or subordinate v3 System to a Component."
1282
+ ));
1283
+ } else {
1284
+ const message = rootCandidate
1285
+ ? "This v3 System has both bounded-service and Component signals. Choose system or component explicitly."
1286
+ : "This v3 System could be either a bounded System or a Component. Choose its v4 identity explicitly.";
1287
+ unsupported.push(classifiedChange("unsupported", record.id, "type", message));
1288
+ manualActions.push({ resourceId: record.id, field: "type", message });
1289
+ }
1290
+ }
1291
+
1292
+ const retainedSystemIds = new Set([...systemKinds].filter(([, kind]) => kind === "system").map(([id]) => id));
1293
+ const componentSystemUses = new Map();
1294
+ for (const record of oldSystems.filter(({ id }) => systemKinds.get(id) === "component")) {
1295
+ const explicit = normalizeSystemDecision(decisions[record.id]);
1296
+ const candidateIds = explicit?.systemUses?.map(({ systemId }) => systemId)
1297
+ || [record.parentSystemId, ...scopedIds].filter(Boolean);
1298
+ const targetIds = [...new Set(candidateIds)].filter((id) => retainedSystemIds.has(id));
1299
+ const uses = explicit?.systemUses?.length
1300
+ ? explicit.systemUses
1301
+ : targetIds.length === 1
1302
+ ? [{
1303
+ systemId: targetIds[0],
1304
+ roles: derivedComponentRoles(record, loaded.resources),
1305
+ rationale: `Migrated from v3 System "${record.title}" because it was recorded as part of or support for this bounded System.`
1306
+ }]
1307
+ : [];
1308
+ if (!uses.length) {
1309
+ const message = "Choose at least one bounded System, role, and rationale for this Component.";
1310
+ unsupported.push(classifiedChange("unsupported", record.id, "systemUses", message));
1311
+ manualActions.push({ resourceId: record.id, field: "systemUses", message });
1312
+ }
1313
+ componentSystemUses.set(record.id, uses);
1314
+ }
1315
+
1316
+ const programId = createResourceId(
1317
+ "program",
1318
+ loaded.workspace.title || `${loaded.workspace.organizationName} program`,
1319
+ [...usedIds, ...creates.map(({ id }) => id)]
1320
+ );
1321
+ const applicability = [];
1322
+ const selectedRequirementIds = new Set(loaded.workspace.requirementIds || []);
1323
+ for (const requirement of loaded.resources.filter(({ type }) => type === "requirement")) {
1324
+ if (!selectedRequirementIds.has(requirement.id) && requirement.applicability !== "not-applicable") continue;
1325
+ const review = requirement.applicabilityReview;
1326
+ if (review?.reviewedByIds?.length && review.reviewedOn && review.scopeRevision && (review.rationale || requirement.applicabilityRationale)) {
1327
+ applicability.push({
1328
+ requirementId: requirement.id,
1329
+ decision: requirement.applicability || review.decision || "undetermined",
1330
+ rationale: requirement.applicabilityRationale || review.rationale,
1331
+ reviewedByIds: review.reviewedByIds,
1332
+ reviewedOn: review.reviewedOn,
1333
+ scopeRevision: review.scopeRevision
1334
+ });
1335
+ automatic.push(classifiedChange("automatic", requirement.id, "applicability", "Move the reviewed applicability decision from the catalog Requirement to the new Program."));
1336
+ } else {
1337
+ applicability.push({
1338
+ requirementId: requirement.id,
1339
+ decision: "undetermined"
1340
+ });
1341
+ reviewRequired.push(classifiedChange(
1342
+ "review-required",
1343
+ requirement.id,
1344
+ "applicability",
1345
+ "Review this Requirement against the new Program and record the decision, rationale, reviewer, date, and scope revision."
1346
+ ));
1347
+ }
1348
+ }
1349
+ const programOwners = programOwnerIds(loaded.resources, retainedSystemIds);
1350
+ const program = {
1351
+ id: programId,
1352
+ type: "program",
1353
+ title: loaded.workspace.title,
1354
+ status: programOwners.length
1355
+ && loaded.workspace.riskMethodology
1356
+ && retainedSystemIds.size
1357
+ && (loaded.workspace.frameworkIds || []).length
1358
+ && (loaded.workspace.controlIds || []).length
1359
+ ? "active"
1360
+ : "planned",
1361
+ assuranceGoal: loaded.workspace.assuranceGoal || "none",
1362
+ systemIds: [...retainedSystemIds].filter((id) => scopedIds.has(id)),
1363
+ frameworkIds: [...(loaded.workspace.frameworkIds || [])],
1364
+ requirementApplicability: applicability,
1365
+ controlIds: [...(loaded.workspace.controlIds || [])],
1366
+ ...(programOwners.length ? { ownerIds: programOwners } : {}),
1367
+ ...(loaded.workspace.riskMethodology ? { riskMethodology: loaded.workspace.riskMethodology } : {}),
1368
+ ...(loaded.workspace.candidateCoverage ? { candidateCoverage: loaded.workspace.candidateCoverage } : {}),
1369
+ description: `Compliance and assurance program for ${loaded.workspace.organizationName}.`
1370
+ };
1371
+ creates.push(program);
1372
+ automatic.push(classifiedChange("automatic", program.id, null, "Create a Program from the program facts previously stored on Workspace."));
1373
+ if (!programOwners.length) reviewRequired.push(classifiedChange("review-required", program.id, "ownerIds", "Assign Program owners before activating the Program."));
1374
+ if (!program.riskMethodology) reviewRequired.push(classifiedChange("review-required", program.id, "riskMethodology", "Record the Program risk methodology before activation."));
1375
+
1376
+ for (const original of loaded.resources) {
1377
+ let record = structuredClone(original);
1378
+ if (record.type === "workspace") {
1379
+ record.dataModelVersion = "4";
1380
+ for (const field of ["assuranceGoal", "frameworkIds", "requirementIds", "controlIds", "systemIds", "riskMethodology", "classificationDefinitions", "candidateCoverage"]) delete record[field];
1381
+ updates.push(record);
1382
+ automatic.push(classifiedChange("automatic", record.id, "dataModelVersion", "Select model v4 and leave repository-wide identity on Workspace."));
1383
+ continue;
1384
+ }
1385
+ if (record.type === "system") {
1386
+ if (!systemKinds.has(record.id)) continue;
1387
+ record = systemKinds.get(record.id) === "system"
1388
+ ? migrateBoundedSystem(record, informationTypes, classifications)
1389
+ : migrateComponent(record, componentSystemUses.get(record.id) || [], informationTypes, classifications);
1390
+ updates.push(record);
1391
+ reviewRequired.push(classifiedChange(
1392
+ "review-required",
1393
+ record.id,
1394
+ record.type === "system" ? "boundary" : "systemUses",
1395
+ record.type === "system"
1396
+ ? "Confirm the migrated purpose, services, boundary, exclusions, and information scope."
1397
+ : "Confirm every migrated Component role, rationale, information use, and evidence-source fact."
1398
+ ));
1399
+ if (record.type === "component") {
1400
+ const oldMarkdown = `systems/${record.id}.md`;
1401
+ const newMarkdown = `components/${record.id}.md`;
1402
+ if (loaded.entries.some(({ record: candidate }) => candidate.id === record.id)) {
1403
+ try {
1404
+ await readFile(resolveDataPath(loaded.root, oldMarkdown), "utf8");
1405
+ movePaths.push({ from: oldMarkdown, to: newMarkdown });
1406
+ } catch (error) {
1407
+ if (error.code !== "ENOENT") throw error;
1408
+ }
1409
+ }
1410
+ }
1411
+ continue;
1412
+ }
1413
+ if (record.type === "requirement") {
1414
+ delete record.applicability;
1415
+ delete record.applicabilityRationale;
1416
+ delete record.applicabilityReview;
1417
+ }
1418
+ if (record.type === "vendor") record = migrateV4Vendor(record, informationTypes, classifications, reviewRequired);
1419
+ record = rewriteV4SystemRelationships(record, {
1420
+ programId,
1421
+ workspaceId: loaded.workspace.id,
1422
+ systemKinds,
1423
+ componentSystemUses,
1424
+ classifications,
1425
+ informationTypes,
1426
+ reviewRequired,
1427
+ unsupported,
1428
+ manualActions,
1429
+ byId
1430
+ });
1431
+ updates.push(record);
1432
+ }
1433
+
1434
+ const updatedById = new Map(updates.map((record) => [record.id, record]));
1435
+ const migratedRecords = [
1436
+ ...loaded.resources.map((record) => updatedById.get(record.id) || record),
1437
+ ...creates
1438
+ ];
1439
+ collectModelShapeActions(migratedRecords, targetModel, missing, manualActions);
1440
+ collectRelationshipConstraintActions(migratedRecords, targetModel, manualActions);
1441
+ collectRelationTypeActions(migratedRecords, targetModel, manualActions);
1442
+ await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
1443
+ for (const item of [...missing, ...manualActions]) {
1444
+ const key = `${item.resourceId}:${item.field}:${item.message || "missing"}`;
1445
+ if (unsupported.some((entry) => `${entry.resourceId}:${entry.field}:${entry.message}` === key)) continue;
1446
+ unsupported.push(classifiedChange(
1447
+ "unsupported",
1448
+ item.resourceId,
1449
+ item.field,
1450
+ item.message || `Record ${item.field} before applying the model v4 migration.`
1451
+ ));
1452
+ }
1453
+ const ready = unsupported.length === 0;
1454
+ return {
1455
+ schemaVersion: 2,
1456
+ sourceModelVersion: "3",
1457
+ targetModelVersion: "4",
1458
+ ready,
1459
+ missing,
1460
+ conflicts: [],
1461
+ manualActions,
1462
+ classifications: { automatic, reviewRequired, unsupported },
1463
+ notes: reviewRequired,
1464
+ migrationReport: {
1465
+ programId,
1466
+ retainedSystemIds: [...retainedSystemIds],
1467
+ componentIds: [...systemKinds].filter(([, kind]) => kind === "component").map(([id]) => id),
1468
+ classificationIds: [...classifications.values()],
1469
+ informationTypeIds: [...informationTypes.values()],
1470
+ relationshipUpdates: updates.filter((record) => record.type !== "workspace").map(({ id, type }) => ({ id, type }))
1471
+ },
1472
+ summary: {
1473
+ create: creates.length,
1474
+ update: updates.length,
1475
+ move: movePaths.length,
1476
+ automatic: automatic.length,
1477
+ reviewRequired: reviewRequired.length,
1478
+ unsupported: unsupported.length
1479
+ },
1480
+ fileDiff: {
1481
+ create: creates.map((record) => ({ type: record.type, id: record.id, after: record })),
1482
+ update: updates.map((record) => ({
1483
+ type: record.type,
1484
+ id: record.id,
1485
+ before: loaded.resources.find(({ id }) => id === record.id),
1486
+ after: record
1487
+ })),
1488
+ move: movePaths
1489
+ },
1490
+ changes: {
1491
+ create: creates,
1492
+ update: updates,
1493
+ movePaths,
1494
+ expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
1495
+ validateWholeWorkspace: true,
1496
+ targetModelVersion: "4"
1208
1497
  }
1209
1498
  };
1210
1499
  }
@@ -1217,11 +1506,12 @@ async function collectTargetValidationActions(loaded, records, model, missing, m
1217
1506
  const definition = model.resources[record.type];
1218
1507
  const recordPath = definition.singleton
1219
1508
  || `${definition.collection}/${(definition.recordPath || "{id}.json").replaceAll("{id}", record.id)}`;
1509
+ const keepsType = existing?.record?.type === record.type;
1220
1510
  const source = `${JSON.stringify(record, null, 2)}\n`;
1221
1511
  return {
1222
1512
  ...existing,
1223
1513
  path: existing?.path,
1224
- relativePath: existing?.relativePath || recordPath,
1514
+ relativePath: keepsType ? existing.relativePath : recordPath,
1225
1515
  record,
1226
1516
  source,
1227
1517
  revision: contentRevision(source)
@@ -1361,6 +1651,331 @@ function classifiedChange(classification, resourceId, field, message) {
1361
1651
  };
1362
1652
  }
1363
1653
 
1654
+ function normalizeSystemDecision(value) {
1655
+ if (value === "system" || value === "component") return { kind: value };
1656
+ if (!value || typeof value !== "object" || !["system", "component"].includes(value.kind)) return null;
1657
+ return {
1658
+ kind: value.kind,
1659
+ ...(Array.isArray(value.systemUses) ? { systemUses: value.systemUses } : {})
1660
+ };
1661
+ }
1662
+
1663
+ function derivedComponentRoles(system, resources) {
1664
+ const roles = new Set();
1665
+ if ((system.evidenceSourceKinds || []).length) roles.add("evidence-source");
1666
+ if (resources.some((record) => (
1667
+ record.type === "control"
1668
+ && ((record.systemIds || []).includes(system.id) || (record.evidenceSourceIds || []).includes(system.id))
1669
+ ))) roles.add("control-support");
1670
+ if (system.parentSystemId) roles.add("service-delivery");
1671
+ if (!roles.size) roles.add("supporting-operations");
1672
+ return [...roles];
1673
+ }
1674
+
1675
+ function programOwnerIds(resources, systemIds) {
1676
+ const appointments = resources.filter((record) => (
1677
+ record.type === "appointment"
1678
+ && record.status === "active"
1679
+ && ["policy-owner", "chief-information-security-officer"].includes(record.appointmentKind)
1680
+ )).map(({ id }) => id);
1681
+ if (appointments.length) return appointments;
1682
+ return [...new Set(resources
1683
+ .filter((record) => record.type === "system" && systemIds.has(record.id))
1684
+ .flatMap(({ ownerIds }) => ownerIds || []))];
1685
+ }
1686
+
1687
+ function migrateV4Classifications(loaded, creates, automatic, reviewRequired, usedIds) {
1688
+ const result = new Map();
1689
+ let rank = 0;
1690
+ for (const [key, description] of Object.entries(loaded.workspace.classificationDefinitions || {})) {
1691
+ const candidate = normalizeClassificationId(key);
1692
+ const id = candidate && ![...usedIds, ...creates.map(({ id: current }) => current)].includes(candidate)
1693
+ ? candidate
1694
+ : createResourceId("classification", key, [...usedIds, ...creates.map(({ id: current }) => current)]);
1695
+ creates.push({
1696
+ id,
1697
+ type: "classification",
1698
+ title: properTitle(key),
1699
+ status: "active",
1700
+ rank,
1701
+ description
1702
+ });
1703
+ result.set(key, id);
1704
+ rank += 1;
1705
+ automatic.push(classifiedChange("automatic", id, null, `Create a Classification from Workspace classificationDefinitions.${key}.`));
1706
+ reviewRequired.push(classifiedChange("review-required", id, "rank", "Confirm that the migrated classification rank reflects increasing handling sensitivity."));
1707
+ }
1708
+ return result;
1709
+ }
1710
+
1711
+ function migrateV4InformationTypes(loaded, creates, automatic, reviewRequired, usedIds, classifications) {
1712
+ const uses = new Map();
1713
+ for (const record of loaded.resources) {
1714
+ for (const value of record.dataTypes || []) {
1715
+ const key = String(value || "").trim().toLowerCase();
1716
+ if (!key) continue;
1717
+ if (!uses.has(key)) uses.set(key, { title: String(value).trim(), classifications: new Set() });
1718
+ if (record.classificationId && classifications.has(record.classificationId)) {
1719
+ uses.get(key).classifications.add(classifications.get(record.classificationId));
1720
+ }
1721
+ }
1722
+ }
1723
+ const result = new Map();
1724
+ for (const [key, value] of uses) {
1725
+ const id = createResourceId("information-type", value.title, [...usedIds, ...creates.map(({ id: current }) => current)]);
1726
+ const classificationIds = [...value.classifications];
1727
+ creates.push({
1728
+ id,
1729
+ type: "information-type",
1730
+ title: value.title,
1731
+ status: classificationIds.length === 1 ? "active" : "planned",
1732
+ description: `Information category migrated from the v3 dataTypes value "${value.title}".`,
1733
+ ...(classificationIds.length === 1 ? { classificationId: classificationIds[0] } : {})
1734
+ });
1735
+ result.set(key, id);
1736
+ automatic.push(classifiedChange("automatic", id, null, `Normalize the v3 dataTypes value "${value.title}" as an Information Type.`));
1737
+ reviewRequired.push(classifiedChange("review-required", id, "classificationId", "Confirm the Information Type description and default Classification before activation."));
1738
+ }
1739
+ return result;
1740
+ }
1741
+
1742
+ function migrateBoundedSystem(record, informationTypes, classifications) {
1743
+ return cleanUndefined({
1744
+ id: record.id,
1745
+ type: "system",
1746
+ title: record.title,
1747
+ status: record.status,
1748
+ purpose: record.description || `Purpose of ${record.title}`,
1749
+ servicesProvided: [record.title],
1750
+ boundary: record.description || `Boundary of ${record.title}`,
1751
+ exclusions: [],
1752
+ criticality: record.criticality,
1753
+ ownerIds: record.ownerIds,
1754
+ informationTypeIds: normalizedInformationTypeIds(record.dataTypes, informationTypes),
1755
+ classificationId: classifications.get(record.classificationId),
1756
+ internetExposed: record.internetExposed,
1757
+ continuityObjectives: record.continuityObjectives,
1758
+ statusTransition: record.statusTransition,
1759
+ tags: record.tags,
1760
+ extensions: mergeMigrationExtension(record.extensions, {
1761
+ ...(record.environment ? { environment: record.environment } : {}),
1762
+ ...(record.vendorId ? { vendorId: record.vendorId } : {}),
1763
+ ...(record.subserviceVendorIds ? { subserviceVendorIds: record.subserviceVendorIds } : {}),
1764
+ ...(record.evidenceSourceKinds ? { evidenceSourceKinds: record.evidenceSourceKinds } : {}),
1765
+ ...(record.evidenceOwnerIds ? { evidenceOwnerIds: record.evidenceOwnerIds } : {})
1766
+ }),
1767
+ externalIds: record.externalIds
1768
+ });
1769
+ }
1770
+
1771
+ function migrateComponent(record, systemUses, informationTypes, classifications) {
1772
+ const kind = componentKind(record.systemKind, record.vendorId);
1773
+ return cleanUndefined({
1774
+ id: record.id,
1775
+ type: "component",
1776
+ title: record.title,
1777
+ status: record.status,
1778
+ componentKind: kind,
1779
+ description: record.description || record.title,
1780
+ criticality: record.criticality,
1781
+ environment: record.environment,
1782
+ vendorId: record.vendorId,
1783
+ systemUses,
1784
+ informationUses: normalizedInformationTypeIds(record.dataTypes, informationTypes).map((informationTypeId) => ({
1785
+ informationTypeId,
1786
+ activities: ["process"]
1787
+ })),
1788
+ internetExposed: record.internetExposed,
1789
+ evidenceSourceKinds: record.evidenceSourceKinds,
1790
+ evidenceOwnerIds: record.evidenceOwnerIds,
1791
+ ownerIds: record.ownerIds,
1792
+ classificationId: classifications.get(record.classificationId),
1793
+ continuityObjectives: record.continuityObjectives,
1794
+ statusTransition: record.statusTransition,
1795
+ tags: record.tags,
1796
+ extensions: mergeMigrationExtension(record.extensions, {
1797
+ ...(record.subserviceVendorIds ? { subserviceVendorIds: record.subserviceVendorIds } : {})
1798
+ }),
1799
+ externalIds: record.externalIds
1800
+ });
1801
+ }
1802
+
1803
+ function componentKind(value, vendorId) {
1804
+ const kind = String(value || "").toLowerCase();
1805
+ if (["infrastructure", "software", "service", "network", "physical", "external-system", "interconnection"].includes(kind)) return kind;
1806
+ if (["application", "platform", "repository", "evidence-source"].includes(kind)) return "software";
1807
+ return vendorId ? "service" : "software";
1808
+ }
1809
+
1810
+ function migrateV4Vendor(record, informationTypes, classifications, reviewRequired) {
1811
+ const migrated = { ...record };
1812
+ const legacy = {};
1813
+ for (const field of ["service", "subprocessor", "backupVendorId"]) {
1814
+ if (!Object.hasOwn(migrated, field)) continue;
1815
+ legacy[field] = migrated[field];
1816
+ delete migrated[field];
1817
+ reviewRequired.push(classifiedChange(
1818
+ "review-required",
1819
+ record.id,
1820
+ field,
1821
+ field === "service"
1822
+ ? "Confirm that supplied capabilities are represented by factual Component records when they meet the inclusion rules."
1823
+ : field === "backupVendorId"
1824
+ ? "Move any real alternate supplier decision to the relevant Component or continuity plan."
1825
+ : "Decide subprocessor or subservice treatment only in the context where it applies; model v4 does not infer it."
1826
+ ));
1827
+ }
1828
+ migrated.informationTypeIds = normalizedInformationTypeIds(record.dataTypes, informationTypes);
1829
+ delete migrated.dataTypes;
1830
+ if (record.classificationId) migrated.classificationId = classifications.get(record.classificationId);
1831
+ migrated.extensions = mergeMigrationExtension(record.extensions, legacy);
1832
+ if (!Object.keys(migrated.extensions || {}).length) delete migrated.extensions;
1833
+ return migrated;
1834
+ }
1835
+
1836
+ function rewriteV4SystemRelationships(record, context) {
1837
+ const split = (ids = []) => ({
1838
+ systems: [...new Set(ids.filter((id) => context.systemKinds.get(id) === "system"))],
1839
+ components: [...new Set(ids.filter((id) => context.systemKinds.get(id) === "component"))]
1840
+ });
1841
+ const targetSystems = (componentIds) => [...new Set(componentIds.flatMap((id) => (
1842
+ (context.componentSystemUses.get(id) || []).map(({ systemId }) => systemId)
1843
+ )))];
1844
+ const setDualScope = (field = "systemIds") => {
1845
+ if (!Array.isArray(record[field])) return;
1846
+ const { systems, components } = split(record[field]);
1847
+ record[field] = [...new Set([...systems, ...targetSystems(components)])];
1848
+ if (components.length && !["audit", "commitment"].includes(context.byId.get(record.id)?.type)) {
1849
+ record.componentIds = [...new Set([...(record.componentIds || []), ...components])];
1850
+ }
1851
+ };
1852
+
1853
+ if (record.classificationId) record.classificationId = context.classifications.get(record.classificationId);
1854
+ if (record.type === "audit") record.programId = context.programId;
1855
+ if (record.type === "control-activity" && record.externalActivity?.systemId) {
1856
+ const oldId = record.externalActivity.systemId;
1857
+ if (context.systemKinds.get(oldId) === "component") {
1858
+ record.externalActivity = { ...record.externalActivity, componentId: oldId };
1859
+ delete record.externalActivity.systemId;
1860
+ } else requireComponentDecision(record, "externalActivity.systemId", context, "Choose the external authority Component for this Control Activity.");
1861
+ }
1862
+ if (record.type === "collection-review") {
1863
+ record.scopeResourceIds = [...new Set((record.scopeResourceIds || []).flatMap((id) => (
1864
+ id === context.workspaceId
1865
+ ? [context.programId]
1866
+ : context.systemKinds.get(id) === "component"
1867
+ ? [id]
1868
+ : [id]
1869
+ )))];
1870
+ }
1871
+ if (record.type === "source-coverage") {
1872
+ if (record.coverageKind === "external-system") record.coverageKind = "external-component";
1873
+ if (record.systemId) {
1874
+ if (context.systemKinds.get(record.systemId) === "component") record.componentId = record.systemId;
1875
+ else requireComponentDecision(record, "systemId", context, "Choose the authoritative Component for this source coverage.");
1876
+ delete record.systemId;
1877
+ }
1878
+ record.scopeResourceIds = (record.scopeResourceIds || []).map((id) => id === context.workspaceId ? context.programId : id);
1879
+ }
1880
+ if (record.type === "control") {
1881
+ const systems = split(record.systemIds || []);
1882
+ record.systemIds = [...new Set([...systems.systems, ...targetSystems(systems.components)])];
1883
+ record.componentIds = [...new Set([...(record.componentIds || []), ...systems.components])];
1884
+ const sources = split(record.evidenceSourceIds || []);
1885
+ record.evidenceSourceComponentIds = sources.components;
1886
+ for (const id of sources.systems) requireComponentDecision(record, "evidenceSourceIds", context, `Choose an evidence-source Component for bounded System "${id}".`);
1887
+ delete record.evidenceSourceIds;
1888
+ } else if (record.type === "asset") {
1889
+ const scope = split(record.systemIds || []);
1890
+ record.componentIds = scope.components;
1891
+ for (const id of scope.systems) requireComponentDecision(record, "systemIds", context, `Choose the Component represented by this Asset link to bounded System "${id}".`);
1892
+ delete record.systemIds;
1893
+ } else if (["access-review", "vulnerability-scan"].includes(record.type)) {
1894
+ const scope = split(record.systemIds || []);
1895
+ record.componentIds = scope.components;
1896
+ for (const id of scope.systems) requireComponentDecision(record, "systemIds", context, `Choose the Component for bounded System "${id}".`);
1897
+ delete record.systemIds;
1898
+ } else if (record.type === "access-grant" && record.systemId) {
1899
+ if (context.systemKinds.get(record.systemId) === "component") record.componentId = record.systemId;
1900
+ else requireComponentDecision(record, "systemId", context, "Choose the Component that grants this access.");
1901
+ delete record.systemId;
1902
+ } else if (record.type === "audit-population" && record.sourceSystemId) {
1903
+ if (context.systemKinds.get(record.sourceSystemId) === "component") record.sourceComponentId = record.sourceSystemId;
1904
+ else requireComponentDecision(record, "sourceSystemId", context, "Choose the authoritative source Component for this population.");
1905
+ delete record.sourceSystemId;
1906
+ } else if (record.type === "audit-request" && record.externalAuthoritySystemId) {
1907
+ if (context.systemKinds.get(record.externalAuthoritySystemId) === "component") record.externalAuthorityComponentId = record.externalAuthoritySystemId;
1908
+ else requireComponentDecision(record, "externalAuthoritySystemId", context, "Choose the external authority Component for this request.");
1909
+ delete record.externalAuthoritySystemId;
1910
+ } else if (record.type === "collection-review" && record.authoritativeSystemId) {
1911
+ if (context.systemKinds.get(record.authoritativeSystemId) === "component") record.authoritativeComponentId = record.authoritativeSystemId;
1912
+ else requireComponentDecision(record, "authoritativeSystemId", context, "Choose the authoritative Component for this collection review.");
1913
+ delete record.authoritativeSystemId;
1914
+ } else if (record.type === "evidence") {
1915
+ setDualScope();
1916
+ if (record.sourceSystemId) {
1917
+ if (context.systemKinds.get(record.sourceSystemId) === "component") {
1918
+ record.sourceComponentId = record.sourceSystemId;
1919
+ if (record.sourceKind === "system") record.sourceKind = "component";
1920
+ } else requireComponentDecision(record, "sourceSystemId", context, "Choose the authoritative source Component for this Evidence Artifact.");
1921
+ delete record.sourceSystemId;
1922
+ }
1923
+ } else if (record.type !== "workspace" && record.type !== "system" && record.type !== "component") {
1924
+ setDualScope();
1925
+ }
1926
+ if (record.type === "audit" && (record.subserviceVendorIds || []).length) {
1927
+ const method = record.subserviceMethod;
1928
+ const treatments = [];
1929
+ for (const vendorId of record.subserviceVendorIds) {
1930
+ const componentIds = [...context.systemKinds]
1931
+ .filter(([id, kind]) => kind === "component" && context.byId.get(id)?.vendorId === vendorId)
1932
+ .map(([id]) => id);
1933
+ if (["carve-out", "inclusive"].includes(method) && componentIds.length) {
1934
+ treatments.push({
1935
+ vendorId,
1936
+ componentIds,
1937
+ method,
1938
+ rationale: "Migrated from the explicit v3 Audit subservice scope and method; management must confirm the treatment."
1939
+ });
1940
+ context.reviewRequired.push(classifiedChange("review-required", record.id, "subserviceTreatments", "Confirm each migrated audit-time subservice treatment and rationale."));
1941
+ } else requireComponentDecision(record, "subserviceVendorIds", context, `Choose the Components and audit-time treatment for Vendor "${vendorId}".`);
1942
+ }
1943
+ if (treatments.length) record.subserviceTreatments = treatments;
1944
+ delete record.subserviceVendorIds;
1945
+ delete record.subserviceMethod;
1946
+ }
1947
+ return cleanUndefined(record);
1948
+ }
1949
+
1950
+ function requireComponentDecision(record, field, context, message) {
1951
+ context.unsupported.push(classifiedChange("unsupported", record.id, field, message));
1952
+ context.manualActions.push({ resourceId: record.id, field, message });
1953
+ }
1954
+
1955
+ function normalizedInformationTypeIds(values, informationTypes) {
1956
+ return [...new Set((values || []).map((value) => informationTypes.get(String(value).trim().toLowerCase())).filter(Boolean))];
1957
+ }
1958
+
1959
+ function mergeMigrationExtension(existing, legacy) {
1960
+ const useful = Object.fromEntries(Object.entries(legacy || {}).filter(([, value]) => value !== undefined));
1961
+ if (!Object.keys(useful).length) return existing;
1962
+ return {
1963
+ ...(existing || {}),
1964
+ "filegrc.migration": {
1965
+ ...(existing?.["filegrc.migration"] || {}),
1966
+ v3: useful
1967
+ }
1968
+ };
1969
+ }
1970
+
1971
+ function cleanUndefined(value) {
1972
+ return Object.fromEntries(Object.entries(value).filter(([, current]) => current !== undefined));
1973
+ }
1974
+
1975
+ function properTitle(value) {
1976
+ return String(value).split(/[-_\s]+/).filter(Boolean).map((part) => `${part[0].toUpperCase()}${part.slice(1)}`).join(" ");
1977
+ }
1978
+
1364
1979
  async function postMigrationAssessment(input) {
1365
1980
  const { assessWorkflow } = await import("./workflow.js");
1366
1981
  return assessWorkflow(input);
@@ -1388,9 +2003,10 @@ function migrateInverseArrays(resources, byId, editable, conflicts, mapping) {
1388
2003
  }
1389
2004
 
1390
2005
  function emptyPlan(version, targetVersion = V1_TARGET_MODEL_VERSION) {
1391
- const currentV3 = targetVersion === ACTIVE_MODEL_VERSION;
2006
+ const classifiedPlan = ["3", "4"].includes(String(targetVersion));
2007
+ const includesPathMoves = String(targetVersion) === "4";
1392
2008
  return {
1393
- schemaVersion: currentV3 ? 2 : 1,
2009
+ schemaVersion: classifiedPlan ? 2 : 1,
1394
2010
  sourceModelVersion: version,
1395
2011
  targetModelVersion: targetVersion,
1396
2012
  ready: true,
@@ -1398,7 +2014,7 @@ function emptyPlan(version, targetVersion = V1_TARGET_MODEL_VERSION) {
1398
2014
  conflicts: [],
1399
2015
  manualActions: [],
1400
2016
  notes: [],
1401
- ...(currentV3 ? {
2017
+ ...(classifiedPlan ? {
1402
2018
  classifications: {
1403
2019
  automatic: [],
1404
2020
  reviewRequired: [],
@@ -1406,11 +2022,12 @@ function emptyPlan(version, targetVersion = V1_TARGET_MODEL_VERSION) {
1406
2022
  },
1407
2023
  fileDiff: {
1408
2024
  create: [],
1409
- update: []
2025
+ update: [],
2026
+ ...(includesPathMoves ? { move: [] } : {})
1410
2027
  }
1411
2028
  } : {}),
1412
- summary: currentV3
1413
- ? { create: 0, update: 0, automatic: 0, reviewRequired: 0, unsupported: 0 }
2029
+ summary: classifiedPlan
2030
+ ? { create: 0, update: 0, ...(includesPathMoves ? { move: 0 } : {}), automatic: 0, reviewRequired: 0, unsupported: 0 }
1414
2031
  : { create: 0, update: 0 },
1415
2032
  changes: {
1416
2033
  create: [],