filegrc 0.3.3 → 0.4.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 +17 -7
- package/model/index.js +37 -3
- package/model/v1.json +89 -52
- package/model/v2.json +8022 -0
- package/package.json +1 -1
- package/src/agent.js +36 -8
- package/src/audit-preparation.js +63 -60
- package/src/cli.js +176 -113
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +115 -75
- package/src/files.js +230 -28
- package/src/git-name.js +16 -0
- package/src/git.js +702 -6
- package/src/index.js +9 -6
- package/src/model-docs.js +88 -7
- package/src/model-migration.js +1463 -0
- package/src/mutation.js +46 -1
- package/src/obligations.js +108 -84
- package/src/parties.js +17 -2
- package/src/program-path.js +31 -58
- package/src/program-readiness.js +142 -106
- package/src/resource-status.js +17 -0
- package/src/server.js +175 -36
- package/src/setup.js +27 -28
- package/src/state.js +93 -27
- package/src/timing.js +41 -0
- package/src/validate.js +611 -43
- package/src/web.js +586 -141
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
|
@@ -0,0 +1,1463 @@
|
|
|
1
|
+
import { createResourceId } from "./id.js";
|
|
2
|
+
import { applyResourceBatch, contentRevision } from "./files.js";
|
|
3
|
+
import { loadWorkspace } from "./workspace.js";
|
|
4
|
+
import { loadModel } from "../model/index.js";
|
|
5
|
+
import { legacyCoverage } from "./coverage.js";
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { resolveDataPath } from "./paths.js";
|
|
8
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
9
|
+
|
|
10
|
+
const TARGET_MODEL_VERSION = "2";
|
|
11
|
+
const EXTENSION_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
12
|
+
const LEGACY_POLICY_OWNER_ROLE = "Policy Owner";
|
|
13
|
+
const ACCOUNTABILITY_FIELDS = new Set(["ownerIds", "evidenceOwnerIds"]);
|
|
14
|
+
const OWNER_RESOURCE_TYPES = new Set([
|
|
15
|
+
"service-account", "system", "asset", "document", "obligation", "obligation-event",
|
|
16
|
+
"commitment", "control", "finding", "exception", "policy", "training", "risk", "vendor",
|
|
17
|
+
"vulnerability", "incident", "penetration-test", "data-request", "audit",
|
|
18
|
+
"audit-population", "audit-request"
|
|
19
|
+
]);
|
|
20
|
+
const CADENCE_MIGRATIONS = new Map([
|
|
21
|
+
["team:meetingCadence", {
|
|
22
|
+
activityType: "oversight-meeting",
|
|
23
|
+
relation: "scope"
|
|
24
|
+
}],
|
|
25
|
+
["document:reviewCadence", {
|
|
26
|
+
activityType: "document-review",
|
|
27
|
+
relation: "template"
|
|
28
|
+
}],
|
|
29
|
+
["exception:reviewCadence", {
|
|
30
|
+
activityType: "exception-review",
|
|
31
|
+
relation: "scope"
|
|
32
|
+
}],
|
|
33
|
+
["policy:reviewCadence", {
|
|
34
|
+
activityType: "policy-review",
|
|
35
|
+
relation: "template"
|
|
36
|
+
}],
|
|
37
|
+
["training:recurrence", {
|
|
38
|
+
activityType: "training",
|
|
39
|
+
relation: "template"
|
|
40
|
+
}],
|
|
41
|
+
["risk:reviewCadence", {
|
|
42
|
+
activityType: "risk-assessment",
|
|
43
|
+
relation: "scope"
|
|
44
|
+
}],
|
|
45
|
+
["vendor:reviewCadence", {
|
|
46
|
+
activityType: "vendor-review",
|
|
47
|
+
relation: "scope"
|
|
48
|
+
}]
|
|
49
|
+
]);
|
|
50
|
+
const STAGE_PAGE_ID_MIGRATIONS = new Map([
|
|
51
|
+
["scope:complementary-control", "controls:complementary-control"]
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
export async function planModelMigration(input = process.cwd(), options = {}) {
|
|
55
|
+
const loaded = await loadWorkspace(input);
|
|
56
|
+
if (!loaded.workspace || !Object.hasOwn(loaded.workspace, "dataModelVersion")) {
|
|
57
|
+
throw new Error("Model migration requires the Workspace record to declare dataModelVersion.");
|
|
58
|
+
}
|
|
59
|
+
const sourceVersion = String(loaded.workspace.dataModelVersion);
|
|
60
|
+
if (sourceVersion === TARGET_MODEL_VERSION) {
|
|
61
|
+
return emptyPlan(sourceVersion);
|
|
62
|
+
}
|
|
63
|
+
if (sourceVersion !== "1") {
|
|
64
|
+
throw new Error(`Model migration supports v1 workspaces, not v${sourceVersion}.`);
|
|
65
|
+
}
|
|
66
|
+
if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
|
|
67
|
+
|
|
68
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
69
|
+
const revisionById = new Map(loaded.entries.map((entry) => [
|
|
70
|
+
entry.record.id,
|
|
71
|
+
contentRevision(entry.source)
|
|
72
|
+
]));
|
|
73
|
+
const updateById = new Map();
|
|
74
|
+
const create = [];
|
|
75
|
+
const missing = [];
|
|
76
|
+
const conflicts = [];
|
|
77
|
+
const manualActions = [];
|
|
78
|
+
const notes = [];
|
|
79
|
+
const usedIds = loaded.resources.map(({ id }) => id);
|
|
80
|
+
const policyOwnerCount = loaded.resources.filter((record) => (
|
|
81
|
+
record.type === "person"
|
|
82
|
+
&& String(record.role || "").trim() === LEGACY_POLICY_OWNER_ROLE
|
|
83
|
+
)).length;
|
|
84
|
+
|
|
85
|
+
const editable = (record) => {
|
|
86
|
+
if (!record) return null;
|
|
87
|
+
if (!updateById.has(record.id)) updateById.set(record.id, structuredClone(record));
|
|
88
|
+
return updateById.get(record.id);
|
|
89
|
+
};
|
|
90
|
+
const addIds = (record, field, ids) => {
|
|
91
|
+
const current = Array.isArray(record[field]) ? record[field] : [];
|
|
92
|
+
record[field] = [...new Set([...current, ...ids])];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
for (const record of loaded.resources) {
|
|
96
|
+
if (Object.hasOwn(record, "schemaVersion")) delete editable(record).schemaVersion;
|
|
97
|
+
if (Array.isArray(record.ownerIds) && !OWNER_RESOURCE_TYPES.has(record.type)) {
|
|
98
|
+
manualActions.push({
|
|
99
|
+
resourceId: record.id,
|
|
100
|
+
field: "ownerIds",
|
|
101
|
+
value: record.ownerIds,
|
|
102
|
+
message: `Model v2 does not define generic ownership for ${record.type}. Move these IDs to the type-specific accountable, performer, reviewer, collector, or approver field, then remove ownerIds.`
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const workspace = editable(loaded.workspace);
|
|
108
|
+
const scopedSystemIds = new Set(workspace.systemIds || []);
|
|
109
|
+
for (const system of loaded.resources.filter(({ type }) => type === "system")) {
|
|
110
|
+
if (system.inScope === true) scopedSystemIds.add(system.id);
|
|
111
|
+
if (Object.hasOwn(system, "inScope")) delete editable(system).inScope;
|
|
112
|
+
}
|
|
113
|
+
workspace.systemIds = [...scopedSystemIds];
|
|
114
|
+
delete workspace.repositoryUrl;
|
|
115
|
+
|
|
116
|
+
const classificationIds = migrateClassificationDefinitions(
|
|
117
|
+
workspace,
|
|
118
|
+
loaded.workspace.classificationDefinitions,
|
|
119
|
+
conflicts
|
|
120
|
+
);
|
|
121
|
+
for (const record of loaded.resources) {
|
|
122
|
+
const oldField = Object.hasOwn(record, "dataClassification")
|
|
123
|
+
? "dataClassification"
|
|
124
|
+
: Object.hasOwn(record, "classification")
|
|
125
|
+
? "classification"
|
|
126
|
+
: null;
|
|
127
|
+
if (!oldField) continue;
|
|
128
|
+
const migrated = editable(record);
|
|
129
|
+
const classificationId = resolveClassificationId(record[oldField], classificationIds);
|
|
130
|
+
if (classificationId) migrated.classificationId = classificationId;
|
|
131
|
+
else {
|
|
132
|
+
manualActions.push({
|
|
133
|
+
resourceId: record.id,
|
|
134
|
+
field: oldField,
|
|
135
|
+
value: record[oldField],
|
|
136
|
+
message: "Map this value to one of the Workspace classificationDefinitions IDs and store it as classificationId."
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
delete migrated[oldField];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
migrateCoverageFields(workspace, loaded.workspace, {
|
|
143
|
+
target: "candidateCoverage",
|
|
144
|
+
asOfFields: ["candidateTypeOneAsOf"],
|
|
145
|
+
startFields: ["candidatePeriodStart"],
|
|
146
|
+
endFields: ["candidatePeriodEnd"]
|
|
147
|
+
}, missing, manualActions);
|
|
148
|
+
for (const record of loaded.resources) {
|
|
149
|
+
const settings = coverageMigrationSettings(record);
|
|
150
|
+
if (settings) migrateCoverageFields(editable(record), record, settings, missing, manualActions);
|
|
151
|
+
if (record.type === "evidence" && record.capture && typeof record.capture === "object") {
|
|
152
|
+
const capture = structuredClone(record.capture);
|
|
153
|
+
migrateCoverageFields(capture, record.capture, {
|
|
154
|
+
target: "coverage",
|
|
155
|
+
startFields: ["periodStart"],
|
|
156
|
+
endFields: ["periodEnd"]
|
|
157
|
+
}, missing, manualActions, record.id, "capture.");
|
|
158
|
+
editable(record).capture = capture;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const person of loaded.resources.filter(({ type }) => type === "person")) {
|
|
163
|
+
const migratedPerson = editable(person);
|
|
164
|
+
migratedPerson.affiliation = person.status === "external" ? "external" : "internal";
|
|
165
|
+
if (person.status === "external") migratedPerson.status = "active";
|
|
166
|
+
if (Array.isArray(person.teamIds)) {
|
|
167
|
+
for (const teamId of person.teamIds) {
|
|
168
|
+
const team = byId.get(teamId);
|
|
169
|
+
if (team?.type !== "team") {
|
|
170
|
+
conflicts.push({
|
|
171
|
+
resourceId: person.id,
|
|
172
|
+
field: "teamIds",
|
|
173
|
+
message: `Team "${teamId}" was not found.`
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
addIds(editable(team), "memberIds", [person.id]);
|
|
178
|
+
}
|
|
179
|
+
delete editable(person).teamIds;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const role = String(person.role || "").trim();
|
|
183
|
+
if (!role) continue;
|
|
184
|
+
if (role !== LEGACY_POLICY_OWNER_ROLE) {
|
|
185
|
+
if (String(person.jobTitle || "").trim() === role) {
|
|
186
|
+
delete editable(person).role;
|
|
187
|
+
} else {
|
|
188
|
+
manualActions.push({
|
|
189
|
+
resourceId: person.id,
|
|
190
|
+
field: "role",
|
|
191
|
+
value: role,
|
|
192
|
+
message: "Set the actual organization jobTitle and create any named Appointment this value represented, then remove role."
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const existingAppointment = loaded.resources.find((record) => (
|
|
199
|
+
record.type === "appointment"
|
|
200
|
+
&& record.appointmentKind === "policy-owner"
|
|
201
|
+
&& record.holderId === person.id
|
|
202
|
+
&& record.status === "active"
|
|
203
|
+
));
|
|
204
|
+
const appointment = existingAppointment || {
|
|
205
|
+
id: createResourceId("appointment", "Policy Owner", [...usedIds, ...create.map(({ id }) => id)]),
|
|
206
|
+
type: "appointment",
|
|
207
|
+
title: "Policy Owner",
|
|
208
|
+
status: "active",
|
|
209
|
+
appointmentKind: "policy-owner",
|
|
210
|
+
holderId: person.id,
|
|
211
|
+
scopeResourceIds: [loaded.workspace.id],
|
|
212
|
+
...(options.startsOn ? { startsOn: options.startsOn } : {}),
|
|
213
|
+
responsibilities: "Own the information security program and the records that reference this Appointment."
|
|
214
|
+
};
|
|
215
|
+
if (!existingAppointment) create.push(appointment);
|
|
216
|
+
const jobTitle = person.jobTitle || (policyOwnerCount === 1 ? options.jobTitle : undefined);
|
|
217
|
+
if (!jobTitle) missing.push({ resourceId: person.id, field: "jobTitle" });
|
|
218
|
+
if (!existingAppointment && !options.startsOn) {
|
|
219
|
+
missing.push({ resourceId: person.id, field: "startsOn" });
|
|
220
|
+
}
|
|
221
|
+
if (jobTitle) migratedPerson.jobTitle = jobTitle;
|
|
222
|
+
delete migratedPerson.role;
|
|
223
|
+
|
|
224
|
+
for (const record of loaded.resources) {
|
|
225
|
+
for (const field of ACCOUNTABILITY_FIELDS) {
|
|
226
|
+
if (!Array.isArray(record[field]) || !record[field].includes(person.id)) continue;
|
|
227
|
+
editable(record)[field] = record[field].map((id) => id === person.id ? appointment.id : id);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const targetModel = loadModel(TARGET_MODEL_VERSION);
|
|
233
|
+
for (const record of loaded.resources.filter((candidate) => approvalBound(candidate))) {
|
|
234
|
+
const migrated = editable(record);
|
|
235
|
+
const revisions = {};
|
|
236
|
+
for (const item of markdownEntries(targetModel, migrated)) {
|
|
237
|
+
try {
|
|
238
|
+
revisions[item.path] = contentRevision(await readFile(resolveDataPath(loaded.root, item.path), "utf8"));
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error.code !== "ENOENT") throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
migrated.approvedContentRevisions = revisions;
|
|
244
|
+
notes.push({
|
|
245
|
+
resourceId: record.id,
|
|
246
|
+
message: "Bound the existing approval to the current companion Markdown revisions. Confirm that these are the revisions the recorded approvers approved."
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
for (const attestation of loaded.resources.filter((candidate) => (
|
|
250
|
+
candidate.type === "attestation"
|
|
251
|
+
&& candidate.status === "completed"
|
|
252
|
+
&& candidate.attestationMethod === "git-approval"
|
|
253
|
+
))) {
|
|
254
|
+
const migrated = editable(attestation);
|
|
255
|
+
const revisions = {};
|
|
256
|
+
for (const id of attestation.subjectResourceIds || []) {
|
|
257
|
+
const subject = updateById.get(id) || byId.get(id);
|
|
258
|
+
if (!subject) continue;
|
|
259
|
+
for (const item of markdownEntries(targetModel, subject)) {
|
|
260
|
+
try {
|
|
261
|
+
revisions[item.path] = contentRevision(
|
|
262
|
+
await readFile(resolveDataPath(loaded.root, item.path), "utf8")
|
|
263
|
+
);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error.code !== "ENOENT") throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
migrated.contentRevisions = revisions;
|
|
270
|
+
notes.push({
|
|
271
|
+
resourceId: attestation.id,
|
|
272
|
+
message: "Bound the completed git approval to the current subject Markdown revisions. Confirm that these are the revisions the named person attested to."
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const obligation of loaded.resources.filter(({ type }) => type === "obligation")) {
|
|
277
|
+
const migrated = editable(obligation);
|
|
278
|
+
delete migrated.completionResourceTypes;
|
|
279
|
+
const activity = targetModel.obligationActivities?.[obligation.activityType];
|
|
280
|
+
if (!activity) {
|
|
281
|
+
manualActions.push({
|
|
282
|
+
resourceId: obligation.id,
|
|
283
|
+
field: "activityType",
|
|
284
|
+
value: obligation.activityType,
|
|
285
|
+
message: "Choose a registered model-v2 obligation activity type."
|
|
286
|
+
});
|
|
287
|
+
} else {
|
|
288
|
+
if (!activity.recurrenceModes.includes(obligation.recurrence?.mode)) {
|
|
289
|
+
manualActions.push({
|
|
290
|
+
resourceId: obligation.id,
|
|
291
|
+
field: "recurrence.mode",
|
|
292
|
+
value: obligation.recurrence?.mode,
|
|
293
|
+
message: `${obligation.activityType} requires ${activity.recurrenceModes.join(" or ")} recurrence in model v2.`
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
for (const [field, ids] of [
|
|
297
|
+
["scopeResourceIds", obligation.scopeResourceIds || []],
|
|
298
|
+
["templateResourceId", obligation.templateResourceId ? [obligation.templateResourceId] : []]
|
|
299
|
+
]) {
|
|
300
|
+
const invalid = ids.filter((id) => {
|
|
301
|
+
const target = byId.get(id);
|
|
302
|
+
return target && !activity.scopeResourceTypes.includes(target.type);
|
|
303
|
+
});
|
|
304
|
+
if (invalid.length) {
|
|
305
|
+
manualActions.push({
|
|
306
|
+
resourceId: obligation.id,
|
|
307
|
+
field,
|
|
308
|
+
value: invalid,
|
|
309
|
+
message: `${obligation.activityType} scope must reference ${activity.scopeResourceTypes.join(" or ")} records in model v2.`
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (obligation.recurrence?.mode === "event" && !targetModel.policyEvents?.[obligation.recurrence.eventType]) {
|
|
315
|
+
manualActions.push({
|
|
316
|
+
resourceId: obligation.id,
|
|
317
|
+
field: "recurrence.eventType",
|
|
318
|
+
value: obligation.recurrence.eventType,
|
|
319
|
+
message: "Choose a registered model-v2 Policy Event type."
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (obligation.window) {
|
|
323
|
+
const window = migrateObligationWindow(obligation.window);
|
|
324
|
+
if (window) migrated.window = window;
|
|
325
|
+
else {
|
|
326
|
+
manualActions.push({
|
|
327
|
+
resourceId: obligation.id,
|
|
328
|
+
field: "window",
|
|
329
|
+
value: obligation.window,
|
|
330
|
+
message: "Choose either date precision with day offsets or timestamp precision with hour offsets, then set startsAfter and dueAfter."
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
} else if (obligation.recurrence?.mode === "event") {
|
|
334
|
+
missing.push({ resourceId: obligation.id, field: "window" });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const eventRecord of loaded.resources.filter(({ type }) => type === "obligation-event")) {
|
|
339
|
+
const event = targetModel.policyEvents?.[eventRecord.eventType];
|
|
340
|
+
if (!event) continue;
|
|
341
|
+
const counts = new Map();
|
|
342
|
+
const invalid = [];
|
|
343
|
+
const allowed = new Set(event.subjectRules.map(({ resourceType }) => resourceType));
|
|
344
|
+
for (const id of new Set(eventRecord.subjectResourceIds || [])) {
|
|
345
|
+
const target = byId.get(id);
|
|
346
|
+
if (!target) continue;
|
|
347
|
+
counts.set(target.type, (counts.get(target.type) || 0) + 1);
|
|
348
|
+
if (!allowed.has(target.type)) invalid.push(id);
|
|
349
|
+
}
|
|
350
|
+
const cardinalityInvalid = event.subjectRules.some(({ resourceType, minimum = 0, maximum }) => {
|
|
351
|
+
const count = counts.get(resourceType) || 0;
|
|
352
|
+
return count < minimum || (Number.isInteger(maximum) && count > maximum);
|
|
353
|
+
});
|
|
354
|
+
if (invalid.length || cardinalityInvalid) {
|
|
355
|
+
manualActions.push({
|
|
356
|
+
resourceId: eventRecord.id,
|
|
357
|
+
field: "subjectResourceIds",
|
|
358
|
+
value: eventRecord.subjectResourceIds,
|
|
359
|
+
message: `Choose subjects that satisfy the ${eventRecord.eventType} model-v2 type and cardinality rules.`
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const review of loaded.resources.filter(({ type }) => type === "vendor-review")) {
|
|
365
|
+
const migrated = editable(review);
|
|
366
|
+
if (Array.isArray(review.vendorIds) && review.vendorIds.length === 1) {
|
|
367
|
+
migrated.vendorId = review.vendorIds[0];
|
|
368
|
+
} else if (Array.isArray(review.vendorIds) && review.vendorIds.length > 1) {
|
|
369
|
+
manualActions.push({
|
|
370
|
+
resourceId: review.id,
|
|
371
|
+
field: "vendorIds",
|
|
372
|
+
value: review.vendorIds,
|
|
373
|
+
message: "Split this record into one Vendor Review per Vendor, with its own decision, coverage, evidence, and follow-up."
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
delete migrated.vendorIds;
|
|
377
|
+
const legacyDecision = ["approved", "conditional", "rejected"].includes(review.status)
|
|
378
|
+
? review.status
|
|
379
|
+
: ["approved", "conditional", "rejected"].includes(review.outcome)
|
|
380
|
+
? review.outcome
|
|
381
|
+
: null;
|
|
382
|
+
if (["approved", "conditional", "rejected"].includes(review.status)) migrated.status = "complete";
|
|
383
|
+
if (legacyDecision) migrated.decision = legacyDecision;
|
|
384
|
+
if ((migrated.status === "complete") && !migrated.decision) {
|
|
385
|
+
manualActions.push({
|
|
386
|
+
resourceId: review.id,
|
|
387
|
+
field: "decision",
|
|
388
|
+
value: review.outcome,
|
|
389
|
+
message: "Record the completed Vendor Review decision as approved, conditional, or rejected."
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
delete migrated.outcome;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (const test of loaded.resources.filter(({ type }) => type === "backup-test")) {
|
|
396
|
+
const migrated = editable(test);
|
|
397
|
+
if (["passed", "failed"].includes(test.status)) {
|
|
398
|
+
migrated.status = "complete";
|
|
399
|
+
migrated.outcome = test.status;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const grant of loaded.resources.filter(({ type }) => type === "access-grant")) {
|
|
404
|
+
delete editable(grant).subjectKind;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (const risk of loaded.resources.filter(({ type }) => type === "risk")) {
|
|
408
|
+
const migrated = editable(risk);
|
|
409
|
+
if (risk.status === "accepted") migrated.status = "monitoring";
|
|
410
|
+
const acceptanceFields = [
|
|
411
|
+
"acceptanceRationale",
|
|
412
|
+
"acceptedByIds",
|
|
413
|
+
"acceptedOn",
|
|
414
|
+
"acceptanceExpiresOn"
|
|
415
|
+
];
|
|
416
|
+
if (acceptanceFields.some((field) => !migrationValueMissing(risk[field]))) {
|
|
417
|
+
migrated.acceptance = {
|
|
418
|
+
...(risk.acceptanceRationale ? { rationale: risk.acceptanceRationale } : {}),
|
|
419
|
+
...(risk.acceptedByIds ? { acceptedByIds: risk.acceptedByIds } : {}),
|
|
420
|
+
...(risk.acceptedOn ? { acceptedOn: risk.acceptedOn } : {}),
|
|
421
|
+
...(risk.acceptanceExpiresOn ? { expiresOn: risk.acceptanceExpiresOn } : {})
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
for (const field of acceptanceFields) delete migrated[field];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for (const vulnerability of loaded.resources.filter(({ type }) => type === "vulnerability")) {
|
|
428
|
+
const migrated = editable(vulnerability);
|
|
429
|
+
if (vulnerability.acceptedByIds || vulnerability.acceptanceExpiresOn) {
|
|
430
|
+
manualActions.push({
|
|
431
|
+
resourceId: vulnerability.id,
|
|
432
|
+
field: "exceptionId",
|
|
433
|
+
value: {
|
|
434
|
+
acceptedByIds: vulnerability.acceptedByIds,
|
|
435
|
+
acceptanceExpiresOn: vulnerability.acceptanceExpiresOn
|
|
436
|
+
},
|
|
437
|
+
message: "Create or select the approved Exception that authorizes this Vulnerability risk acceptance, set exceptionId, then remove the legacy inline acceptance fields."
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
delete migrated.acceptedByIds;
|
|
441
|
+
delete migrated.acceptanceExpiresOn;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
for (const exception of loaded.resources.filter(({ type }) => type === "exception")) {
|
|
445
|
+
const migrated = editable(exception);
|
|
446
|
+
const approvalFields = ["approvedByIds", "approvedOn", "expiresOn"];
|
|
447
|
+
if (approvalFields.some((field) => !migrationValueMissing(exception[field]))) {
|
|
448
|
+
migrated.approval = {
|
|
449
|
+
...(exception.approvedByIds ? { approvedByIds: exception.approvedByIds } : {}),
|
|
450
|
+
...(exception.approvedOn ? { approvedOn: exception.approvedOn } : {}),
|
|
451
|
+
...(exception.expiresOn ? { expiresOn: exception.expiresOn } : {})
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
if (exception.status === "expired") {
|
|
455
|
+
migrated.status = "closed";
|
|
456
|
+
migrated.resolution = {
|
|
457
|
+
...(exception.expiresOn ? { resolvedOn: exception.expiresOn } : {}),
|
|
458
|
+
rationale: "The approved exception period expired without renewal."
|
|
459
|
+
};
|
|
460
|
+
} else if (["revoked", "closed"].includes(exception.status) && exception.closedOn) {
|
|
461
|
+
migrated.resolution = {
|
|
462
|
+
resolvedOn: exception.closedOn,
|
|
463
|
+
rationale: exception.status === "revoked"
|
|
464
|
+
? "The exception approval was revoked."
|
|
465
|
+
: "The exception was closed."
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
for (const field of [...approvalFields, "closedOn"]) delete migrated[field];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
for (const attestation of loaded.resources.filter(({ type }) => type === "attestation")) {
|
|
472
|
+
const migrated = editable(attestation);
|
|
473
|
+
if (attestation.status === "overdue") migrated.status = "pending";
|
|
474
|
+
delete migrated.attestedCommit;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
for (const [type, oldField] of [
|
|
478
|
+
["policy-review", "reviewedOn"],
|
|
479
|
+
["vendor-review", "reviewedOn"],
|
|
480
|
+
["access-review", "reviewDate"],
|
|
481
|
+
["risk-assessment", "assessmentDate"],
|
|
482
|
+
["backup-test", "testDate"]
|
|
483
|
+
]) {
|
|
484
|
+
for (const record of loaded.resources.filter((candidate) => candidate.type === type)) {
|
|
485
|
+
if (!record[oldField]) continue;
|
|
486
|
+
const migrated = editable(record);
|
|
487
|
+
if (type === "backup-test" && migrated.status === "complete") {
|
|
488
|
+
if (!migrated.completedAt) missing.push({ resourceId: record.id, field: "completedAt" });
|
|
489
|
+
migrated.scheduledFor = record[oldField];
|
|
490
|
+
} else if (migrated.status === "complete") migrated.completedOn = record[oldField];
|
|
491
|
+
else migrated.scheduledFor = record[oldField];
|
|
492
|
+
delete migrated[oldField];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
for (const type of ["meeting", "vulnerability-scan", "exercise"]) {
|
|
497
|
+
for (const record of loaded.resources.filter((candidate) => candidate.type === type)) {
|
|
498
|
+
if (!record.scheduledOn) continue;
|
|
499
|
+
const migrated = editable(record);
|
|
500
|
+
migrated.scheduledFor = record.scheduledOn;
|
|
501
|
+
delete migrated.scheduledOn;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const record of loaded.resources.filter(({ type }) => type === "backup-test")) {
|
|
506
|
+
const migrated = editable(record);
|
|
507
|
+
if (
|
|
508
|
+
migrated.status === "complete"
|
|
509
|
+
&& !migrated.completedAt
|
|
510
|
+
&& !missing.some((item) => item.resourceId === record.id && item.field === "completedAt")
|
|
511
|
+
) {
|
|
512
|
+
missing.push({ resourceId: record.id, field: "completedAt" });
|
|
513
|
+
}
|
|
514
|
+
delete migrated.completedOn;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
for (const audit of loaded.resources.filter(({ type }) => type === "audit")) {
|
|
518
|
+
const migrated = editable(audit);
|
|
519
|
+
if (audit.auditor && !audit.auditorVendorId) {
|
|
520
|
+
manualActions.push({
|
|
521
|
+
resourceId: audit.id,
|
|
522
|
+
field: "auditor",
|
|
523
|
+
value: audit.auditor,
|
|
524
|
+
message: "Create or select the CPA firm Vendor, set auditorVendorId, add any external auditor contacts as People, then remove auditor."
|
|
525
|
+
});
|
|
526
|
+
} else delete migrated.auditor;
|
|
527
|
+
if (audit.assessmentCoverage) {
|
|
528
|
+
manualActions.push({
|
|
529
|
+
resourceId: audit.id,
|
|
530
|
+
field: "assessmentCoverage",
|
|
531
|
+
value: audit.assessmentCoverage,
|
|
532
|
+
message: "Move useful scope details into scope, systemIds, requirementIds, controlIds, or Record Markdown, then remove assessmentCoverage."
|
|
533
|
+
});
|
|
534
|
+
} else delete migrated.assessmentCoverage;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
for (const population of loaded.resources.filter(({ type }) => type === "audit-population")) {
|
|
538
|
+
if (population.status !== "incomplete") continue;
|
|
539
|
+
const migrated = editable(population);
|
|
540
|
+
migrated.status = "reconciled";
|
|
541
|
+
migrated.conclusion = "incomplete";
|
|
542
|
+
notes.push({
|
|
543
|
+
resourceId: population.id,
|
|
544
|
+
message: "Moved the incomplete result from Audit Population status to conclusion; status now records whether reconciliation occurred."
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
migrateInverseArrays(loaded.resources, byId, editable, conflicts, {
|
|
549
|
+
sourceType: "system",
|
|
550
|
+
sourceField: "commitmentIds",
|
|
551
|
+
targetType: "commitment",
|
|
552
|
+
targetField: "systemIds"
|
|
553
|
+
});
|
|
554
|
+
migrateInverseArrays(loaded.resources, byId, editable, conflicts, {
|
|
555
|
+
sourceType: "requirement",
|
|
556
|
+
sourceField: "controlIds",
|
|
557
|
+
targetType: "control",
|
|
558
|
+
targetField: "requirementIds"
|
|
559
|
+
});
|
|
560
|
+
migrateInverseArrays(loaded.resources, byId, editable, conflicts, {
|
|
561
|
+
sourceType: "control",
|
|
562
|
+
sourceField: "commitmentIds",
|
|
563
|
+
targetType: "commitment",
|
|
564
|
+
targetField: "controlIds"
|
|
565
|
+
});
|
|
566
|
+
migrateInverseArrays(loaded.resources, byId, editable, conflicts, {
|
|
567
|
+
sourceType: "control",
|
|
568
|
+
sourceField: "riskIds",
|
|
569
|
+
targetType: "risk",
|
|
570
|
+
targetField: "controlIds"
|
|
571
|
+
});
|
|
572
|
+
migrateInverseArrays(loaded.resources, byId, editable, conflicts, {
|
|
573
|
+
sourceType: "policy",
|
|
574
|
+
sourceField: "controlIds",
|
|
575
|
+
targetType: "control",
|
|
576
|
+
targetField: "policyIds"
|
|
577
|
+
});
|
|
578
|
+
migrateInverseArrays(loaded.resources, byId, editable, conflicts, {
|
|
579
|
+
sourceType: "audit",
|
|
580
|
+
sourceField: "evidenceIds",
|
|
581
|
+
targetType: "evidence",
|
|
582
|
+
targetField: "auditIds"
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
for (const vendor of loaded.resources.filter(({ type }) => type === "vendor")) {
|
|
586
|
+
if (!Array.isArray(vendor.systemIds)) continue;
|
|
587
|
+
for (const systemId of vendor.systemIds) {
|
|
588
|
+
const system = byId.get(systemId);
|
|
589
|
+
if (system?.type !== "system") {
|
|
590
|
+
conflicts.push({
|
|
591
|
+
resourceId: vendor.id,
|
|
592
|
+
field: "systemIds",
|
|
593
|
+
message: `System "${systemId}" was not found.`
|
|
594
|
+
});
|
|
595
|
+
} else if (system.vendorId && system.vendorId !== vendor.id) {
|
|
596
|
+
conflicts.push({
|
|
597
|
+
resourceId: system.id,
|
|
598
|
+
field: "vendorId",
|
|
599
|
+
message: `System already names vendor "${system.vendorId}", but vendor "${vendor.id}" also links it.`
|
|
600
|
+
});
|
|
601
|
+
} else {
|
|
602
|
+
editable(system).vendorId = vendor.id;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
delete editable(vendor).systemIds;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
for (const audit of loaded.resources.filter(({ type }) => type === "audit")) {
|
|
609
|
+
if (!Array.isArray(audit.controlTestIds)) continue;
|
|
610
|
+
for (const testId of audit.controlTestIds) {
|
|
611
|
+
const controlTest = byId.get(testId);
|
|
612
|
+
if (controlTest?.type !== "control-test") {
|
|
613
|
+
conflicts.push({
|
|
614
|
+
resourceId: audit.id,
|
|
615
|
+
field: "controlTestIds",
|
|
616
|
+
message: `Control Test "${testId}" was not found.`
|
|
617
|
+
});
|
|
618
|
+
} else if (controlTest.auditId && controlTest.auditId !== audit.id) {
|
|
619
|
+
conflicts.push({
|
|
620
|
+
resourceId: controlTest.id,
|
|
621
|
+
field: "auditId",
|
|
622
|
+
message: `Control Test already names audit "${controlTest.auditId}", but audit "${audit.id}" also links it.`
|
|
623
|
+
});
|
|
624
|
+
} else {
|
|
625
|
+
editable(controlTest).auditId = audit.id;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
delete editable(audit).controlTestIds;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
for (const evidence of loaded.resources.filter(({ type }) => type === "evidence")) {
|
|
632
|
+
const migrated = editable(evidence);
|
|
633
|
+
if (evidence.status === "expired") {
|
|
634
|
+
migrated.status = evidence.verifierIds?.length && evidence.verifiedOn ? "verified" : "collected";
|
|
635
|
+
if (!evidence.expiresOn) missing.push({ resourceId: evidence.id, field: "expiresOn" });
|
|
636
|
+
notes.push({
|
|
637
|
+
resourceId: evidence.id,
|
|
638
|
+
message: "Removed the stored expired state. Model v2 derives expiry from expiresOn while preserving the collected or verified workflow state."
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const oldKind = String(evidence.evidenceKind || "").trim();
|
|
642
|
+
if (oldKind) {
|
|
643
|
+
const { artifactKind, artifactSubtype } = migrateEvidenceKind(oldKind);
|
|
644
|
+
migrated.artifactKind = artifactKind;
|
|
645
|
+
if (artifactSubtype) migrated.artifactSubtype = artifactSubtype;
|
|
646
|
+
delete migrated.evidenceKind;
|
|
647
|
+
} else if (!evidence.artifactKind) {
|
|
648
|
+
missing.push({ resourceId: evidence.id, field: "artifactKind" });
|
|
649
|
+
}
|
|
650
|
+
migrated.sourceKind = evidenceSourceKind(evidence, migrated.artifactKind);
|
|
651
|
+
if (Object.hasOwn(evidence, "source")) {
|
|
652
|
+
migrated.sourceDescription = evidence.source;
|
|
653
|
+
delete migrated.source;
|
|
654
|
+
}
|
|
655
|
+
if (
|
|
656
|
+
evidence.status !== "draft"
|
|
657
|
+
&& migrated.sourceKind === "rendered-page"
|
|
658
|
+
) {
|
|
659
|
+
for (const field of ["capture", "sourceCommit"]) {
|
|
660
|
+
if (!migrated[field] || (Array.isArray(migrated[field]) && migrated[field].length === 0)) {
|
|
661
|
+
missing.push({ resourceId: evidence.id, field });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const hadCollectionDraftFields = Object.hasOwn(evidence, "collectionTestFamilyId")
|
|
666
|
+
|| Object.hasOwn(evidence, "collectionTestPrompt");
|
|
667
|
+
if (hadCollectionDraftFields) {
|
|
668
|
+
delete migrated.collectionTestFamilyId;
|
|
669
|
+
delete migrated.collectionTestPrompt;
|
|
670
|
+
if (evidence.status === "draft") {
|
|
671
|
+
notes.push({
|
|
672
|
+
resourceId: evidence.id,
|
|
673
|
+
message: "This former collection-test draft remains a normal draft External Evidence record. Review, complete, withdraw, or delete it separately."
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
for (const action of loaded.resources.filter(({ type }) => type === "action-item")) {
|
|
680
|
+
const migrated = editable(action);
|
|
681
|
+
if (!action.completionWindow) {
|
|
682
|
+
const completionWindow = migrateCompletionWindow(action, loaded.workspace.timezone);
|
|
683
|
+
if (completionWindow) migrated.completionWindow = completionWindow;
|
|
684
|
+
else if (["open", "in-progress", "blocked"].includes(action.status)) {
|
|
685
|
+
missing.push({ resourceId: action.id, field: "completionWindow" });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
for (const field of [
|
|
689
|
+
"dueWindowStart", "dueWindowEnd", "overdueOn",
|
|
690
|
+
"dueWindowStartAt", "dueWindowEndAt", "overdueAt", "dueOn"
|
|
691
|
+
]) delete migrated[field];
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
for (const record of loaded.resources) {
|
|
695
|
+
if (!Array.isArray(record.relatedResourceIds)) continue;
|
|
696
|
+
const targets = record.relatedResourceIds.map((id) => byId.get(id));
|
|
697
|
+
if (record.type === "document" && targets.every((target) => target?.type === "training")) {
|
|
698
|
+
addIds(editable(record), "trainingIds", record.relatedResourceIds);
|
|
699
|
+
delete editable(record).relatedResourceIds;
|
|
700
|
+
} else {
|
|
701
|
+
manualActions.push({
|
|
702
|
+
resourceId: record.id,
|
|
703
|
+
field: "relatedResourceIds",
|
|
704
|
+
value: record.relatedResourceIds,
|
|
705
|
+
message: "Replace each catch-all relationship with the model-defined field that states what the relationship means, then remove relatedResourceIds."
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
for (const record of loaded.resources) {
|
|
711
|
+
for (const [key, migration] of CADENCE_MIGRATIONS) {
|
|
712
|
+
const [type, field] = key.split(":");
|
|
713
|
+
if (record.type !== type || !Object.hasOwn(record, field)) continue;
|
|
714
|
+
const recurrence = record[field];
|
|
715
|
+
if (!validMigratableRecurrence(recurrence, record)) {
|
|
716
|
+
manualActions.push({
|
|
717
|
+
resourceId: record.id,
|
|
718
|
+
field,
|
|
719
|
+
value: recurrence,
|
|
720
|
+
message: `Create an Obligation that preserves this schedule, then remove ${field}.`
|
|
721
|
+
});
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const existing = [...loaded.resources, ...create].find((candidate) => (
|
|
725
|
+
candidate.type === "obligation"
|
|
726
|
+
&& (
|
|
727
|
+
candidate.templateResourceId === record.id
|
|
728
|
+
|| (candidate.scopeResourceIds || []).includes(record.id)
|
|
729
|
+
)
|
|
730
|
+
&& candidate.activityType === migration.activityType
|
|
731
|
+
));
|
|
732
|
+
if (!existing) {
|
|
733
|
+
const ownerIds = cadenceOwnerIds(record);
|
|
734
|
+
if (!ownerIds.length) {
|
|
735
|
+
manualActions.push({
|
|
736
|
+
resourceId: record.id,
|
|
737
|
+
field,
|
|
738
|
+
value: recurrence,
|
|
739
|
+
message: "Assign the Obligation owner, create the schedule, then remove this legacy cadence field."
|
|
740
|
+
});
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const title = cadenceTitle(record, migration.activityType);
|
|
744
|
+
const obligation = {
|
|
745
|
+
id: createResourceId("obligation", title, [...usedIds, ...create.map(({ id }) => id)]),
|
|
746
|
+
type: "obligation",
|
|
747
|
+
title,
|
|
748
|
+
status: "active",
|
|
749
|
+
activityType: migration.activityType,
|
|
750
|
+
recurrence: {
|
|
751
|
+
...recurrence,
|
|
752
|
+
anchorDate: recurrence.anchorDate || record.effectiveOn || record.startsOn
|
|
753
|
+
},
|
|
754
|
+
ownerIds,
|
|
755
|
+
...(migration.relation === "template"
|
|
756
|
+
? { templateResourceId: record.id }
|
|
757
|
+
: { scopeResourceIds: [record.id] }),
|
|
758
|
+
...(record.type === "policy" ? { policyIds: [record.id] } : {}),
|
|
759
|
+
...(record.effectiveOn || record.startsOn
|
|
760
|
+
? { startsOn: record.effectiveOn || record.startsOn }
|
|
761
|
+
: {})
|
|
762
|
+
};
|
|
763
|
+
create.push(obligation);
|
|
764
|
+
notes.push({
|
|
765
|
+
resourceId: record.id,
|
|
766
|
+
message: `Created Obligation "${obligation.id}" from ${field}; Obligations are the schedule authority in model v2.`
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
delete editable(record)[field];
|
|
770
|
+
}
|
|
771
|
+
for (const field of ["nextReviewConstraint"]) {
|
|
772
|
+
if (!Object.hasOwn(record, field)) continue;
|
|
773
|
+
manualActions.push({
|
|
774
|
+
resourceId: record.id,
|
|
775
|
+
field,
|
|
776
|
+
value: record[field],
|
|
777
|
+
message: `Replace ${field} with an explicit Obligation deadline, then remove the legacy field.`
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const obligationsByControl = new Map();
|
|
783
|
+
for (const obligation of [...loaded.resources, ...create].filter((record) => (
|
|
784
|
+
record.type === "obligation" && recordIsNotRetired(record)
|
|
785
|
+
))) {
|
|
786
|
+
for (const controlId of obligation.controlIds || []) {
|
|
787
|
+
if (!obligationsByControl.has(controlId)) obligationsByControl.set(controlId, []);
|
|
788
|
+
obligationsByControl.get(controlId).push(obligation);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
for (const control of loaded.resources.filter(({ type }) => type === "control")) {
|
|
792
|
+
const migrated = editable(control);
|
|
793
|
+
if (Object.hasOwn(control, "frequency")) {
|
|
794
|
+
migrated.operationPattern = controlOperationPattern(control.frequency);
|
|
795
|
+
delete migrated.frequency;
|
|
796
|
+
} else if (!control.operationPattern) {
|
|
797
|
+
missing.push({ resourceId: control.id, field: "operationPattern" });
|
|
798
|
+
}
|
|
799
|
+
if (
|
|
800
|
+
control.status === "implemented"
|
|
801
|
+
&& ["scheduled", "event-driven", "mixed"].includes(migrated.operationPattern)
|
|
802
|
+
&& !(obligationsByControl.get(control.id) || []).length
|
|
803
|
+
) {
|
|
804
|
+
manualActions.push({
|
|
805
|
+
resourceId: control.id,
|
|
806
|
+
field: "operationPattern",
|
|
807
|
+
value: migrated.operationPattern,
|
|
808
|
+
message: "Link an active Obligation that defines this implemented Control's schedule before applying model v2."
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const renderer = loaded.resources.find(({ type }) => type === "renderer-settings");
|
|
814
|
+
if (renderer) {
|
|
815
|
+
const migrated = editable(renderer);
|
|
816
|
+
if (!migrated.repositoryMode) migrated.repositoryMode = "manual";
|
|
817
|
+
if (!migrated.authoritativeBranch) migrated.authoritativeBranch = "main";
|
|
818
|
+
if (!migrated.repositoryRemote) migrated.repositoryRemote = "origin";
|
|
819
|
+
if (Array.isArray(migrated.completedStagePageIds)) {
|
|
820
|
+
migrated.completedStagePageIds = [...new Set(
|
|
821
|
+
migrated.completedStagePageIds.map((id) => STAGE_PAGE_ID_MIGRATIONS.get(id) || id)
|
|
822
|
+
)].sort();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
editable(loaded.workspace).dataModelVersion = TARGET_MODEL_VERSION;
|
|
827
|
+
const migratedRecords = [
|
|
828
|
+
...loaded.resources.map((record) => updateById.get(record.id) || record),
|
|
829
|
+
...create
|
|
830
|
+
];
|
|
831
|
+
collectModelShapeActions(
|
|
832
|
+
migratedRecords,
|
|
833
|
+
targetModel,
|
|
834
|
+
missing,
|
|
835
|
+
manualActions
|
|
836
|
+
);
|
|
837
|
+
collectRelationshipConstraintActions(
|
|
838
|
+
migratedRecords,
|
|
839
|
+
targetModel,
|
|
840
|
+
manualActions
|
|
841
|
+
);
|
|
842
|
+
collectRelationTypeActions(
|
|
843
|
+
migratedRecords,
|
|
844
|
+
targetModel,
|
|
845
|
+
manualActions
|
|
846
|
+
);
|
|
847
|
+
const update = [
|
|
848
|
+
...[...updateById.values()].filter(({ id }) => id !== loaded.workspace.id),
|
|
849
|
+
updateById.get(loaded.workspace.id)
|
|
850
|
+
];
|
|
851
|
+
return {
|
|
852
|
+
schemaVersion: 1,
|
|
853
|
+
sourceModelVersion: sourceVersion,
|
|
854
|
+
targetModelVersion: TARGET_MODEL_VERSION,
|
|
855
|
+
ready: !missing.length && !conflicts.length && !manualActions.length,
|
|
856
|
+
missing,
|
|
857
|
+
conflicts,
|
|
858
|
+
manualActions,
|
|
859
|
+
notes,
|
|
860
|
+
summary: {
|
|
861
|
+
create: create.length,
|
|
862
|
+
update: update.length
|
|
863
|
+
},
|
|
864
|
+
changes: {
|
|
865
|
+
create,
|
|
866
|
+
update,
|
|
867
|
+
expectedRevisions: Object.fromEntries(
|
|
868
|
+
update.map(({ id }) => [id, revisionById.get(id)])
|
|
869
|
+
),
|
|
870
|
+
validateWholeWorkspace: true
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
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 };
|
|
878
|
+
if (!plan.ready) {
|
|
879
|
+
throw new Error(
|
|
880
|
+
"Model migration needs review. Run `npx filegrc migrate --to-model 2 --preview --json` "
|
|
881
|
+
+ "and resolve every missing value, conflict, and manual action."
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
const result = await applyResourceBatch(input, plan.changes);
|
|
885
|
+
return { ...plan, applied: true, result };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function migrateInverseArrays(resources, byId, editable, conflicts, mapping) {
|
|
889
|
+
for (const source of resources.filter(({ type }) => type === mapping.sourceType)) {
|
|
890
|
+
if (!Array.isArray(source[mapping.sourceField])) continue;
|
|
891
|
+
for (const targetId of source[mapping.sourceField]) {
|
|
892
|
+
const target = byId.get(targetId);
|
|
893
|
+
if (target?.type !== mapping.targetType) {
|
|
894
|
+
conflicts.push({
|
|
895
|
+
resourceId: source.id,
|
|
896
|
+
field: mapping.sourceField,
|
|
897
|
+
message: `${mapping.targetType} "${targetId}" was not found.`
|
|
898
|
+
});
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const migrated = editable(target);
|
|
902
|
+
const current = Array.isArray(migrated[mapping.targetField]) ? migrated[mapping.targetField] : [];
|
|
903
|
+
migrated[mapping.targetField] = [...new Set([...current, source.id])];
|
|
904
|
+
}
|
|
905
|
+
delete editable(source)[mapping.sourceField];
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function emptyPlan(version) {
|
|
910
|
+
return {
|
|
911
|
+
schemaVersion: 1,
|
|
912
|
+
sourceModelVersion: version,
|
|
913
|
+
targetModelVersion: TARGET_MODEL_VERSION,
|
|
914
|
+
ready: true,
|
|
915
|
+
missing: [],
|
|
916
|
+
conflicts: [],
|
|
917
|
+
manualActions: [],
|
|
918
|
+
notes: [],
|
|
919
|
+
summary: { create: 0, update: 0 },
|
|
920
|
+
changes: {
|
|
921
|
+
create: [],
|
|
922
|
+
update: [],
|
|
923
|
+
expectedRevisions: {},
|
|
924
|
+
validateWholeWorkspace: true
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function migrateClassificationDefinitions(workspace, definitions, conflicts) {
|
|
930
|
+
const migrated = {};
|
|
931
|
+
const ids = new Map();
|
|
932
|
+
for (const [name, description] of Object.entries(definitions || {})) {
|
|
933
|
+
const id = normalizeClassificationId(name);
|
|
934
|
+
if (!id) continue;
|
|
935
|
+
if (Object.hasOwn(migrated, id) && migrated[id] !== description) {
|
|
936
|
+
conflicts.push({
|
|
937
|
+
resourceId: workspace.id,
|
|
938
|
+
field: "classificationDefinitions",
|
|
939
|
+
message: `Classification names "${ids.get(id)}" and "${name}" both normalize to "${id}".`
|
|
940
|
+
});
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
migrated[id] = description;
|
|
944
|
+
ids.set(id, name);
|
|
945
|
+
}
|
|
946
|
+
workspace.classificationDefinitions = migrated;
|
|
947
|
+
return new Map([...ids].flatMap(([id, original]) => [
|
|
948
|
+
[id.toLowerCase(), id],
|
|
949
|
+
[String(original).trim().toLowerCase(), id]
|
|
950
|
+
]));
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function normalizeClassificationId(value) {
|
|
954
|
+
return String(value || "")
|
|
955
|
+
.trim()
|
|
956
|
+
.toLowerCase()
|
|
957
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
958
|
+
.replace(/^-|-$/g, "");
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function resolveClassificationId(value, ids) {
|
|
962
|
+
const text = String(value || "").trim().toLowerCase();
|
|
963
|
+
return ids.get(text) || ids.get(normalizeClassificationId(text)) || null;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function coverageMigrationSettings(record) {
|
|
967
|
+
if (record.type === "control-test") {
|
|
968
|
+
return { target: "coverage", asOfFields: ["asOfDate"], startFields: ["periodStart"], endFields: ["periodEnd"] };
|
|
969
|
+
}
|
|
970
|
+
if (record.type === "audit") {
|
|
971
|
+
return { target: "coverage", asOfFields: ["typeOneAsOf"], startFields: ["periodStart"], endFields: ["periodEnd"] };
|
|
972
|
+
}
|
|
973
|
+
if ([
|
|
974
|
+
"evidence", "policy-review", "vendor-review", "access-review",
|
|
975
|
+
"penetration-test", "audit-population", "audit-request"
|
|
976
|
+
].includes(record.type)) {
|
|
977
|
+
return { target: "coverage", startFields: ["periodStart"], endFields: ["periodEnd"] };
|
|
978
|
+
}
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function migrateCoverageFields(
|
|
983
|
+
target,
|
|
984
|
+
source,
|
|
985
|
+
settings,
|
|
986
|
+
missing,
|
|
987
|
+
manualActions,
|
|
988
|
+
resourceId = source.id,
|
|
989
|
+
fieldPrefix = ""
|
|
990
|
+
) {
|
|
991
|
+
const fields = [
|
|
992
|
+
...(settings.asOfFields || []),
|
|
993
|
+
...(settings.startFields || []),
|
|
994
|
+
...(settings.endFields || [])
|
|
995
|
+
];
|
|
996
|
+
const present = fields.filter((field) => source[field]);
|
|
997
|
+
if (!target[settings.target]) {
|
|
998
|
+
const coverage = legacyCoverage(source, settings);
|
|
999
|
+
if (coverage) target[settings.target] = coverage;
|
|
1000
|
+
else if (present.length) {
|
|
1001
|
+
manualActions.push({
|
|
1002
|
+
resourceId,
|
|
1003
|
+
field: `${fieldPrefix}${settings.target}`,
|
|
1004
|
+
value: Object.fromEntries(present.map((field) => [field, source[field]])),
|
|
1005
|
+
message: "Complete the as-of date or both range dates and store them as a model-v2 coverage object."
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
for (const field of fields) delete target[field];
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function migrateObligationWindow(window) {
|
|
1013
|
+
if (window.precision && Number.isInteger(window.dueAfter)) return structuredClone(window);
|
|
1014
|
+
const hasDays = Number.isInteger(window.startOffsetDays) || Number.isInteger(window.endOffsetDays);
|
|
1015
|
+
const hasHours = Number.isInteger(window.startOffsetHours) || Number.isInteger(window.endOffsetHours);
|
|
1016
|
+
if (hasDays === hasHours) return null;
|
|
1017
|
+
if (hasDays && Number.isInteger(window.endOffsetDays)) {
|
|
1018
|
+
return {
|
|
1019
|
+
precision: "date",
|
|
1020
|
+
startsAfter: window.startOffsetDays || 0,
|
|
1021
|
+
dueAfter: window.endOffsetDays
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
if (hasHours && Number.isInteger(window.endOffsetHours)) {
|
|
1025
|
+
return {
|
|
1026
|
+
precision: "timestamp",
|
|
1027
|
+
startsAfter: window.startOffsetHours || 0,
|
|
1028
|
+
dueAfter: window.endOffsetHours
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function migrateEvidenceKind(value) {
|
|
1035
|
+
const direct = new Set([
|
|
1036
|
+
"population-export", "system-export", "configuration-export",
|
|
1037
|
+
"signed-record", "third-party-report", "business-record"
|
|
1038
|
+
]);
|
|
1039
|
+
if (direct.has(value)) return { artifactKind: value };
|
|
1040
|
+
if (value === "rendered-record") return { artifactKind: "rendered-page" };
|
|
1041
|
+
if (["test-capture", "screenshot", "capture"].includes(value)) {
|
|
1042
|
+
return { artifactKind: "capture", artifactSubtype: value === "capture" ? undefined : value };
|
|
1043
|
+
}
|
|
1044
|
+
if (value === "signed-management-representation") {
|
|
1045
|
+
return { artifactKind: "signed-record", artifactSubtype: value };
|
|
1046
|
+
}
|
|
1047
|
+
if (value === "export" || value.endsWith("-export")) {
|
|
1048
|
+
return { artifactKind: "system-export", artifactSubtype: value };
|
|
1049
|
+
}
|
|
1050
|
+
if (/soc|assurance|vendor-report|third-party-report/.test(value)) {
|
|
1051
|
+
return { artifactKind: "third-party-report", artifactSubtype: value };
|
|
1052
|
+
}
|
|
1053
|
+
if (["attachment", "narrative", "review", "risk-governance"].includes(value)) {
|
|
1054
|
+
return { artifactKind: "business-record", artifactSubtype: value };
|
|
1055
|
+
}
|
|
1056
|
+
return { artifactKind: "other", artifactSubtype: value };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function evidenceSourceKind(record, artifactKind) {
|
|
1060
|
+
if (record.capture || artifactKind === "rendered-page") return "rendered-page";
|
|
1061
|
+
if (record.sourceSystemId) return "system";
|
|
1062
|
+
if (record.externalReference) return "external-reference";
|
|
1063
|
+
if (Array.isArray(record.filePaths) && record.filePaths.length) return "file";
|
|
1064
|
+
return "authored-record";
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function migrateCompletionWindow(record, timezone) {
|
|
1068
|
+
const dueAt = record.dueWindowEndAt || record.overdueAt;
|
|
1069
|
+
if (dueAt) {
|
|
1070
|
+
return {
|
|
1071
|
+
precision: "timestamp",
|
|
1072
|
+
startsAt: record.dueWindowStartAt || dueAt,
|
|
1073
|
+
dueAt,
|
|
1074
|
+
overdueAt: record.overdueAt || dueAt,
|
|
1075
|
+
timezone: timezone || "UTC"
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
const dueOn = record.dueWindowEnd || record.dueOn || record.overdueOn;
|
|
1079
|
+
if (!dueOn) return null;
|
|
1080
|
+
return {
|
|
1081
|
+
precision: "date",
|
|
1082
|
+
startsOn: record.dueWindowStart || dueOn,
|
|
1083
|
+
dueOn,
|
|
1084
|
+
overdueOn: record.overdueOn || nextCalendarDate(dueOn)
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function nextCalendarDate(value) {
|
|
1089
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
1090
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
1091
|
+
date.setUTCDate(date.getUTCDate() + 1);
|
|
1092
|
+
return date.toISOString().slice(0, 10);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function validMigratableRecurrence(value, record) {
|
|
1096
|
+
return Boolean(
|
|
1097
|
+
value
|
|
1098
|
+
&& !Array.isArray(value)
|
|
1099
|
+
&& typeof value === "object"
|
|
1100
|
+
&& value.mode === "calendar"
|
|
1101
|
+
&& Number.isSafeInteger(value.interval)
|
|
1102
|
+
&& value.interval > 0
|
|
1103
|
+
&& ["day", "week", "month", "year"].includes(value.unit)
|
|
1104
|
+
&& (value.anchorDate || record.effectiveOn || record.startsOn)
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function cadenceOwnerIds(record) {
|
|
1109
|
+
if (Array.isArray(record.ownerIds) && record.ownerIds.length) return record.ownerIds;
|
|
1110
|
+
if (record.type === "team") return [record.id];
|
|
1111
|
+
return [];
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function cadenceTitle(record, activityType) {
|
|
1115
|
+
if (activityType === "training") return `Complete ${record.title}`;
|
|
1116
|
+
if (activityType === "oversight-meeting") return `Hold ${record.title} meeting`;
|
|
1117
|
+
return `Review ${record.title}`;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function controlOperationPattern(frequency) {
|
|
1121
|
+
const value = String(frequency || "").toLowerCase();
|
|
1122
|
+
const continuous = /continuous|ongoing/.test(value);
|
|
1123
|
+
const eventDriven = /\bper\b|\bbefore\b|\bonboarding\b|\bafter\b|as issues arise|material change|material disruption|incident/.test(value);
|
|
1124
|
+
const scheduled = /daily|weekly|monthly|quarterly|annually|annual|yearly/.test(value);
|
|
1125
|
+
if ([continuous, eventDriven, scheduled].filter(Boolean).length > 1) return "mixed";
|
|
1126
|
+
if (continuous) return "continuous";
|
|
1127
|
+
if (eventDriven) return "event-driven";
|
|
1128
|
+
return "scheduled";
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function recordIsNotRetired(record) {
|
|
1132
|
+
return record.status !== "retired";
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function approvalBound(record) {
|
|
1136
|
+
if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
|
|
1137
|
+
if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
|
|
1138
|
+
return false;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function collectModelShapeActions(records, model, missing, manualActions) {
|
|
1142
|
+
const reportedManual = new Set(manualActions.map(({ resourceId, field }) => `${resourceId}:${field}`));
|
|
1143
|
+
const reportedMissing = new Set(missing.map(({ resourceId, field }) => `${resourceId}:${field}`));
|
|
1144
|
+
for (const record of records) {
|
|
1145
|
+
const definition = model.resources[record.type];
|
|
1146
|
+
if (!definition) continue;
|
|
1147
|
+
const fields = { ...model.commonFields, ...definition.fields };
|
|
1148
|
+
const required = new Set([
|
|
1149
|
+
...Object.entries(model.commonFields).filter(([, field]) => field.required).map(([name]) => name),
|
|
1150
|
+
...(definition.required || [])
|
|
1151
|
+
]);
|
|
1152
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
1153
|
+
if (field.requiredWhen && migrationConditionMatches(record, field.requiredWhen)) required.add(name);
|
|
1154
|
+
}
|
|
1155
|
+
for (const name of required) {
|
|
1156
|
+
if (!migrationValueMissing(record[name])) continue;
|
|
1157
|
+
const key = `${record.id}:${name}`;
|
|
1158
|
+
if (reportedMissing.has(key)) continue;
|
|
1159
|
+
missing.push({ resourceId: record.id, field: name });
|
|
1160
|
+
reportedMissing.add(key);
|
|
1161
|
+
}
|
|
1162
|
+
for (const name of Object.keys(record)) {
|
|
1163
|
+
if (fields[name]) continue;
|
|
1164
|
+
const key = `${record.id}:${name}`;
|
|
1165
|
+
if (reportedManual.has(key)) continue;
|
|
1166
|
+
manualActions.push({
|
|
1167
|
+
resourceId: record.id,
|
|
1168
|
+
field: name,
|
|
1169
|
+
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.`
|
|
1171
|
+
});
|
|
1172
|
+
reportedManual.add(key);
|
|
1173
|
+
}
|
|
1174
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
1175
|
+
if (
|
|
1176
|
+
!migrationValueMissing(record[name])
|
|
1177
|
+
&& field.allowedWhen
|
|
1178
|
+
&& !migrationConditionMatches(record, field.allowedWhen)
|
|
1179
|
+
) {
|
|
1180
|
+
const key = `${record.id}:${name}`;
|
|
1181
|
+
if (!reportedManual.has(key)) {
|
|
1182
|
+
manualActions.push({
|
|
1183
|
+
resourceId: record.id,
|
|
1184
|
+
field: name,
|
|
1185
|
+
value: record[name],
|
|
1186
|
+
message: `Field "${name}" is not allowed for the selected ${Object.keys(field.allowedWhen).join(" and ")} in model v2.`
|
|
1187
|
+
});
|
|
1188
|
+
reportedManual.add(key);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if (field.type === "object" && field.objectType && record[name] && !Array.isArray(record[name])) {
|
|
1192
|
+
collectObjectShapeActions(
|
|
1193
|
+
record.id,
|
|
1194
|
+
name,
|
|
1195
|
+
record[name],
|
|
1196
|
+
field.objectType,
|
|
1197
|
+
model,
|
|
1198
|
+
missing,
|
|
1199
|
+
manualActions,
|
|
1200
|
+
reportedMissing,
|
|
1201
|
+
reportedManual
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
if (field.type === "array" && field.itemObjectType && Array.isArray(record[name])) {
|
|
1205
|
+
for (const [index, item] of record[name].entries()) {
|
|
1206
|
+
if (!item || Array.isArray(item) || typeof item !== "object") continue;
|
|
1207
|
+
collectObjectShapeActions(
|
|
1208
|
+
record.id,
|
|
1209
|
+
`${name}[${index}]`,
|
|
1210
|
+
item,
|
|
1211
|
+
field.itemObjectType,
|
|
1212
|
+
model,
|
|
1213
|
+
missing,
|
|
1214
|
+
manualActions,
|
|
1215
|
+
reportedMissing,
|
|
1216
|
+
reportedManual
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function collectRelationshipConstraintActions(records, model, manualActions) {
|
|
1225
|
+
const byId = new Map(records.map((record) => [record.id, record]));
|
|
1226
|
+
const reported = new Set(manualActions.map(({ resourceId, field }) => `${resourceId}:${field}`));
|
|
1227
|
+
for (const constraint of model.relationshipConstraints?.acyclic || []) {
|
|
1228
|
+
const candidates = records.filter(({ type }) => type === constraint.resourceType);
|
|
1229
|
+
for (const record of candidates) {
|
|
1230
|
+
const chain = [];
|
|
1231
|
+
const positions = new Map();
|
|
1232
|
+
let current = record;
|
|
1233
|
+
while (current?.type === constraint.resourceType) {
|
|
1234
|
+
if (positions.has(current.id)) {
|
|
1235
|
+
const cycle = [...chain.slice(positions.get(current.id)), current.id];
|
|
1236
|
+
const key = `${record.id}:${constraint.field}`;
|
|
1237
|
+
if (!reported.has(key)) {
|
|
1238
|
+
manualActions.push({
|
|
1239
|
+
resourceId: record.id,
|
|
1240
|
+
field: constraint.field,
|
|
1241
|
+
value: record[constraint.field],
|
|
1242
|
+
message: `Break the model-v2 relationship cycle: ${cycle.join(" -> ")}.`
|
|
1243
|
+
});
|
|
1244
|
+
reported.add(key);
|
|
1245
|
+
}
|
|
1246
|
+
break;
|
|
1247
|
+
}
|
|
1248
|
+
positions.set(current.id, chain.length);
|
|
1249
|
+
chain.push(current.id);
|
|
1250
|
+
current = byId.get(current[constraint.field]);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
for (const constraint of model.relationshipConstraints?.unique || []) {
|
|
1256
|
+
const keys = new Map();
|
|
1257
|
+
for (const record of records) {
|
|
1258
|
+
if (record.type !== constraint.resourceType) continue;
|
|
1259
|
+
if (constraint.statuses && !constraint.statuses.includes(record.status)) continue;
|
|
1260
|
+
const keyValue = JSON.stringify((constraint.fields || []).map((field) => (
|
|
1261
|
+
Array.isArray(record[field]) ? [...record[field]].sort() : record[field] ?? null
|
|
1262
|
+
)));
|
|
1263
|
+
const previous = keys.get(keyValue);
|
|
1264
|
+
if (!previous) {
|
|
1265
|
+
keys.set(keyValue, record);
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
const key = `${record.id}:${constraint.fields.join(",")}`;
|
|
1269
|
+
if (!reported.has(key)) {
|
|
1270
|
+
manualActions.push({
|
|
1271
|
+
resourceId: record.id,
|
|
1272
|
+
field: constraint.fields.join(", "),
|
|
1273
|
+
value: Object.fromEntries(constraint.fields.map((field) => [field, record[field]])),
|
|
1274
|
+
message: `Resolve the duplicate active ${constraint.resourceType} relationship shared with "${previous.id}".`
|
|
1275
|
+
});
|
|
1276
|
+
reported.add(key);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function collectRelationTypeActions(records, model, manualActions) {
|
|
1283
|
+
const byId = new Map(records.map((record) => [record.id, record]));
|
|
1284
|
+
const reported = new Set(manualActions.map(({ resourceId, field }) => `${resourceId}:${field}`));
|
|
1285
|
+
const checkRelation = (resourceId, fieldName, value, field) => {
|
|
1286
|
+
if (!field?.relation || migrationValueMissing(value)) return;
|
|
1287
|
+
const ids = Array.isArray(value) ? value : [value];
|
|
1288
|
+
const invalid = ids.filter((id) => {
|
|
1289
|
+
const target = byId.get(id);
|
|
1290
|
+
return target && !field.relation.includes("*") && !field.relation.includes(target.type);
|
|
1291
|
+
});
|
|
1292
|
+
if (!invalid.length) return;
|
|
1293
|
+
const key = `${resourceId}:${fieldName}`;
|
|
1294
|
+
if (reported.has(key)) return;
|
|
1295
|
+
manualActions.push({
|
|
1296
|
+
resourceId,
|
|
1297
|
+
field: fieldName,
|
|
1298
|
+
value: invalid,
|
|
1299
|
+
message: `Replace IDs whose resource type is not allowed by model v2 (${field.relation.join(" or ")}).`
|
|
1300
|
+
});
|
|
1301
|
+
reported.add(key);
|
|
1302
|
+
};
|
|
1303
|
+
const checkObject = (resourceId, prefix, value, schema) => {
|
|
1304
|
+
if (!schema || !value || Array.isArray(value) || typeof value !== "object") return;
|
|
1305
|
+
for (const [name, property] of Object.entries(schema.properties || {})) {
|
|
1306
|
+
const nested = value[name];
|
|
1307
|
+
checkRelation(resourceId, `${prefix}.${name}`, nested, property);
|
|
1308
|
+
if (property.type === "object" && property.objectType) {
|
|
1309
|
+
checkObject(resourceId, `${prefix}.${name}`, nested, model.objectTypes?.[property.objectType]);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
for (const record of records) {
|
|
1314
|
+
const definition = model.resources[record.type];
|
|
1315
|
+
if (!definition) continue;
|
|
1316
|
+
for (const [name, field] of Object.entries({ ...model.commonFields, ...definition.fields })) {
|
|
1317
|
+
const value = record[name];
|
|
1318
|
+
checkRelation(record.id, name, value, field);
|
|
1319
|
+
if (field.type === "object" && field.objectType) {
|
|
1320
|
+
checkObject(record.id, name, value, model.objectTypes?.[field.objectType]);
|
|
1321
|
+
}
|
|
1322
|
+
if (field.type === "array" && field.itemObjectType && Array.isArray(value)) {
|
|
1323
|
+
for (const [index, item] of value.entries()) {
|
|
1324
|
+
checkObject(record.id, `${name}[${index}]`, item, model.objectTypes?.[field.itemObjectType]);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function collectObjectShapeActions(
|
|
1332
|
+
resourceId,
|
|
1333
|
+
path,
|
|
1334
|
+
value,
|
|
1335
|
+
objectType,
|
|
1336
|
+
model,
|
|
1337
|
+
missing,
|
|
1338
|
+
manualActions,
|
|
1339
|
+
reportedMissing,
|
|
1340
|
+
reportedManual
|
|
1341
|
+
) {
|
|
1342
|
+
const schema = model.objectTypes?.[objectType];
|
|
1343
|
+
if (!schema) return;
|
|
1344
|
+
const properties = schema.properties || {};
|
|
1345
|
+
if (schema.keyFormat === "namespace") {
|
|
1346
|
+
for (const name of Object.keys(value)) {
|
|
1347
|
+
if (EXTENSION_NAMESPACE_PATTERN.test(name)) continue;
|
|
1348
|
+
const field = `${path}.${name}`;
|
|
1349
|
+
const key = `${resourceId}:${field}`;
|
|
1350
|
+
if (!reportedManual.has(key)) {
|
|
1351
|
+
manualActions.push({
|
|
1352
|
+
resourceId,
|
|
1353
|
+
field,
|
|
1354
|
+
value: value[name],
|
|
1355
|
+
message: "Extension namespaces must use lowercase dot-separated names."
|
|
1356
|
+
});
|
|
1357
|
+
reportedManual.add(key);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const required = new Set(schema.required || []);
|
|
1362
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
1363
|
+
if (property.requiredWhen && migrationConditionMatches(value, property.requiredWhen)) required.add(name);
|
|
1364
|
+
if (
|
|
1365
|
+
!migrationValueMissing(value[name])
|
|
1366
|
+
&& property.allowedWhen
|
|
1367
|
+
&& !migrationConditionMatches(value, property.allowedWhen)
|
|
1368
|
+
) {
|
|
1369
|
+
const field = `${path}.${name}`;
|
|
1370
|
+
const key = `${resourceId}:${field}`;
|
|
1371
|
+
if (!reportedManual.has(key)) {
|
|
1372
|
+
manualActions.push({
|
|
1373
|
+
resourceId,
|
|
1374
|
+
field,
|
|
1375
|
+
value: value[name],
|
|
1376
|
+
message: `Nested field "${field}" is not allowed for the selected ${Object.keys(property.allowedWhen).join(" and ")} in model v2.`
|
|
1377
|
+
});
|
|
1378
|
+
reportedManual.add(key);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
for (const name of required) {
|
|
1383
|
+
if (!migrationValueMissing(value[name])) continue;
|
|
1384
|
+
const field = `${path}.${name}`;
|
|
1385
|
+
const key = `${resourceId}:${field}`;
|
|
1386
|
+
if (!reportedMissing.has(key)) {
|
|
1387
|
+
missing.push({ resourceId, field });
|
|
1388
|
+
reportedMissing.add(key);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
for (const [name, nested] of Object.entries(value)) {
|
|
1392
|
+
const property = properties[name];
|
|
1393
|
+
if (!property) {
|
|
1394
|
+
if (schema.additionalProperties === true) continue;
|
|
1395
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
1396
|
+
if (
|
|
1397
|
+
schema.additionalProperties.type === "object"
|
|
1398
|
+
&& schema.additionalProperties.objectType
|
|
1399
|
+
&& nested
|
|
1400
|
+
&& !Array.isArray(nested)
|
|
1401
|
+
&& typeof nested === "object"
|
|
1402
|
+
) {
|
|
1403
|
+
collectObjectShapeActions(
|
|
1404
|
+
resourceId,
|
|
1405
|
+
`${path}.${name}`,
|
|
1406
|
+
nested,
|
|
1407
|
+
schema.additionalProperties.objectType,
|
|
1408
|
+
model,
|
|
1409
|
+
missing,
|
|
1410
|
+
manualActions,
|
|
1411
|
+
reportedMissing,
|
|
1412
|
+
reportedManual
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
const field = `${path}.${name}`;
|
|
1418
|
+
const key = `${resourceId}:${field}`;
|
|
1419
|
+
if (!reportedManual.has(key)) {
|
|
1420
|
+
manualActions.push({
|
|
1421
|
+
resourceId,
|
|
1422
|
+
field,
|
|
1423
|
+
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.`
|
|
1425
|
+
});
|
|
1426
|
+
reportedManual.add(key);
|
|
1427
|
+
}
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
if (
|
|
1431
|
+
property.type === "object"
|
|
1432
|
+
&& property.objectType
|
|
1433
|
+
&& nested
|
|
1434
|
+
&& !Array.isArray(nested)
|
|
1435
|
+
&& typeof nested === "object"
|
|
1436
|
+
) {
|
|
1437
|
+
collectObjectShapeActions(
|
|
1438
|
+
resourceId,
|
|
1439
|
+
`${path}.${name}`,
|
|
1440
|
+
nested,
|
|
1441
|
+
property.objectType,
|
|
1442
|
+
model,
|
|
1443
|
+
missing,
|
|
1444
|
+
manualActions,
|
|
1445
|
+
reportedMissing,
|
|
1446
|
+
reportedManual
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function migrationConditionMatches(record, condition) {
|
|
1453
|
+
return Object.entries(condition).every(([name, expected]) => (
|
|
1454
|
+
Array.isArray(expected) ? expected.includes(record[name]) : record[name] === expected
|
|
1455
|
+
));
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function migrationValueMissing(value) {
|
|
1459
|
+
return value === undefined
|
|
1460
|
+
|| value === null
|
|
1461
|
+
|| (typeof value === "string" && value.trim() === "")
|
|
1462
|
+
|| (Array.isArray(value) && value.length === 0);
|
|
1463
|
+
}
|