filegrc 0.4.0 → 0.5.1

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,13 +1,13 @@
1
1
  import { createResourceId } from "./id.js";
2
2
  import { applyResourceBatch, contentRevision } from "./files.js";
3
3
  import { loadWorkspace } from "./workspace.js";
4
- import { loadModel } from "../model/index.js";
4
+ import { ACTIVE_MODEL_VERSION, loadModel } from "../model/index.js";
5
5
  import { legacyCoverage } from "./coverage.js";
6
6
  import { readFile } from "node:fs/promises";
7
7
  import { resolveDataPath } from "./paths.js";
8
8
  import { markdownEntries } from "./resource-markdown.js";
9
9
 
10
- const TARGET_MODEL_VERSION = "2";
10
+ const V1_TARGET_MODEL_VERSION = "2";
11
11
  const EXTENSION_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
12
12
  const LEGACY_POLICY_OWNER_ROLE = "Policy Owner";
13
13
  const ACCOUNTABILITY_FIELDS = new Set(["ownerIds", "evidenceOwnerIds"]);
@@ -51,13 +51,13 @@ const STAGE_PAGE_ID_MIGRATIONS = new Map([
51
51
  ["scope:complementary-control", "controls:complementary-control"]
52
52
  ]);
53
53
 
54
- export async function planModelMigration(input = process.cwd(), options = {}) {
54
+ async function planV1ToV2Migration(input = process.cwd(), options = {}) {
55
55
  const loaded = await loadWorkspace(input);
56
56
  if (!loaded.workspace || !Object.hasOwn(loaded.workspace, "dataModelVersion")) {
57
57
  throw new Error("Model migration requires the Workspace record to declare dataModelVersion.");
58
58
  }
59
59
  const sourceVersion = String(loaded.workspace.dataModelVersion);
60
- if (sourceVersion === TARGET_MODEL_VERSION) {
60
+ if (sourceVersion === V1_TARGET_MODEL_VERSION) {
61
61
  return emptyPlan(sourceVersion);
62
62
  }
63
63
  if (sourceVersion !== "1") {
@@ -229,7 +229,7 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
229
229
  }
230
230
  }
231
231
 
232
- const targetModel = loadModel(TARGET_MODEL_VERSION);
232
+ const targetModel = loadModel(V1_TARGET_MODEL_VERSION);
233
233
  for (const record of loaded.resources.filter((candidate) => approvalBound(candidate))) {
234
234
  const migrated = editable(record);
235
235
  const revisions = {};
@@ -823,7 +823,7 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
823
823
  }
824
824
  }
825
825
 
826
- editable(loaded.workspace).dataModelVersion = TARGET_MODEL_VERSION;
826
+ editable(loaded.workspace).dataModelVersion = V1_TARGET_MODEL_VERSION;
827
827
  const migratedRecords = [
828
828
  ...loaded.resources.map((record) => updateById.get(record.id) || record),
829
829
  ...create
@@ -851,7 +851,7 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
851
851
  return {
852
852
  schemaVersion: 1,
853
853
  sourceModelVersion: sourceVersion,
854
- targetModelVersion: TARGET_MODEL_VERSION,
854
+ targetModelVersion: V1_TARGET_MODEL_VERSION,
855
855
  ready: !missing.length && !conflicts.length && !manualActions.length,
856
856
  missing,
857
857
  conflicts,
@@ -867,14 +867,15 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
867
867
  expectedRevisions: Object.fromEntries(
868
868
  update.map(({ id }) => [id, revisionById.get(id)])
869
869
  ),
870
- validateWholeWorkspace: true
870
+ validateWholeWorkspace: true,
871
+ targetModelVersion: V1_TARGET_MODEL_VERSION
871
872
  }
872
873
  };
873
874
  }
874
875
 
875
- export async function migrateModel(input = process.cwd(), options = {}) {
876
- const plan = await planModelMigration(input, options);
877
- if (plan.sourceModelVersion === TARGET_MODEL_VERSION) return { ...plan, applied: false };
876
+ async function migrateV1ToV2(input = process.cwd(), options = {}) {
877
+ const plan = await planV1ToV2Migration(input, options);
878
+ if (plan.sourceModelVersion === V1_TARGET_MODEL_VERSION) return { ...plan, applied: false };
878
879
  if (!plan.ready) {
879
880
  throw new Error(
880
881
  "Model migration needs review. Run `npx filegrc migrate --to-model 2 --preview --json` "
@@ -885,6 +886,486 @@ export async function migrateModel(input = process.cwd(), options = {}) {
885
886
  return { ...plan, applied: true, result };
886
887
  }
887
888
 
889
+ export async function planModelMigration(input = process.cwd(), options = {}) {
890
+ const loaded = await loadWorkspace(input);
891
+ const sourceVersion = String(loaded.workspace?.dataModelVersion || "");
892
+ const requestedTarget = options.targetModelVersion
893
+ ? String(options.targetModelVersion)
894
+ : sourceVersion === "1" ? V1_TARGET_MODEL_VERSION : ACTIVE_MODEL_VERSION;
895
+ if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
896
+ if (sourceVersion === "1" && requestedTarget === "2") {
897
+ return planV1ToV2Migration(input, options);
898
+ }
899
+ if (sourceVersion === "2" && requestedTarget === ACTIVE_MODEL_VERSION) {
900
+ return planV2ToV3Migration(loaded);
901
+ }
902
+ if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
903
+ throw new Error(
904
+ "Model v1 workspaces must migrate to model v2 first. "
905
+ + "Preview and apply `npx filegrc migrate --to-model 2`, then migrate to model v3."
906
+ );
907
+ }
908
+ throw new Error(`Model migration does not support v${sourceVersion} to v${requestedTarget}.`);
909
+ }
910
+
911
+ export async function migrateModel(input = process.cwd(), options = {}) {
912
+ const plan = await planModelMigration(input, options);
913
+ if (plan.sourceModelVersion === plan.targetModelVersion) {
914
+ return { ...plan, applied: false };
915
+ }
916
+ if (!plan.ready) {
917
+ throw new Error(
918
+ `Model migration needs review. Run \`npx filegrc migrate --to-model ${plan.targetModelVersion} --preview --json\` `
919
+ + "and resolve every missing value, conflict, manual action, and unsupported change."
920
+ );
921
+ }
922
+ if (plan.sourceModelVersion === "1" && plan.targetModelVersion === "2") {
923
+ return migrateV1ToV2(input, options);
924
+ }
925
+ const result = await applyResourceBatch(input, plan.changes);
926
+ return {
927
+ ...plan,
928
+ applied: true,
929
+ result,
930
+ postMigrationAssessment: await postMigrationAssessment(input)
931
+ };
932
+ }
933
+
934
+ async function planV2ToV3Migration(loaded) {
935
+ if (!loaded.workspace?.id) {
936
+ throw new Error("Model migration requires a valid Workspace record.");
937
+ }
938
+ const revisionById = new Map(loaded.entries.map((entry) => [
939
+ entry.record.id,
940
+ contentRevision(entry.source)
941
+ ]));
942
+ const updates = [];
943
+ const create = [];
944
+ const automatic = [];
945
+ const reviewRequired = [];
946
+ const unsupported = [];
947
+ const missing = [];
948
+ const manualActions = [];
949
+ const targetModel = loadModel(ACTIVE_MODEL_VERSION);
950
+ const workspace = {
951
+ ...loaded.workspace,
952
+ dataModelVersion: ACTIVE_MODEL_VERSION
953
+ };
954
+ automatic.push(classifiedChange(
955
+ "automatic",
956
+ loaded.workspace.id,
957
+ "dataModelVersion",
958
+ "Select model v3."
959
+ ));
960
+
961
+ const renderer = loaded.resources.find(({ type }) => type === "renderer-settings");
962
+ if (renderer && Object.hasOwn(renderer, "completedStagePageIds")) {
963
+ const migrated = { ...renderer };
964
+ delete migrated.completedStagePageIds;
965
+ updates.push(migrated);
966
+ automatic.push(classifiedChange(
967
+ "automatic",
968
+ renderer.id,
969
+ "completedStagePageIds",
970
+ "Remove obsolete manual Step-page completion state. Model v3 derives page completion."
971
+ ));
972
+ }
973
+
974
+ for (const [appointmentKind, template] of Object.entries(targetModel.appointmentTemplates || {})) {
975
+ const { title, responsibilities } = template;
976
+ if (loaded.resources.some((record) => (
977
+ record.type === "appointment"
978
+ && record.appointmentKind === appointmentKind
979
+ && record.status !== "ended"
980
+ ))) continue;
981
+ const appointment = {
982
+ id: createResourceId("appointment", title, [
983
+ ...loaded.resources.map(({ id }) => id),
984
+ ...create.map(({ id }) => id)
985
+ ]),
986
+ type: "appointment",
987
+ title,
988
+ status: "planned",
989
+ appointmentKind,
990
+ scopeResourceIds: [loaded.workspace.id],
991
+ responsibilities
992
+ };
993
+ create.push(appointment);
994
+ automatic.push(classifiedChange(
995
+ "automatic",
996
+ appointment.id,
997
+ null,
998
+ `Create a planned ${title} Appointment without inventing a holder or effective date.`
999
+ ));
1000
+ reviewRequired.push(classifiedChange(
1001
+ "review-required",
1002
+ appointment.id,
1003
+ "holderId",
1004
+ `Assign and activate the ${title} Appointment when management selects the holder.`
1005
+ ));
1006
+ }
1007
+
1008
+ for (const [resourceType, configuration] of Object.entries(targetModel.collectionReviews || {})) {
1009
+ if (loaded.resources.some((record) => (
1010
+ record.type === "collection-review"
1011
+ && record.resourceType === resourceType
1012
+ && record.status !== "retired"
1013
+ ))) continue;
1014
+ const review = {
1015
+ id: createResourceId("collection-review", `${configuration.title} review`, [
1016
+ ...loaded.resources.map(({ id }) => id),
1017
+ ...create.map(({ id }) => id)
1018
+ ]),
1019
+ type: "collection-review",
1020
+ title: `${configuration.title} review`,
1021
+ status: "planned",
1022
+ resourceType,
1023
+ scopeResourceIds: [loaded.workspace.id]
1024
+ };
1025
+ create.push(review);
1026
+ automatic.push(classifiedChange(
1027
+ "automatic",
1028
+ review.id,
1029
+ null,
1030
+ `Create a planned ${configuration.title} confirmation without asserting that management reviewed the collection.`
1031
+ ));
1032
+ reviewRequired.push(classifiedChange(
1033
+ "review-required",
1034
+ review.id,
1035
+ "decision",
1036
+ configuration.description
1037
+ ));
1038
+ }
1039
+
1040
+ const sourceOwner = [...loaded.resources, ...create].find((record) => (
1041
+ record.type === "appointment"
1042
+ && record.appointmentKind === "policy-owner"
1043
+ && record.status !== "ended"
1044
+ ));
1045
+ if (sourceOwner) {
1046
+ for (const family of targetModel.evidenceSourceFamilies || []) {
1047
+ const record = {
1048
+ id: createResourceId("source-coverage", `${family.title} coverage`, [
1049
+ ...loaded.resources.map(({ id }) => id),
1050
+ ...create.map(({ id }) => id)
1051
+ ]),
1052
+ type: "source-coverage",
1053
+ title: `${family.title} coverage`,
1054
+ status: "planned",
1055
+ sourceFamilyId: family.id,
1056
+ coverageKind: family.filegrcManaged ? "filegrc" : "external-system",
1057
+ scopeResourceIds: [loaded.workspace.id],
1058
+ ownerIds: [sourceOwner.id]
1059
+ };
1060
+ create.push(record);
1061
+ automatic.push(classifiedChange(
1062
+ "automatic",
1063
+ record.id,
1064
+ null,
1065
+ `Create a planned ${family.title} source-coverage prompt without asserting that the proposed path is final.`
1066
+ ));
1067
+ reviewRequired.push(classifiedChange(
1068
+ "review-required",
1069
+ record.id,
1070
+ "coverageKind",
1071
+ `Confirm the authoritative recordkeeping path for ${family.title}.`
1072
+ ));
1073
+ }
1074
+ }
1075
+
1076
+ for (const record of loaded.resources) {
1077
+ collectV3ReviewItems(record, reviewRequired);
1078
+ if (
1079
+ ["policy", "document"].includes(record.type)
1080
+ && ["draft", "in-review", "approved"].includes(record.status)
1081
+ && record.effectiveOn
1082
+ ) {
1083
+ const migrated = {
1084
+ ...record,
1085
+ proposedEffectiveOn: record.effectiveOn
1086
+ };
1087
+ delete migrated.effectiveOn;
1088
+ updates.push(migrated);
1089
+ automatic.push(classifiedChange(
1090
+ "automatic",
1091
+ record.id,
1092
+ "effectiveOn",
1093
+ "Move the non-active effective date to proposedEffectiveOn so it remains a proposal in model v3."
1094
+ ));
1095
+ }
1096
+ if (
1097
+ record.type === "obligation"
1098
+ && record.recurrence?.mode === "event"
1099
+ && record.recurrence.eventType === "high-risk-person-ended"
1100
+ ) {
1101
+ updates.push({
1102
+ ...record,
1103
+ recurrence: { ...record.recurrence, eventType: "person-ended" },
1104
+ eventRiskLevels: ["high"]
1105
+ });
1106
+ automatic.push(classifiedChange(
1107
+ "automatic",
1108
+ record.id,
1109
+ "recurrence.eventType",
1110
+ "Merge the high-risk departure trigger into person-ended and retain the high-risk filter."
1111
+ ));
1112
+ }
1113
+ if (
1114
+ record.type === "obligation-event"
1115
+ && ["person-ended", "high-risk-person-ended"].includes(record.eventType)
1116
+ ) {
1117
+ updates.push({
1118
+ ...record,
1119
+ eventType: "person-ended",
1120
+ riskLevel: record.eventType === "high-risk-person-ended" ? "high" : "normal"
1121
+ });
1122
+ automatic.push(classifiedChange(
1123
+ "automatic",
1124
+ record.id,
1125
+ "riskLevel",
1126
+ "Record the departure risk level required by the unified person-ended workflow."
1127
+ ));
1128
+ }
1129
+ }
1130
+ for (const family of targetModel.evidenceSourceFamilies || []) {
1131
+ reviewRequired.push(classifiedChange(
1132
+ "review-required",
1133
+ loaded.workspace.id,
1134
+ `sourceCoverage.${family.id}`,
1135
+ `Review whether the ${family.title} source family applies and identify its authoritative recordkeeping path.`
1136
+ ));
1137
+ }
1138
+
1139
+ updates.push(workspace);
1140
+ const updatedById = new Map(updates.map((record) => [record.id, record]));
1141
+ const migratedRecords = [
1142
+ ...loaded.resources.map((record) => updatedById.get(record.id) || record),
1143
+ ...create
1144
+ ];
1145
+ collectModelShapeActions(migratedRecords, targetModel, missing, manualActions);
1146
+ collectRelationshipConstraintActions(migratedRecords, targetModel, manualActions);
1147
+ collectRelationTypeActions(migratedRecords, targetModel, manualActions);
1148
+ collectV3CompatibilityActions(migratedRecords, targetModel, missing, manualActions);
1149
+ await collectTargetValidationActions(
1150
+ loaded,
1151
+ migratedRecords,
1152
+ targetModel,
1153
+ missing,
1154
+ manualActions
1155
+ );
1156
+ for (const item of missing) {
1157
+ unsupported.push(classifiedChange(
1158
+ "unsupported",
1159
+ item.resourceId,
1160
+ item.field,
1161
+ `Record ${item.field} before applying the model v3 migration.`
1162
+ ));
1163
+ }
1164
+ for (const item of manualActions) {
1165
+ unsupported.push(classifiedChange(
1166
+ "unsupported",
1167
+ item.resourceId,
1168
+ item.field,
1169
+ item.message
1170
+ ));
1171
+ }
1172
+ return {
1173
+ schemaVersion: 2,
1174
+ sourceModelVersion: "2",
1175
+ targetModelVersion: ACTIVE_MODEL_VERSION,
1176
+ ready: unsupported.length === 0,
1177
+ missing,
1178
+ conflicts: [],
1179
+ manualActions,
1180
+ classifications: {
1181
+ automatic,
1182
+ reviewRequired,
1183
+ unsupported
1184
+ },
1185
+ notes: reviewRequired,
1186
+ summary: {
1187
+ create: create.length,
1188
+ update: updates.length,
1189
+ automatic: automatic.length,
1190
+ reviewRequired: reviewRequired.length,
1191
+ unsupported: unsupported.length
1192
+ },
1193
+ fileDiff: {
1194
+ create: create.map((record) => ({ type: record.type, id: record.id, after: record })),
1195
+ update: updates.map((record) => {
1196
+ const before = loaded.resources.find(({ id }) => id === record.id);
1197
+ return { type: record.type, id: record.id, before, after: record };
1198
+ })
1199
+ },
1200
+ changes: {
1201
+ create,
1202
+ update: updates,
1203
+ expectedRevisions: Object.fromEntries(
1204
+ updates.map(({ id }) => [id, revisionById.get(id)])
1205
+ ),
1206
+ validateWholeWorkspace: true,
1207
+ targetModelVersion: ACTIVE_MODEL_VERSION
1208
+ }
1209
+ };
1210
+ }
1211
+
1212
+ async function collectTargetValidationActions(loaded, records, model, missing, manualActions) {
1213
+ const { validateWorkspace } = await import("./validate.js");
1214
+ const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
1215
+ const entries = records.map((record) => {
1216
+ const existing = existingById.get(record.id);
1217
+ const definition = model.resources[record.type];
1218
+ const recordPath = definition.singleton
1219
+ || `${definition.collection}/${(definition.recordPath || "{id}.json").replaceAll("{id}", record.id)}`;
1220
+ const source = `${JSON.stringify(record, null, 2)}\n`;
1221
+ return {
1222
+ ...existing,
1223
+ path: existing?.path,
1224
+ relativePath: existing?.relativePath || recordPath,
1225
+ record,
1226
+ source,
1227
+ revision: contentRevision(source)
1228
+ };
1229
+ });
1230
+ const candidate = {
1231
+ ...loaded,
1232
+ model,
1233
+ workspace: records.find((record) => record.type === "workspace"),
1234
+ resources: records,
1235
+ entries,
1236
+ diagnostics: []
1237
+ };
1238
+ const validation = await validateWorkspace(candidate);
1239
+ const recordIdByPath = new Map(entries.map((entry) => [
1240
+ `data/${entry.relativePath}`,
1241
+ entry.record.id
1242
+ ]));
1243
+ const reported = new Set([
1244
+ ...missing.map(({ resourceId, field }) => `${resourceId}:${field}`),
1245
+ ...manualActions.map(({ resourceId, field }) => `${resourceId}:${field}`)
1246
+ ]);
1247
+ for (const diagnostic of validation.diagnostics.filter(({ severity }) => severity === "error")) {
1248
+ const resourceId = recordIdByPath.get(diagnostic.path) || candidate.workspace.id;
1249
+ const field = targetValidationField(diagnostic);
1250
+ const key = `${resourceId}:${field}`;
1251
+ if (reported.has(key)) continue;
1252
+ manualActions.push({
1253
+ resourceId,
1254
+ field,
1255
+ message: diagnostic.message
1256
+ });
1257
+ reported.add(key);
1258
+ }
1259
+ }
1260
+
1261
+ function targetValidationField(diagnostic) {
1262
+ const quoted = diagnostic.message.match(/(?:Required field|Field) "([^"]+)"/)?.[1];
1263
+ if (quoted) return quoted;
1264
+ const prefix = diagnostic.message.match(/^([a-zA-Z][a-zA-Z0-9.[\]-]*)(?::| is not allowed)/)?.[1];
1265
+ return prefix || diagnostic.code;
1266
+ }
1267
+
1268
+ function collectV3ReviewItems(record, items) {
1269
+ if (
1270
+ ["requirement", "commitment", "complementary-control", "control"].includes(record.type)
1271
+ && !record.applicabilityReview
1272
+ ) {
1273
+ items.push(classifiedChange(
1274
+ "review-required",
1275
+ record.id,
1276
+ "applicabilityReview",
1277
+ "Review applicability against the current scope and bind the decision to its Git revision."
1278
+ ));
1279
+ }
1280
+ if (
1281
+ ["policy", "document"].includes(record.type)
1282
+ && ["draft", "in-review", "approved"].includes(record.status)
1283
+ && record.effectiveOn
1284
+ ) {
1285
+ items.push(classifiedChange(
1286
+ "review-required",
1287
+ record.id,
1288
+ "proposedEffectiveOn",
1289
+ "Confirm the proposed effective date before this content becomes active."
1290
+ ));
1291
+ }
1292
+ const requested = {
1293
+ risk: ["treatmentTargetOn", "reviewDueOn"],
1294
+ vulnerability: ["confirmedOn", "severityAssignedOn", "targetRemediationOn"],
1295
+ "access-grant": ["businessNeed"],
1296
+ "service-account": ["authenticationMethod", "reviewDueOn"]
1297
+ }[record.type] || [];
1298
+ for (const field of requested) {
1299
+ if (record[field] !== undefined && record[field] !== null && record[field] !== "") continue;
1300
+ items.push(classifiedChange(
1301
+ "review-required",
1302
+ record.id,
1303
+ field,
1304
+ `Review and record ${field} when it applies to this ${record.type}.`
1305
+ ));
1306
+ }
1307
+ }
1308
+
1309
+ function collectV3CompatibilityActions(records, model, missing, manualActions) {
1310
+ const byId = new Map(records.map((record) => [record.id, record]));
1311
+ const reported = new Set(manualActions.map(({ resourceId, field }) => `${resourceId}:${field}`));
1312
+ const reportedMissing = new Set(missing.map(({ resourceId, field }) => `${resourceId}:${field}`));
1313
+ for (const record of records) {
1314
+ if (
1315
+ record.type === "action-item"
1316
+ && record.status === "done"
1317
+ && record.obligationId
1318
+ ) {
1319
+ const obligation = byId.get(record.obligationId);
1320
+ const expectedTypes = obligation?.type === "obligation"
1321
+ ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
1322
+ : [];
1323
+ const linked = (record.completionResourceIds || [])
1324
+ .map((id) => byId.get(id))
1325
+ .filter(Boolean);
1326
+ if (expectedTypes.length && !linked.some((item) => expectedTypes.includes(item.type))) {
1327
+ const field = "completionResourceIds";
1328
+ const key = `${record.id}:${field}`;
1329
+ if (!reported.has(key)) {
1330
+ manualActions.push({
1331
+ resourceId: record.id,
1332
+ field,
1333
+ value: record.completionResourceIds || [],
1334
+ message: `Link the completion record required by Obligation "${record.obligationId}" before applying model v3.`
1335
+ });
1336
+ reported.add(key);
1337
+ }
1338
+ }
1339
+ }
1340
+ if (
1341
+ record.type === "action-item"
1342
+ && record.status === "blocked"
1343
+ && migrationValueMissing(record.blockingResourceIds)
1344
+ ) {
1345
+ const field = "blockingResourceIds";
1346
+ const key = `${record.id}:${field}`;
1347
+ if (!reportedMissing.has(key)) {
1348
+ missing.push({ resourceId: record.id, field });
1349
+ reportedMissing.add(key);
1350
+ }
1351
+ }
1352
+ }
1353
+ }
1354
+
1355
+ function classifiedChange(classification, resourceId, field, message) {
1356
+ return {
1357
+ classification,
1358
+ resourceId,
1359
+ ...(field ? { field } : {}),
1360
+ message
1361
+ };
1362
+ }
1363
+
1364
+ async function postMigrationAssessment(input) {
1365
+ const { assessWorkflow } = await import("./workflow.js");
1366
+ return assessWorkflow(input);
1367
+ }
1368
+
888
1369
  function migrateInverseArrays(resources, byId, editable, conflicts, mapping) {
889
1370
  for (const source of resources.filter(({ type }) => type === mapping.sourceType)) {
890
1371
  if (!Array.isArray(source[mapping.sourceField])) continue;
@@ -906,17 +1387,31 @@ function migrateInverseArrays(resources, byId, editable, conflicts, mapping) {
906
1387
  }
907
1388
  }
908
1389
 
909
- function emptyPlan(version) {
1390
+ function emptyPlan(version, targetVersion = V1_TARGET_MODEL_VERSION) {
1391
+ const currentV3 = targetVersion === ACTIVE_MODEL_VERSION;
910
1392
  return {
911
- schemaVersion: 1,
1393
+ schemaVersion: currentV3 ? 2 : 1,
912
1394
  sourceModelVersion: version,
913
- targetModelVersion: TARGET_MODEL_VERSION,
1395
+ targetModelVersion: targetVersion,
914
1396
  ready: true,
915
1397
  missing: [],
916
1398
  conflicts: [],
917
1399
  manualActions: [],
918
1400
  notes: [],
919
- summary: { create: 0, update: 0 },
1401
+ ...(currentV3 ? {
1402
+ classifications: {
1403
+ automatic: [],
1404
+ reviewRequired: [],
1405
+ unsupported: []
1406
+ },
1407
+ fileDiff: {
1408
+ create: [],
1409
+ update: []
1410
+ }
1411
+ } : {}),
1412
+ summary: currentV3
1413
+ ? { create: 0, update: 0, automatic: 0, reviewRequired: 0, unsupported: 0 }
1414
+ : { create: 0, update: 0 },
920
1415
  changes: {
921
1416
  create: [],
922
1417
  update: [],
@@ -1167,7 +1662,7 @@ function collectModelShapeActions(records, model, missing, manualActions) {
1167
1662
  resourceId: record.id,
1168
1663
  field: name,
1169
1664
  value: record[name],
1170
- message: `Field "${name}" is not part of model v2. Move organization-specific data under a namespaced extensions object or remove the obsolete field.`
1665
+ message: `Field "${name}" is not part of model v${model.modelVersion}. Move organization-specific data under a namespaced extensions object or remove the obsolete field.`
1171
1666
  });
1172
1667
  reportedManual.add(key);
1173
1668
  }
@@ -1183,7 +1678,7 @@ function collectModelShapeActions(records, model, missing, manualActions) {
1183
1678
  resourceId: record.id,
1184
1679
  field: name,
1185
1680
  value: record[name],
1186
- message: `Field "${name}" is not allowed for the selected ${Object.keys(field.allowedWhen).join(" and ")} in model v2.`
1681
+ message: `Field "${name}" is not allowed for the selected ${Object.keys(field.allowedWhen).join(" and ")} in model v${model.modelVersion}.`
1187
1682
  });
1188
1683
  reportedManual.add(key);
1189
1684
  }
@@ -1239,7 +1734,7 @@ function collectRelationshipConstraintActions(records, model, manualActions) {
1239
1734
  resourceId: record.id,
1240
1735
  field: constraint.field,
1241
1736
  value: record[constraint.field],
1242
- message: `Break the model-v2 relationship cycle: ${cycle.join(" -> ")}.`
1737
+ message: `Break the model-v${model.modelVersion} relationship cycle: ${cycle.join(" -> ")}.`
1243
1738
  });
1244
1739
  reported.add(key);
1245
1740
  }
@@ -1296,7 +1791,7 @@ function collectRelationTypeActions(records, model, manualActions) {
1296
1791
  resourceId,
1297
1792
  field: fieldName,
1298
1793
  value: invalid,
1299
- message: `Replace IDs whose resource type is not allowed by model v2 (${field.relation.join(" or ")}).`
1794
+ message: `Replace IDs whose resource type is not allowed by model v${model.modelVersion} (${field.relation.join(" or ")}).`
1300
1795
  });
1301
1796
  reported.add(key);
1302
1797
  };
@@ -1373,7 +1868,7 @@ function collectObjectShapeActions(
1373
1868
  resourceId,
1374
1869
  field,
1375
1870
  value: value[name],
1376
- message: `Nested field "${field}" is not allowed for the selected ${Object.keys(property.allowedWhen).join(" and ")} in model v2.`
1871
+ message: `Nested field "${field}" is not allowed for the selected ${Object.keys(property.allowedWhen).join(" and ")} in model v${model.modelVersion}.`
1377
1872
  });
1378
1873
  reportedManual.add(key);
1379
1874
  }
@@ -1421,7 +1916,7 @@ function collectObjectShapeActions(
1421
1916
  resourceId,
1422
1917
  field,
1423
1918
  value: nested,
1424
- message: `Nested field "${field}" is not part of model v2. Move organization-specific data under extensions or replace it with a defined property.`
1919
+ message: `Nested field "${field}" is not part of model v${model.modelVersion}. Move organization-specific data under extensions or replace it with a defined property.`
1425
1920
  });
1426
1921
  reportedManual.add(key);
1427
1922
  }