filegrc 0.3.4 → 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 +24 -6
- package/model/index.js +41 -3
- package/model/v1.json +81 -47
- package/model/v2.json +8022 -0
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +89 -8
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +109 -65
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +563 -148
- package/src/collection-review.js +185 -0
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +149 -77
- package/src/external-reviewer.js +165 -0
- package/src/files.js +267 -29
- package/src/git.js +239 -41
- package/src/index.js +41 -7
- package/src/model-docs.js +103 -7
- package/src/model-migration.js +1958 -0
- package/src/mutation.js +42 -0
- package/src/obligations.js +502 -95
- package/src/parties.js +17 -2
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +70 -60
- package/src/program-readiness.js +470 -130
- package/src/reconciliation.js +277 -0
- package/src/resource-status.js +17 -0
- package/src/server.js +347 -48
- package/src/setup.js +57 -26
- package/src/source-coverage.js +61 -0
- package/src/state.js +122 -25
- package/src/timing.js +41 -0
- package/src/validate.js +707 -44
- package/src/web.js +1440 -304
- package/src/workflow.js +1595 -0
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
package/src/validate.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
2
4
|
import { getResourceDefinition } from "../model/index.js";
|
|
3
5
|
import { isSafeGitName } from "./git-name.js";
|
|
4
6
|
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
|
|
@@ -7,19 +9,34 @@ import { obligationIsRunning } from "./program-lifecycle.js";
|
|
|
7
9
|
import { partyPeople } from "./parties.js";
|
|
8
10
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
9
11
|
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
12
|
+
import { recordTiming } from "./timing.js";
|
|
10
13
|
import { indexResources, loadWorkspace } from "./workspace.js";
|
|
11
14
|
|
|
12
15
|
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
16
|
+
const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
13
17
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
14
18
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
15
19
|
const MAX_OBLIGATION_OFFSET_DAYS = 36_600;
|
|
16
20
|
const MAX_OBLIGATION_OFFSET_HOURS = MAX_OBLIGATION_OFFSET_DAYS * 24;
|
|
17
21
|
|
|
18
22
|
export async function validateWorkspace(input = process.cwd()) {
|
|
23
|
+
const timingStarted = performance.now();
|
|
24
|
+
try {
|
|
25
|
+
return await validateWorkspaceUnmeasured(input);
|
|
26
|
+
} finally {
|
|
27
|
+
recordTiming("validation", performance.now() - timingStarted);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function validateWorkspaceUnmeasured(input) {
|
|
19
32
|
const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
|
|
20
33
|
const diagnostics = [...loaded.diagnostics];
|
|
21
34
|
const { byId } = indexResources(loaded.resources);
|
|
22
35
|
const seen = new Map();
|
|
36
|
+
const pathById = new Map(loaded.entries.map((entry) => [
|
|
37
|
+
entry.record?.id,
|
|
38
|
+
`data/${entry.relativePath}`
|
|
39
|
+
]));
|
|
23
40
|
const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
|
|
24
41
|
const obligationsByControl = new Map();
|
|
25
42
|
for (const obligation of loaded.resources.filter((record) => record.type === "obligation" && record.status !== "retired")) {
|
|
@@ -55,8 +72,20 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
55
72
|
validateLocation(record, definition, entry.relativePath, diagnostics);
|
|
56
73
|
validateRecord(record, definition, loaded.model, displayPath, diagnostics);
|
|
57
74
|
validateDateRanges(record, displayPath, diagnostics);
|
|
58
|
-
if (record.type === "
|
|
75
|
+
if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
|
|
76
|
+
if (record.type === "collection-review") {
|
|
77
|
+
validateCollectionReview(record, loaded.model, loaded.resources, byId, displayPath, diagnostics);
|
|
78
|
+
}
|
|
79
|
+
if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
|
|
80
|
+
if (record.type === "action-item") {
|
|
81
|
+
validateCompletedObligationAction(record, byId, loaded.model, displayPath, diagnostics);
|
|
82
|
+
}
|
|
83
|
+
if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
|
|
59
84
|
if (record.type === "evidence") validateEvidencePaths(record, displayPath, diagnostics);
|
|
85
|
+
validateCoverage(record, displayPath, diagnostics);
|
|
86
|
+
validateClassification(record, loaded.workspace, displayPath, diagnostics);
|
|
87
|
+
validateCompletionDates(record, displayPath, diagnostics);
|
|
88
|
+
await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
|
|
60
89
|
|
|
61
90
|
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
62
91
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
@@ -96,12 +125,21 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
96
125
|
}
|
|
97
126
|
}
|
|
98
127
|
}
|
|
128
|
+
validateNestedRelations(fieldName, value, field, loaded.model, byId, displayPath, diagnostics);
|
|
99
129
|
}
|
|
100
130
|
validateIndependentApproval(record, byId, displayPath, diagnostics);
|
|
101
|
-
validateCompletedObligationEvent(record, byId, displayPath, diagnostics);
|
|
131
|
+
validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
|
|
102
132
|
validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, displayPath, diagnostics);
|
|
103
133
|
await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
|
|
134
|
+
await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
|
|
104
135
|
}
|
|
136
|
+
validateRelationshipConstraints(
|
|
137
|
+
loaded.resources,
|
|
138
|
+
loaded.model,
|
|
139
|
+
byId,
|
|
140
|
+
pathById,
|
|
141
|
+
diagnostics
|
|
142
|
+
);
|
|
105
143
|
|
|
106
144
|
diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
|
|
107
145
|
return {
|
|
@@ -116,10 +154,93 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
116
154
|
};
|
|
117
155
|
}
|
|
118
156
|
|
|
157
|
+
function validateCollectionReview(record, model, resources, byId, path, diagnostics) {
|
|
158
|
+
if (record.status !== "active") return;
|
|
159
|
+
const configuration = model.collectionReviews?.[record.resourceType];
|
|
160
|
+
if (!configuration) return;
|
|
161
|
+
const allowedDecisions = configuration.decisions || ["complete"];
|
|
162
|
+
if (!allowedDecisions.includes(record.decision)) {
|
|
163
|
+
diagnostics.push(error(
|
|
164
|
+
"invalid-collection-review-decision",
|
|
165
|
+
path,
|
|
166
|
+
`${configuration.title} review must use one of: ${allowedDecisions.join(", ")}.`
|
|
167
|
+
));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const recordCount = resources.filter(({ type }) => type === record.resourceType).length;
|
|
171
|
+
if (!recordCount && record.decision === "complete") {
|
|
172
|
+
diagnostics.push(error(
|
|
173
|
+
"invalid-collection-review-decision",
|
|
174
|
+
path,
|
|
175
|
+
`${configuration.title} has no records and cannot use the complete conclusion.`
|
|
176
|
+
));
|
|
177
|
+
}
|
|
178
|
+
if (recordCount && record.decision === "zero-population") {
|
|
179
|
+
diagnostics.push(error(
|
|
180
|
+
"invalid-collection-review-decision",
|
|
181
|
+
path,
|
|
182
|
+
`${configuration.title} has ${recordCount} records and cannot use the zero-population conclusion.`
|
|
183
|
+
));
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
record.decision === "externally-managed"
|
|
187
|
+
&& byId.get(record.authoritativeSystemId)?.status !== "active"
|
|
188
|
+
) {
|
|
189
|
+
diagnostics.push(error(
|
|
190
|
+
"inactive-authoritative-system",
|
|
191
|
+
path,
|
|
192
|
+
`${configuration.title} must name an active authoritative System for an externally managed conclusion.`
|
|
193
|
+
));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function fingerprintWorkspace(input = process.cwd()) {
|
|
198
|
+
const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
|
|
199
|
+
const hash = createHash("sha256");
|
|
200
|
+
hash.update(`model\0${loaded.model.modelVersion}\0`);
|
|
201
|
+
for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
|
|
202
|
+
hash.update(`record\0${entry.relativePath}\0${entry.source.length}\0${entry.source}`);
|
|
203
|
+
const definition = loaded.model.resources[entry.record?.type];
|
|
204
|
+
if (!definition) continue;
|
|
205
|
+
for (const item of markdownEntries(loaded.model, entry.record).sort((a, b) => a.path.localeCompare(b.path))) {
|
|
206
|
+
try {
|
|
207
|
+
const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
208
|
+
hash.update(`markdown\0${item.path}\0${source.length}\0${source}`);
|
|
209
|
+
} catch {
|
|
210
|
+
hash.update(`markdown-missing\0${item.path}\0`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
214
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
215
|
+
if (field.format !== "data-path" && field.items !== "data-path") continue;
|
|
216
|
+
const value = entry.record[name];
|
|
217
|
+
const paths = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
|
|
218
|
+
for (const path of [...paths].sort()) {
|
|
219
|
+
try {
|
|
220
|
+
const file = await stat(resolveDataPath(loaded.root, path));
|
|
221
|
+
hash.update(`data-path\0${path}\0${file.isFile() ? "file" : "other"}\0`);
|
|
222
|
+
} catch {
|
|
223
|
+
hash.update(`data-path-missing\0${path}\0`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { fingerprint: hash.digest("hex"), loaded };
|
|
229
|
+
}
|
|
230
|
+
|
|
119
231
|
function validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, path, diagnostics) {
|
|
120
232
|
if (record.type !== "control" || record.status !== "implemented") return;
|
|
121
233
|
const schedules = obligationsByControl.get(record.id) || [];
|
|
122
|
-
if (!schedules.length
|
|
234
|
+
if (!schedules.length) {
|
|
235
|
+
if (record.operationPattern === "continuous") return;
|
|
236
|
+
diagnostics.push(error(
|
|
237
|
+
"control-work-queue-missing",
|
|
238
|
+
path,
|
|
239
|
+
"An implemented scheduled, event-driven, or mixed Control must link at least one active Obligation."
|
|
240
|
+
));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (schedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))) return;
|
|
123
244
|
const stopped = schedules.filter((obligation) => !obligationIsRunning(obligation, byId, asOf));
|
|
124
245
|
const paused = stopped.filter((obligation) => obligation.status === "paused");
|
|
125
246
|
const waiting = stopped.filter((obligation) => obligation.status === "active");
|
|
@@ -149,9 +270,7 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
|
|
|
149
270
|
function validateDateRanges(record, path, diagnostics) {
|
|
150
271
|
for (const [startField, endField] of [
|
|
151
272
|
["startDate", "endDate"],
|
|
152
|
-
["
|
|
153
|
-
["candidatePeriodStart", "candidatePeriodEnd"],
|
|
154
|
-
["dueWindowStart", "dueWindowEnd"]
|
|
273
|
+
["startsOn", "endsOn"]
|
|
155
274
|
]) {
|
|
156
275
|
const start = record[startField];
|
|
157
276
|
const end = record[endField];
|
|
@@ -165,7 +284,25 @@ function validateDateRanges(record, path, diagnostics) {
|
|
|
165
284
|
}
|
|
166
285
|
}
|
|
167
286
|
|
|
168
|
-
function
|
|
287
|
+
function validateAppointment(record, byId, path, diagnostics) {
|
|
288
|
+
if ((record.scopeResourceIds || []).includes(record.id)) {
|
|
289
|
+
diagnostics.push(error(
|
|
290
|
+
"invalid-appointment-scope",
|
|
291
|
+
path,
|
|
292
|
+
"An Appointment cannot include itself in scopeResourceIds."
|
|
293
|
+
));
|
|
294
|
+
}
|
|
295
|
+
if (record.status !== "active") return;
|
|
296
|
+
const holder = byId.get(record.holderId);
|
|
297
|
+
if (holder?.type === "person" && holder.status === "active") return;
|
|
298
|
+
diagnostics.push(error(
|
|
299
|
+
"inactive-appointment-holder",
|
|
300
|
+
path,
|
|
301
|
+
"An active Appointment must have an active Person as its holder."
|
|
302
|
+
));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function validateCompletedObligationEvent(record, byId, model, path, diagnostics) {
|
|
169
306
|
if (record.type !== "obligation-event" || record.status !== "complete") return;
|
|
170
307
|
if (record.completedOn && record.occurredOn && record.completedOn < record.occurredOn) {
|
|
171
308
|
diagnostics.push(error(
|
|
@@ -193,9 +330,14 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
|
|
|
193
330
|
continue;
|
|
194
331
|
}
|
|
195
332
|
const obligation = byId.get(action.obligationId);
|
|
196
|
-
const expectedTypes = obligation?.type === "obligation"
|
|
333
|
+
const expectedTypes = obligation?.type === "obligation"
|
|
334
|
+
? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
|
|
335
|
+
: [];
|
|
197
336
|
if (!expectedTypes.length) continue;
|
|
198
|
-
const
|
|
337
|
+
const completionIds = String(model.modelVersion) === "3"
|
|
338
|
+
? action.completionResourceIds || []
|
|
339
|
+
: [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
|
|
340
|
+
const linked = [...new Set(completionIds)]
|
|
199
341
|
.map((id) => byId.get(id))
|
|
200
342
|
.filter(Boolean);
|
|
201
343
|
if (!linked.some((item) => expectedTypes.includes(item.type))) {
|
|
@@ -208,6 +350,27 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
|
|
|
208
350
|
}
|
|
209
351
|
}
|
|
210
352
|
|
|
353
|
+
function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
|
|
354
|
+
if (
|
|
355
|
+
String(model.modelVersion) !== "3"
|
|
356
|
+
|| record.status !== "done"
|
|
357
|
+
|| !record.obligationId
|
|
358
|
+
) return;
|
|
359
|
+
const obligation = byId.get(record.obligationId);
|
|
360
|
+
if (obligation?.type !== "obligation") return;
|
|
361
|
+
const expectedTypes = model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || [];
|
|
362
|
+
if (!expectedTypes.length) return;
|
|
363
|
+
const linked = [...new Set(record.completionResourceIds || [])]
|
|
364
|
+
.map((id) => byId.get(id))
|
|
365
|
+
.filter(Boolean);
|
|
366
|
+
if (linked.some((item) => expectedTypes.includes(item.type))) return;
|
|
367
|
+
diagnostics.push(error(
|
|
368
|
+
"missing-obligation-completion",
|
|
369
|
+
path,
|
|
370
|
+
`A done Action Item linked to "${obligation.id}" needs completionResourceIds containing ${expectedTypes.join(" or ")}.`
|
|
371
|
+
));
|
|
372
|
+
}
|
|
373
|
+
|
|
211
374
|
function validateEvidencePaths(record, path, diagnostics) {
|
|
212
375
|
const expectedPrefix = `evidence/${record.id}/`;
|
|
213
376
|
for (const filePath of record.filePaths || []) {
|
|
@@ -239,9 +402,38 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
|
|
|
239
402
|
}
|
|
240
403
|
}
|
|
241
404
|
|
|
242
|
-
function validateObligation(record, path, diagnostics) {
|
|
405
|
+
function validateObligation(record, model, byId, path, diagnostics) {
|
|
243
406
|
const recurrence = record.recurrence;
|
|
244
407
|
if (!recurrence || Array.isArray(recurrence) || typeof recurrence !== "object") return;
|
|
408
|
+
const activity = model.obligationActivities?.[record.activityType];
|
|
409
|
+
if (
|
|
410
|
+
activity
|
|
411
|
+
&& Array.isArray(activity.recurrenceModes)
|
|
412
|
+
&& !activity.recurrenceModes.includes(recurrence.mode)
|
|
413
|
+
) {
|
|
414
|
+
diagnostics.push(error(
|
|
415
|
+
"invalid-obligation-activity",
|
|
416
|
+
path,
|
|
417
|
+
`${record.activityType} obligations require ${activity.recurrenceModes.join(" or ")} recurrence.`
|
|
418
|
+
));
|
|
419
|
+
}
|
|
420
|
+
if (activity) {
|
|
421
|
+
const allowedScopeTypes = new Set(activity.scopeResourceTypes || []);
|
|
422
|
+
for (const [field, ids] of [
|
|
423
|
+
["scopeResourceIds", record.scopeResourceIds || []],
|
|
424
|
+
["templateResourceId", record.templateResourceId ? [record.templateResourceId] : []]
|
|
425
|
+
]) {
|
|
426
|
+
for (const id of ids) {
|
|
427
|
+
const target = byId.get(id);
|
|
428
|
+
if (!target || allowedScopeTypes.has(target.type)) continue;
|
|
429
|
+
diagnostics.push(error(
|
|
430
|
+
"invalid-obligation-scope",
|
|
431
|
+
path,
|
|
432
|
+
`${field} references ${target.type} "${id}", but ${record.activityType} allows ${[...allowedScopeTypes].join(" or ")} scope.`
|
|
433
|
+
));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
245
437
|
if (recurrence.mode === "calendar") {
|
|
246
438
|
const normalized = { ...recurrence, anchorDate: recurrence.anchorDate || record.startsOn };
|
|
247
439
|
if (!validCalendarRecurrence(normalized)) {
|
|
@@ -252,13 +444,30 @@ function validateObligation(record, path, diagnostics) {
|
|
|
252
444
|
));
|
|
253
445
|
}
|
|
254
446
|
} else if (recurrence.mode === "event") {
|
|
255
|
-
if (
|
|
447
|
+
if (!model.policyEvents?.[recurrence.eventType]) {
|
|
256
448
|
diagnostics.push(error(
|
|
257
449
|
"invalid-obligation-recurrence",
|
|
258
450
|
path,
|
|
259
|
-
"Event recurrence
|
|
451
|
+
"Event recurrence must use a policy event defined by the model."
|
|
260
452
|
));
|
|
261
453
|
}
|
|
454
|
+
if (record.eventRiskLevels?.length) {
|
|
455
|
+
if (recurrence.eventType !== "person-ended") {
|
|
456
|
+
diagnostics.push(error(
|
|
457
|
+
"invalid-obligation-event-filter",
|
|
458
|
+
path,
|
|
459
|
+
"eventRiskLevels may be used only with the person-ended Policy Event."
|
|
460
|
+
));
|
|
461
|
+
}
|
|
462
|
+
const invalid = record.eventRiskLevels.filter((value) => !["normal", "high"].includes(value));
|
|
463
|
+
if (invalid.length) {
|
|
464
|
+
diagnostics.push(error(
|
|
465
|
+
"invalid-obligation-event-filter",
|
|
466
|
+
path,
|
|
467
|
+
`Departure risk filters must be normal or high, not ${invalid.join(", ")}.`
|
|
468
|
+
));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
262
471
|
} else {
|
|
263
472
|
diagnostics.push(error(
|
|
264
473
|
"invalid-obligation-recurrence",
|
|
@@ -268,53 +477,297 @@ function validateObligation(record, path, diagnostics) {
|
|
|
268
477
|
}
|
|
269
478
|
|
|
270
479
|
const window = record.window;
|
|
271
|
-
if (!window || Array.isArray(window) || typeof window !== "object")
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
480
|
+
if (!window || Array.isArray(window) || typeof window !== "object") {
|
|
481
|
+
if (recurrence.mode === "event") {
|
|
482
|
+
diagnostics.push(error("invalid-obligation-window", path, "Event obligations require an explicit deadline window."));
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
for (const name of ["startsAfter", "dueAfter"]) {
|
|
487
|
+
if (window[name] === undefined) continue;
|
|
275
488
|
if (!Number.isInteger(window[name])) {
|
|
276
489
|
diagnostics.push(error("invalid-obligation-window", path, `window.${name} must be an integer.`));
|
|
277
490
|
}
|
|
278
491
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
492
|
+
const limit = window.precision === "timestamp" ? MAX_OBLIGATION_OFFSET_HOURS : MAX_OBLIGATION_OFFSET_DAYS;
|
|
493
|
+
const unit = window.precision === "timestamp" ? "hours" : "days";
|
|
494
|
+
for (const name of ["startsAfter", "dueAfter"]) {
|
|
495
|
+
if (Number.isInteger(window[name]) && Math.abs(window[name]) > limit) {
|
|
496
|
+
diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${limit.toLocaleString("en-US")} ${unit} of the policy event.`));
|
|
282
497
|
}
|
|
283
498
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
499
|
+
if (recurrence.mode === "calendar" && window.precision !== "date") {
|
|
500
|
+
diagnostics.push(error("invalid-obligation-window", path, "Calendar obligations require a date-precision window."));
|
|
501
|
+
}
|
|
502
|
+
if (
|
|
503
|
+
Number.isInteger(window.dueAfter)
|
|
504
|
+
&& window.dueAfter < (Number.isInteger(window.startsAfter) ? window.startsAfter : 0)
|
|
505
|
+
) {
|
|
506
|
+
diagnostics.push(error("invalid-obligation-window", path, "window.dueAfter must be on or after window.startsAfter."));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function validatePolicyEvent(record, model, byId, path, diagnostics) {
|
|
511
|
+
const event = model.policyEvents?.[record.eventType];
|
|
512
|
+
if (!event) return;
|
|
513
|
+
const rules = event.subjectRules || [];
|
|
514
|
+
const allowedTypes = new Set(rules.map(({ resourceType }) => resourceType));
|
|
515
|
+
const counts = new Map();
|
|
516
|
+
for (const id of new Set(record.subjectResourceIds || [])) {
|
|
517
|
+
const target = byId.get(id);
|
|
518
|
+
if (!target) continue;
|
|
519
|
+
counts.set(target.type, (counts.get(target.type) || 0) + 1);
|
|
520
|
+
if (!allowedTypes.has(target.type)) {
|
|
521
|
+
diagnostics.push(error(
|
|
522
|
+
"invalid-policy-event-subject",
|
|
523
|
+
path,
|
|
524
|
+
`${record.eventType} cannot use ${target.type} "${id}" as a subject.`
|
|
525
|
+
));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const { resourceType, minimum = 0, maximum } of rules) {
|
|
529
|
+
const count = counts.get(resourceType) || 0;
|
|
530
|
+
if (count < minimum) {
|
|
531
|
+
diagnostics.push(error(
|
|
532
|
+
"invalid-policy-event-subject",
|
|
533
|
+
path,
|
|
534
|
+
`${record.eventType} requires at least ${minimum} ${resourceType} subject${minimum === 1 ? "" : "s"}.`
|
|
535
|
+
));
|
|
536
|
+
}
|
|
537
|
+
if (Number.isInteger(maximum) && count > maximum) {
|
|
538
|
+
diagnostics.push(error(
|
|
539
|
+
"invalid-policy-event-subject",
|
|
540
|
+
path,
|
|
541
|
+
`${record.eventType} allows at most ${maximum} ${resourceType} subject${maximum === 1 ? "" : "s"}.`
|
|
542
|
+
));
|
|
287
543
|
}
|
|
288
544
|
}
|
|
289
|
-
|
|
290
|
-
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function validateCompletionDates(record, path, diagnostics) {
|
|
548
|
+
if (record.startedAt && record.completedAt && record.completedAt < record.startedAt) {
|
|
549
|
+
diagnostics.push(error("invalid-completion-order", path, "completedAt cannot be before startedAt."));
|
|
550
|
+
}
|
|
551
|
+
if (record.completedOn && record.reviewedOn && record.reviewedOn < record.completedOn) {
|
|
552
|
+
diagnostics.push(error("invalid-completion-order", path, "reviewedOn cannot be before completedOn."));
|
|
553
|
+
}
|
|
554
|
+
if (record.completedOn && record.approvedOn && record.approvedOn < record.completedOn) {
|
|
555
|
+
diagnostics.push(error("invalid-completion-order", path, "approvedOn cannot be before completedOn."));
|
|
291
556
|
}
|
|
292
|
-
|
|
293
|
-
|
|
557
|
+
validateOrderedDates(record, path, diagnostics, [
|
|
558
|
+
"requestedOn",
|
|
559
|
+
"approvedOn",
|
|
560
|
+
"provisionedOn",
|
|
561
|
+
"deprovisionedOn"
|
|
562
|
+
]);
|
|
563
|
+
validateOrderedDates(record, path, diagnostics, [
|
|
564
|
+
"detectedAt",
|
|
565
|
+
"declaredAt",
|
|
566
|
+
"containedAt",
|
|
567
|
+
"eradicatedAt",
|
|
568
|
+
"recoveredAt",
|
|
569
|
+
"closedAt"
|
|
570
|
+
]);
|
|
571
|
+
validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
|
|
572
|
+
validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
|
|
573
|
+
if (record.acceptance) {
|
|
574
|
+
validateOrderedDates(record.acceptance, path, diagnostics, ["acceptedOn", "expiresOn"], "acceptance.");
|
|
294
575
|
}
|
|
576
|
+
if (record.approval) {
|
|
577
|
+
validateOrderedDates(record.approval, path, diagnostics, ["approvedOn", "expiresOn"], "approval.");
|
|
578
|
+
if (
|
|
579
|
+
record.resolution?.resolvedOn
|
|
580
|
+
&& record.approval.approvedOn
|
|
581
|
+
&& record.resolution.resolvedOn < record.approval.approvedOn
|
|
582
|
+
) {
|
|
583
|
+
diagnostics.push(error(
|
|
584
|
+
"invalid-completion-order",
|
|
585
|
+
path,
|
|
586
|
+
"resolution.resolvedOn cannot be before approval.approvedOn."
|
|
587
|
+
));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function validateOrderedDates(record, path, diagnostics, fields, prefix = "") {
|
|
593
|
+
let previous = null;
|
|
594
|
+
for (const field of fields) {
|
|
595
|
+
const value = record[field];
|
|
596
|
+
if (!value) continue;
|
|
597
|
+
if (previous && value < previous.value) {
|
|
598
|
+
diagnostics.push(error(
|
|
599
|
+
"invalid-completion-order",
|
|
600
|
+
path,
|
|
601
|
+
`${prefix}${field} cannot be before ${prefix}${previous.field}.`
|
|
602
|
+
));
|
|
603
|
+
}
|
|
604
|
+
previous = { field, value };
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function validateAttestationBinding(record, model, root, byId, path, diagnostics) {
|
|
295
609
|
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
)
|
|
610
|
+
record.type !== "attestation"
|
|
611
|
+
|| record.status !== "completed"
|
|
612
|
+
|| record.attestationMethod !== "git-approval"
|
|
613
|
+
|| !record.contentRevisions
|
|
614
|
+
) return;
|
|
615
|
+
const expectedPaths = new Set();
|
|
616
|
+
const subjectPaths = new Map();
|
|
617
|
+
for (const id of record.subjectResourceIds || []) {
|
|
618
|
+
const subject = byId.get(id);
|
|
619
|
+
if (!subject) continue;
|
|
620
|
+
const paths = [];
|
|
621
|
+
for (const item of markdownEntries(model, subject)) {
|
|
622
|
+
try {
|
|
623
|
+
if ((await stat(resolveDataPath(root, item.path))).isFile()) paths.push(item.path);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
if (error.code !== "ENOENT") throw error;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
subjectPaths.set(id, paths);
|
|
629
|
+
for (const item of paths) expectedPaths.add(item);
|
|
630
|
+
}
|
|
631
|
+
const actualPaths = Object.keys(record.contentRevisions);
|
|
632
|
+
if (!expectedPaths.size) {
|
|
633
|
+
diagnostics.push(error(
|
|
634
|
+
"invalid-attestation-binding",
|
|
635
|
+
path,
|
|
636
|
+
"A git-approval Attestation must reference authored Policy, Document, or Training content."
|
|
637
|
+
));
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (!actualPaths.length) {
|
|
641
|
+
diagnostics.push(error(
|
|
642
|
+
"invalid-attestation-binding",
|
|
643
|
+
path,
|
|
644
|
+
"A git-approval Attestation must bind at least one subject Markdown file."
|
|
645
|
+
));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const invalid = actualPaths.filter((item) => (
|
|
649
|
+
!expectedPaths.has(item)
|
|
650
|
+
|| !/^[a-f0-9]{64}$/.test(String(record.contentRevisions[item] || ""))
|
|
651
|
+
));
|
|
652
|
+
const unboundSubjects = [...subjectPaths].filter(([, paths]) => (
|
|
653
|
+
paths.length && !paths.some((item) => actualPaths.includes(item))
|
|
654
|
+
)).map(([id]) => id);
|
|
655
|
+
if (invalid.length || unboundSubjects.length) {
|
|
301
656
|
diagnostics.push(error(
|
|
302
|
-
"invalid-
|
|
657
|
+
"invalid-attestation-binding",
|
|
303
658
|
path,
|
|
304
|
-
|
|
659
|
+
unboundSubjects.length
|
|
660
|
+
? `Attestation contentRevisions must bind authored Markdown for every subject; missing ${unboundSubjects.join(", ")}.`
|
|
661
|
+
: "Attestation contentRevisions must contain valid SHA-256 hashes for subject Markdown paths and no unrelated paths."
|
|
305
662
|
));
|
|
306
663
|
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
667
|
+
const bindingField = approvalBindingField(record, model);
|
|
668
|
+
if (!bindingField || !approvalBound(record) || !record[bindingField]) return;
|
|
669
|
+
const actual = {};
|
|
670
|
+
for (const item of markdownEntries(model, record)) {
|
|
671
|
+
try {
|
|
672
|
+
const source = await readFile(resolveDataPath(root, item.path), "utf8");
|
|
673
|
+
actual[item.path] = createHash("sha256").update(source).digest("hex");
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (error.code !== "ENOENT") throw error;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const expected = record[bindingField];
|
|
679
|
+
const paths = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort();
|
|
680
|
+
const invalid = paths.filter((item) => (
|
|
681
|
+
!/^[a-f0-9]{64}$/.test(String(expected[item] || ""))
|
|
682
|
+
|| expected[item] !== actual[item]
|
|
683
|
+
));
|
|
684
|
+
if (invalid.length) {
|
|
685
|
+
diagnostics.push(error(
|
|
686
|
+
"approval-content-changed",
|
|
687
|
+
path,
|
|
688
|
+
`Approved content no longer matches ${invalid.map((item) => `data/${item}`).join(", ")}. Move the record to draft or in-review, review the change, then approve it again.`
|
|
689
|
+
));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function approvalBound(record) {
|
|
694
|
+
if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
|
|
695
|
+
if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
|
|
696
|
+
if (record.type === "training") return ["active", "retired"].includes(record.status);
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function approvalBindingField(record, model) {
|
|
701
|
+
if (["policy", "document"].includes(record.type)) return "approvedContentRevisions";
|
|
702
|
+
if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
|
|
703
|
+
return "effectiveContentRevisions";
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function validateCoverage(record, path, diagnostics) {
|
|
709
|
+
const coverage = record.type === "workspace" ? record.candidateCoverage : record.coverage;
|
|
710
|
+
if (!coverage) return;
|
|
711
|
+
if (coverage.kind === "range" && coverage.startsOn && coverage.endsOn && coverage.endsOn < coverage.startsOn) {
|
|
712
|
+
diagnostics.push(error("invalid-date-range", path, "coverage.endsOn cannot be before coverage.startsOn."));
|
|
713
|
+
}
|
|
307
714
|
if (
|
|
308
|
-
|
|
309
|
-
&&
|
|
715
|
+
record.type === "audit"
|
|
716
|
+
&& record.auditKind === "soc-2-type-1"
|
|
717
|
+
&& coverage.kind !== "as-of"
|
|
310
718
|
) {
|
|
311
|
-
diagnostics.push(error("invalid-
|
|
719
|
+
diagnostics.push(error("invalid-audit-coverage", path, "A SOC 2 Type 1 Audit requires as-of coverage."));
|
|
312
720
|
}
|
|
313
721
|
if (
|
|
314
|
-
|
|
315
|
-
&&
|
|
722
|
+
record.type === "audit"
|
|
723
|
+
&& record.auditKind === "soc-2-type-2"
|
|
724
|
+
&& coverage.kind !== "range"
|
|
316
725
|
) {
|
|
317
|
-
diagnostics.push(error("invalid-
|
|
726
|
+
diagnostics.push(error("invalid-audit-coverage", path, "A SOC 2 Type 2 Audit requires range coverage."));
|
|
727
|
+
}
|
|
728
|
+
if (
|
|
729
|
+
record.type === "workspace"
|
|
730
|
+
&& record.assuranceGoal === "soc-2-type-1"
|
|
731
|
+
&& coverage.kind !== "as-of"
|
|
732
|
+
) {
|
|
733
|
+
diagnostics.push(error("invalid-candidate-coverage", path, "A Type 1 management goal requires as-of candidate coverage."));
|
|
734
|
+
}
|
|
735
|
+
if (
|
|
736
|
+
record.type === "workspace"
|
|
737
|
+
&& record.assuranceGoal === "soc-2-type-2"
|
|
738
|
+
&& coverage.kind !== "range"
|
|
739
|
+
) {
|
|
740
|
+
diagnostics.push(error("invalid-candidate-coverage", path, "A Type 2 management goal requires range candidate coverage."));
|
|
741
|
+
}
|
|
742
|
+
if (
|
|
743
|
+
["audit-population", "penetration-test"].includes(record.type)
|
|
744
|
+
&& coverage.kind !== "range"
|
|
745
|
+
) {
|
|
746
|
+
diagnostics.push(error("invalid-coverage", path, `${record.type} requires range coverage.`));
|
|
747
|
+
}
|
|
748
|
+
if (
|
|
749
|
+
record.type === "evidence"
|
|
750
|
+
&& record.artifactKind === "population-export"
|
|
751
|
+
&& coverage.kind !== "range"
|
|
752
|
+
) {
|
|
753
|
+
diagnostics.push(error("invalid-coverage", path, "Population Export Evidence requires range coverage."));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function validateClassification(record, workspace, path, diagnostics) {
|
|
758
|
+
if (!record.classificationId) return;
|
|
759
|
+
const definitions = workspace?.classificationDefinitions;
|
|
760
|
+
if (
|
|
761
|
+
!definitions
|
|
762
|
+
|| Array.isArray(definitions)
|
|
763
|
+
|| typeof definitions !== "object"
|
|
764
|
+
|| !Object.hasOwn(definitions, record.classificationId)
|
|
765
|
+
) {
|
|
766
|
+
diagnostics.push(error(
|
|
767
|
+
"unknown-classification",
|
|
768
|
+
path,
|
|
769
|
+
`classificationId references undefined Workspace classification "${record.classificationId}".`
|
|
770
|
+
));
|
|
318
771
|
}
|
|
319
772
|
}
|
|
320
773
|
|
|
@@ -340,6 +793,13 @@ function validateRecord(record, definition, model, path, diagnostics) {
|
|
|
340
793
|
]);
|
|
341
794
|
for (const [name, field] of Object.entries(fields)) {
|
|
342
795
|
if (field.requiredWhen && conditionMatches(record, field.requiredWhen)) required.add(name);
|
|
796
|
+
if (!isMissing(record[name]) && field.allowedWhen && !conditionMatches(record, field.allowedWhen)) {
|
|
797
|
+
diagnostics.push(error(
|
|
798
|
+
"invalid-field",
|
|
799
|
+
path,
|
|
800
|
+
`${name} is not allowed for the selected ${Object.keys(field.allowedWhen).join(" and ")}.`
|
|
801
|
+
));
|
|
802
|
+
}
|
|
343
803
|
if (field.disjointFrom) {
|
|
344
804
|
const values = normalizedValues(record[name]);
|
|
345
805
|
const otherValues = new Set(normalizedValues(record[field.disjointFrom]));
|
|
@@ -365,7 +825,11 @@ function validateRecord(record, definition, model, path, diagnostics) {
|
|
|
365
825
|
for (const [name, value] of Object.entries(record)) {
|
|
366
826
|
const field = fields[name];
|
|
367
827
|
if (!field) {
|
|
368
|
-
diagnostics.push(
|
|
828
|
+
diagnostics.push(error(
|
|
829
|
+
"unknown-field",
|
|
830
|
+
path,
|
|
831
|
+
`Field "${name}" is not defined by model v${model.modelVersion}. Put organization-specific data under extensions.`
|
|
832
|
+
));
|
|
369
833
|
continue;
|
|
370
834
|
}
|
|
371
835
|
validateValue(name, value, field, model, path, diagnostics);
|
|
@@ -378,6 +842,109 @@ function normalizedValues(value) {
|
|
|
378
842
|
return typeof value === "string" ? [value] : [];
|
|
379
843
|
}
|
|
380
844
|
|
|
845
|
+
function validateNestedRelations(name, value, field, model, byId, path, diagnostics) {
|
|
846
|
+
if (
|
|
847
|
+
field.type === "object"
|
|
848
|
+
&& field.objectType
|
|
849
|
+
&& value
|
|
850
|
+
&& !Array.isArray(value)
|
|
851
|
+
&& typeof value === "object"
|
|
852
|
+
) {
|
|
853
|
+
validateObjectRelations(name, value, model.objectTypes?.[field.objectType], model, byId, path, diagnostics);
|
|
854
|
+
}
|
|
855
|
+
if (field.type === "array" && field.itemObjectType && Array.isArray(value)) {
|
|
856
|
+
for (const [index, item] of value.entries()) {
|
|
857
|
+
if (!item || Array.isArray(item) || typeof item !== "object") continue;
|
|
858
|
+
validateObjectRelations(
|
|
859
|
+
`${name}[${index}]`,
|
|
860
|
+
item,
|
|
861
|
+
model.objectTypes?.[field.itemObjectType],
|
|
862
|
+
model,
|
|
863
|
+
byId,
|
|
864
|
+
path,
|
|
865
|
+
diagnostics
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function validateObjectRelations(name, value, schema, model, byId, path, diagnostics) {
|
|
872
|
+
if (!schema) return;
|
|
873
|
+
for (const [propertyName, property] of Object.entries(schema.properties || {})) {
|
|
874
|
+
const nested = value[propertyName];
|
|
875
|
+
if (nested === undefined || nested === null) continue;
|
|
876
|
+
if (property.relation) {
|
|
877
|
+
const ids = Array.isArray(nested) ? nested : [nested];
|
|
878
|
+
for (const id of ids) {
|
|
879
|
+
const target = byId.get(id);
|
|
880
|
+
if (!target) {
|
|
881
|
+
diagnostics.push(error(
|
|
882
|
+
"missing-reference",
|
|
883
|
+
path,
|
|
884
|
+
`${name}.${propertyName} references unknown ID "${id}".`
|
|
885
|
+
));
|
|
886
|
+
} else if (!property.relation.includes("*") && !property.relation.includes(target.type)) {
|
|
887
|
+
diagnostics.push(error(
|
|
888
|
+
"wrong-reference-type",
|
|
889
|
+
path,
|
|
890
|
+
`${name}.${propertyName} references ${target.type} "${id}", expected ${property.relation.join(" or ")}.`
|
|
891
|
+
));
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
validateNestedRelations(`${name}.${propertyName}`, nested, property, model, byId, path, diagnostics);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function validateRelationshipConstraints(resources, model, byId, pathById, diagnostics) {
|
|
900
|
+
for (const constraint of model.relationshipConstraints?.acyclic || []) {
|
|
901
|
+
const candidates = resources.filter(({ type }) => type === constraint.resourceType);
|
|
902
|
+
const visited = new Set();
|
|
903
|
+
for (const record of candidates) {
|
|
904
|
+
if (visited.has(record.id)) continue;
|
|
905
|
+
const chain = [];
|
|
906
|
+
const positions = new Map();
|
|
907
|
+
let current = record;
|
|
908
|
+
while (current?.type === constraint.resourceType && !visited.has(current.id)) {
|
|
909
|
+
if (positions.has(current.id)) {
|
|
910
|
+
const cycle = [...chain.slice(positions.get(current.id)), current.id];
|
|
911
|
+
const cycleRecord = chain[positions.get(current.id)];
|
|
912
|
+
diagnostics.push(error(
|
|
913
|
+
"cyclic-relationship",
|
|
914
|
+
pathById.get(cycleRecord) || `data/${cycleRecord}`,
|
|
915
|
+
`${constraint.field} forms a cycle: ${cycle.join(" -> ")}.`
|
|
916
|
+
));
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
positions.set(current.id, chain.length);
|
|
920
|
+
chain.push(current.id);
|
|
921
|
+
current = byId.get(current[constraint.field]);
|
|
922
|
+
}
|
|
923
|
+
for (const id of chain) visited.add(id);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
for (const constraint of model.relationshipConstraints?.unique || []) {
|
|
928
|
+
const keys = new Map();
|
|
929
|
+
for (const record of resources) {
|
|
930
|
+
if (record.type !== constraint.resourceType) continue;
|
|
931
|
+
if (constraint.statuses && !constraint.statuses.includes(record.status)) continue;
|
|
932
|
+
const key = JSON.stringify((constraint.fields || []).map((field) => {
|
|
933
|
+
const value = record[field];
|
|
934
|
+
return Array.isArray(value) ? [...value].sort() : value ?? null;
|
|
935
|
+
}));
|
|
936
|
+
const previous = keys.get(key);
|
|
937
|
+
if (previous) {
|
|
938
|
+
diagnostics.push(error(
|
|
939
|
+
"duplicate-active-relationship",
|
|
940
|
+
pathById.get(record.id) || `data/${record.id}`,
|
|
941
|
+
`${record.title} duplicates ${previous.title} for ${constraint.fields.join(", ")} while both are ${record.status}.`
|
|
942
|
+
));
|
|
943
|
+
} else keys.set(key, record);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
381
948
|
async function validateMarkdown(record, definition, model, root, path, diagnostics) {
|
|
382
949
|
const present = new Set();
|
|
383
950
|
for (const item of markdownEntries(model, record)) {
|
|
@@ -452,14 +1019,28 @@ function validateValue(name, value, field, model, path, diagnostics) {
|
|
|
452
1019
|
if (typeof value !== "boolean") fail("must be a boolean.");
|
|
453
1020
|
return;
|
|
454
1021
|
case "object":
|
|
455
|
-
if (!value || Array.isArray(value) || typeof value !== "object")
|
|
1022
|
+
if (!value || Array.isArray(value) || typeof value !== "object") {
|
|
1023
|
+
fail("must be an object.");
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (field.objectType) {
|
|
1027
|
+
validateObjectValue(name, value, field.objectType, model, path, diagnostics);
|
|
1028
|
+
}
|
|
456
1029
|
return;
|
|
457
1030
|
case "array":
|
|
458
1031
|
if (!Array.isArray(value)) {
|
|
459
1032
|
fail("must be an array.");
|
|
460
1033
|
return;
|
|
461
1034
|
}
|
|
462
|
-
|
|
1035
|
+
if (
|
|
1036
|
+
["id", "string", "data-path"].includes(field.items)
|
|
1037
|
+
&& new Set(value).size !== value.length
|
|
1038
|
+
) {
|
|
1039
|
+
fail("must not contain duplicate values.");
|
|
1040
|
+
}
|
|
1041
|
+
for (const [index, item] of value.entries()) {
|
|
1042
|
+
validateArrayItem(name, item, field, model, path, diagnostics, index);
|
|
1043
|
+
}
|
|
463
1044
|
return;
|
|
464
1045
|
default:
|
|
465
1046
|
fail(`uses unsupported model type "${field.type}".`);
|
|
@@ -489,9 +1070,12 @@ function validateNumericRange(value, field, fail) {
|
|
|
489
1070
|
}
|
|
490
1071
|
}
|
|
491
1072
|
|
|
492
|
-
function validateArrayItem(name, value,
|
|
1073
|
+
function validateArrayItem(name, value, field, model, path, diagnostics, index) {
|
|
1074
|
+
const type = field.items;
|
|
493
1075
|
if (type === "object" && (!value || Array.isArray(value) || typeof value !== "object")) {
|
|
494
1076
|
diagnostics.push(error("invalid-field", path, `${name} items must be objects.`));
|
|
1077
|
+
} else if (type === "object" && field.itemObjectType) {
|
|
1078
|
+
validateObjectValue(`${name}[${index}]`, value, field.itemObjectType, model, path, diagnostics);
|
|
495
1079
|
} else if ((type === "string" || type === "data-path") && typeof value !== "string") {
|
|
496
1080
|
diagnostics.push(error("invalid-field", path, `${name} items must be strings.`));
|
|
497
1081
|
} else if (type === "id" && (typeof value !== "string" || !ID_PATTERN.test(value))) {
|
|
@@ -499,6 +1083,85 @@ function validateArrayItem(name, value, type, path, diagnostics) {
|
|
|
499
1083
|
}
|
|
500
1084
|
}
|
|
501
1085
|
|
|
1086
|
+
function validateObjectValue(name, value, objectType, model, path, diagnostics) {
|
|
1087
|
+
const schema = model.objectTypes?.[objectType];
|
|
1088
|
+
if (!schema) {
|
|
1089
|
+
diagnostics.push(error("invalid-field", path, `${name}: uses unknown object type "${objectType}".`));
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const properties = schema.properties || {};
|
|
1093
|
+
const required = new Set(schema.required || []);
|
|
1094
|
+
for (const [propertyName, property] of Object.entries(properties)) {
|
|
1095
|
+
if (property.requiredWhen && conditionMatches(value, property.requiredWhen)) required.add(propertyName);
|
|
1096
|
+
if (!isMissing(value[propertyName]) && property.allowedWhen && !conditionMatches(value, property.allowedWhen)) {
|
|
1097
|
+
diagnostics.push(error(
|
|
1098
|
+
"invalid-field",
|
|
1099
|
+
path,
|
|
1100
|
+
`${name}.${propertyName} is not allowed for the selected ${Object.keys(property.allowedWhen).join(" and ")}.`
|
|
1101
|
+
));
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
for (const propertyName of required) {
|
|
1105
|
+
if (isMissing(value[propertyName])) {
|
|
1106
|
+
diagnostics.push(error("missing-field", path, `Required field "${name}.${propertyName}" is missing.`));
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
for (const [propertyName, propertyValue] of Object.entries(value)) {
|
|
1110
|
+
const property = properties[propertyName];
|
|
1111
|
+
if (property) {
|
|
1112
|
+
validateValue(`${name}.${propertyName}`, propertyValue, property, model, path, diagnostics);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (schema.additionalProperties === true) continue;
|
|
1116
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
1117
|
+
validateValue(`${name}.${propertyName}`, propertyValue, schema.additionalProperties, model, path, diagnostics);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
diagnostics.push(error(
|
|
1121
|
+
"unknown-field",
|
|
1122
|
+
path,
|
|
1123
|
+
`Field "${name}.${propertyName}" is not defined by object type "${objectType}".`
|
|
1124
|
+
));
|
|
1125
|
+
}
|
|
1126
|
+
for (const propertyName of Object.keys(value)) {
|
|
1127
|
+
if (schema.keyFormat === "namespace" && !NAMESPACE_PATTERN.test(propertyName)) {
|
|
1128
|
+
diagnostics.push(error(
|
|
1129
|
+
"invalid-field",
|
|
1130
|
+
path,
|
|
1131
|
+
`${name}.${propertyName}: extension namespaces must use lowercase dot-separated names.`
|
|
1132
|
+
));
|
|
1133
|
+
}
|
|
1134
|
+
if (schema.keyFormat === "data-path" && !isCanonicalDataPath(propertyName)) {
|
|
1135
|
+
diagnostics.push(error("invalid-field", path, `${name}.${propertyName}: must be a canonical data-relative path.`));
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
validateObjectDateRanges(name, value, path, diagnostics);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function validateObjectDateRanges(name, value, path, diagnostics) {
|
|
1142
|
+
for (const [startField, endField] of [
|
|
1143
|
+
["startsOn", "dueOn"],
|
|
1144
|
+
["dueOn", "overdueOn"],
|
|
1145
|
+
["startsAt", "dueAt"],
|
|
1146
|
+
["dueAt", "overdueAt"],
|
|
1147
|
+
["startsOn", "endsOn"]
|
|
1148
|
+
]) {
|
|
1149
|
+
const start = value[startField];
|
|
1150
|
+
const end = value[endField];
|
|
1151
|
+
if (!start || !end) continue;
|
|
1152
|
+
const invalid = startField.endsWith("At")
|
|
1153
|
+
? new Date(end) < new Date(start)
|
|
1154
|
+
: parseCalendarDate(start) && parseCalendarDate(end) && end < start;
|
|
1155
|
+
if (invalid) {
|
|
1156
|
+
diagnostics.push(error(
|
|
1157
|
+
"invalid-date-range",
|
|
1158
|
+
path,
|
|
1159
|
+
`${name}.${endField} cannot be before ${name}.${startField}.`
|
|
1160
|
+
));
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
502
1165
|
function isDate(value) {
|
|
503
1166
|
return DATE_PATTERN.test(value) && Boolean(parseCalendarDate(value));
|
|
504
1167
|
}
|