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.
- package/README.md +14 -6
- package/model/index.js +4 -4
- package/model/v4.json +10052 -0
- package/package.json +2 -2
- package/src/agent.js +17 -5
- package/src/audit-preparation.js +17 -13
- package/src/audit-transition.js +7 -4
- package/src/batch-review.js +31 -8
- package/src/cli.js +43 -25
- package/src/collection-review.js +71 -25
- package/src/evidence-packet.js +76 -43
- package/src/external-reviewer.js +4 -4
- package/src/files.js +70 -6
- package/src/git.js +70 -13
- package/src/index.js +1 -0
- package/src/model-migration.js +631 -14
- package/src/obligations.js +14 -7
- package/src/program-path.js +49 -38
- package/src/program-readiness.js +94 -54
- package/src/program.js +52 -0
- package/src/reconciliation.js +2 -2
- package/src/server.js +50 -8
- package/src/setup.js +56 -21
- package/src/source-coverage.js +13 -9
- package/src/startup.js +1 -1
- package/src/state.js +17 -14
- package/src/timing.js +6 -0
- package/src/validate.js +100 -9
- package/src/web.js +709 -151
- package/src/workflow.js +47 -25
package/src/model-migration.js
CHANGED
|
@@ -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 ===
|
|
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(
|
|
952
|
+
const targetModel = loadModel("3");
|
|
950
953
|
const workspace = {
|
|
951
954
|
...loaded.workspace,
|
|
952
|
-
dataModelVersion:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
2006
|
+
const classifiedPlan = ["3", "4"].includes(String(targetVersion));
|
|
2007
|
+
const includesPathMoves = String(targetVersion) === "4";
|
|
1392
2008
|
return {
|
|
1393
|
-
schemaVersion:
|
|
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
|
-
...(
|
|
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:
|
|
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: [],
|