filegrc 0.4.0 → 0.5.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 +11 -3
- package/model/index.js +9 -5
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +53 -0
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +47 -6
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +418 -61
- package/src/collection-review.js +185 -0
- package/src/evidence-packet.js +34 -2
- package/src/external-reviewer.js +165 -0
- package/src/files.js +46 -10
- package/src/index.js +36 -2
- package/src/model-docs.js +15 -0
- package/src/model-migration.js +516 -21
- package/src/obligations.js +396 -13
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +49 -12
- package/src/program-readiness.js +331 -27
- package/src/reconciliation.js +277 -0
- package/src/server.js +242 -14
- package/src/setup.js +36 -4
- package/src/source-coverage.js +61 -0
- package/src/state.js +37 -1
- package/src/validate.js +100 -3
- package/src/web.js +961 -202
- package/src/workflow.js +1595 -0
package/src/model-migration.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ===
|
|
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(
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
876
|
-
const plan = await
|
|
877
|
-
if (plan.sourceModelVersion ===
|
|
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:
|
|
1395
|
+
targetModelVersion: targetVersion,
|
|
914
1396
|
ready: true,
|
|
915
1397
|
missing: [],
|
|
916
1398
|
conflicts: [],
|
|
917
1399
|
manualActions: [],
|
|
918
1400
|
notes: [],
|
|
919
|
-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|