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
package/src/validate.js
CHANGED
|
@@ -1,24 +1,42 @@
|
|
|
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";
|
|
5
|
+
import { isSafeGitName } from "./git-name.js";
|
|
3
6
|
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
|
|
4
7
|
import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
|
|
5
8
|
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
6
9
|
import { partyPeople } from "./parties.js";
|
|
7
10
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
8
11
|
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
12
|
+
import { recordTiming } from "./timing.js";
|
|
9
13
|
import { indexResources, loadWorkspace } from "./workspace.js";
|
|
10
14
|
|
|
11
15
|
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
16
|
+
const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
12
17
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
13
18
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
14
19
|
const MAX_OBLIGATION_OFFSET_DAYS = 36_600;
|
|
15
20
|
const MAX_OBLIGATION_OFFSET_HOURS = MAX_OBLIGATION_OFFSET_DAYS * 24;
|
|
16
21
|
|
|
17
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) {
|
|
18
32
|
const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
|
|
19
33
|
const diagnostics = [...loaded.diagnostics];
|
|
20
34
|
const { byId } = indexResources(loaded.resources);
|
|
21
35
|
const seen = new Map();
|
|
36
|
+
const pathById = new Map(loaded.entries.map((entry) => [
|
|
37
|
+
entry.record?.id,
|
|
38
|
+
`data/${entry.relativePath}`
|
|
39
|
+
]));
|
|
22
40
|
const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
|
|
23
41
|
const obligationsByControl = new Map();
|
|
24
42
|
for (const obligation of loaded.resources.filter((record) => record.type === "obligation" && record.status !== "retired")) {
|
|
@@ -54,8 +72,14 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
54
72
|
validateLocation(record, definition, entry.relativePath, diagnostics);
|
|
55
73
|
validateRecord(record, definition, loaded.model, displayPath, diagnostics);
|
|
56
74
|
validateDateRanges(record, displayPath, diagnostics);
|
|
57
|
-
if (record.type === "
|
|
75
|
+
if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
|
|
76
|
+
if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
|
|
77
|
+
if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
|
|
58
78
|
if (record.type === "evidence") validateEvidencePaths(record, displayPath, diagnostics);
|
|
79
|
+
validateCoverage(record, displayPath, diagnostics);
|
|
80
|
+
validateClassification(record, loaded.workspace, displayPath, diagnostics);
|
|
81
|
+
validateCompletionDates(record, displayPath, diagnostics);
|
|
82
|
+
await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
|
|
59
83
|
|
|
60
84
|
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
61
85
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
@@ -95,12 +119,21 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
95
119
|
}
|
|
96
120
|
}
|
|
97
121
|
}
|
|
122
|
+
validateNestedRelations(fieldName, value, field, loaded.model, byId, displayPath, diagnostics);
|
|
98
123
|
}
|
|
99
124
|
validateIndependentApproval(record, byId, displayPath, diagnostics);
|
|
100
|
-
validateCompletedObligationEvent(record, byId, displayPath, diagnostics);
|
|
125
|
+
validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
|
|
101
126
|
validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, displayPath, diagnostics);
|
|
102
127
|
await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
|
|
128
|
+
await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
|
|
103
129
|
}
|
|
130
|
+
validateRelationshipConstraints(
|
|
131
|
+
loaded.resources,
|
|
132
|
+
loaded.model,
|
|
133
|
+
byId,
|
|
134
|
+
pathById,
|
|
135
|
+
diagnostics
|
|
136
|
+
);
|
|
104
137
|
|
|
105
138
|
diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
|
|
106
139
|
return {
|
|
@@ -115,10 +148,53 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
115
148
|
};
|
|
116
149
|
}
|
|
117
150
|
|
|
151
|
+
export async function fingerprintWorkspace(input = process.cwd()) {
|
|
152
|
+
const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
|
|
153
|
+
const hash = createHash("sha256");
|
|
154
|
+
hash.update(`model\0${loaded.model.modelVersion}\0`);
|
|
155
|
+
for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
|
|
156
|
+
hash.update(`record\0${entry.relativePath}\0${entry.source.length}\0${entry.source}`);
|
|
157
|
+
const definition = loaded.model.resources[entry.record?.type];
|
|
158
|
+
if (!definition) continue;
|
|
159
|
+
for (const item of markdownEntries(loaded.model, entry.record).sort((a, b) => a.path.localeCompare(b.path))) {
|
|
160
|
+
try {
|
|
161
|
+
const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
162
|
+
hash.update(`markdown\0${item.path}\0${source.length}\0${source}`);
|
|
163
|
+
} catch {
|
|
164
|
+
hash.update(`markdown-missing\0${item.path}\0`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
168
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
169
|
+
if (field.format !== "data-path" && field.items !== "data-path") continue;
|
|
170
|
+
const value = entry.record[name];
|
|
171
|
+
const paths = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
|
|
172
|
+
for (const path of [...paths].sort()) {
|
|
173
|
+
try {
|
|
174
|
+
const file = await stat(resolveDataPath(loaded.root, path));
|
|
175
|
+
hash.update(`data-path\0${path}\0${file.isFile() ? "file" : "other"}\0`);
|
|
176
|
+
} catch {
|
|
177
|
+
hash.update(`data-path-missing\0${path}\0`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { fingerprint: hash.digest("hex"), loaded };
|
|
183
|
+
}
|
|
184
|
+
|
|
118
185
|
function validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, path, diagnostics) {
|
|
119
186
|
if (record.type !== "control" || record.status !== "implemented") return;
|
|
120
187
|
const schedules = obligationsByControl.get(record.id) || [];
|
|
121
|
-
if (!schedules.length
|
|
188
|
+
if (!schedules.length) {
|
|
189
|
+
if (record.operationPattern === "continuous") return;
|
|
190
|
+
diagnostics.push(error(
|
|
191
|
+
"control-work-queue-missing",
|
|
192
|
+
path,
|
|
193
|
+
"An implemented scheduled, event-driven, or mixed Control must link at least one active Obligation."
|
|
194
|
+
));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (schedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))) return;
|
|
122
198
|
const stopped = schedules.filter((obligation) => !obligationIsRunning(obligation, byId, asOf));
|
|
123
199
|
const paused = stopped.filter((obligation) => obligation.status === "paused");
|
|
124
200
|
const waiting = stopped.filter((obligation) => obligation.status === "active");
|
|
@@ -148,9 +224,7 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
|
|
|
148
224
|
function validateDateRanges(record, path, diagnostics) {
|
|
149
225
|
for (const [startField, endField] of [
|
|
150
226
|
["startDate", "endDate"],
|
|
151
|
-
["
|
|
152
|
-
["candidatePeriodStart", "candidatePeriodEnd"],
|
|
153
|
-
["dueWindowStart", "dueWindowEnd"]
|
|
227
|
+
["startsOn", "endsOn"]
|
|
154
228
|
]) {
|
|
155
229
|
const start = record[startField];
|
|
156
230
|
const end = record[endField];
|
|
@@ -164,7 +238,25 @@ function validateDateRanges(record, path, diagnostics) {
|
|
|
164
238
|
}
|
|
165
239
|
}
|
|
166
240
|
|
|
167
|
-
function
|
|
241
|
+
function validateAppointment(record, byId, path, diagnostics) {
|
|
242
|
+
if ((record.scopeResourceIds || []).includes(record.id)) {
|
|
243
|
+
diagnostics.push(error(
|
|
244
|
+
"invalid-appointment-scope",
|
|
245
|
+
path,
|
|
246
|
+
"An Appointment cannot include itself in scopeResourceIds."
|
|
247
|
+
));
|
|
248
|
+
}
|
|
249
|
+
if (record.status !== "active") return;
|
|
250
|
+
const holder = byId.get(record.holderId);
|
|
251
|
+
if (holder?.type === "person" && holder.status === "active") return;
|
|
252
|
+
diagnostics.push(error(
|
|
253
|
+
"inactive-appointment-holder",
|
|
254
|
+
path,
|
|
255
|
+
"An active Appointment must have an active Person as its holder."
|
|
256
|
+
));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateCompletedObligationEvent(record, byId, model, path, diagnostics) {
|
|
168
260
|
if (record.type !== "obligation-event" || record.status !== "complete") return;
|
|
169
261
|
if (record.completedOn && record.occurredOn && record.completedOn < record.occurredOn) {
|
|
170
262
|
diagnostics.push(error(
|
|
@@ -192,7 +284,9 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
|
|
|
192
284
|
continue;
|
|
193
285
|
}
|
|
194
286
|
const obligation = byId.get(action.obligationId);
|
|
195
|
-
const expectedTypes = obligation?.type === "obligation"
|
|
287
|
+
const expectedTypes = obligation?.type === "obligation"
|
|
288
|
+
? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
|
|
289
|
+
: [];
|
|
196
290
|
if (!expectedTypes.length) continue;
|
|
197
291
|
const linked = [...new Set([...(action.completionResourceIds || []), ...(action.evidenceIds || [])])]
|
|
198
292
|
.map((id) => byId.get(id))
|
|
@@ -238,9 +332,38 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
|
|
|
238
332
|
}
|
|
239
333
|
}
|
|
240
334
|
|
|
241
|
-
function validateObligation(record, path, diagnostics) {
|
|
335
|
+
function validateObligation(record, model, byId, path, diagnostics) {
|
|
242
336
|
const recurrence = record.recurrence;
|
|
243
337
|
if (!recurrence || Array.isArray(recurrence) || typeof recurrence !== "object") return;
|
|
338
|
+
const activity = model.obligationActivities?.[record.activityType];
|
|
339
|
+
if (
|
|
340
|
+
activity
|
|
341
|
+
&& Array.isArray(activity.recurrenceModes)
|
|
342
|
+
&& !activity.recurrenceModes.includes(recurrence.mode)
|
|
343
|
+
) {
|
|
344
|
+
diagnostics.push(error(
|
|
345
|
+
"invalid-obligation-activity",
|
|
346
|
+
path,
|
|
347
|
+
`${record.activityType} obligations require ${activity.recurrenceModes.join(" or ")} recurrence.`
|
|
348
|
+
));
|
|
349
|
+
}
|
|
350
|
+
if (activity) {
|
|
351
|
+
const allowedScopeTypes = new Set(activity.scopeResourceTypes || []);
|
|
352
|
+
for (const [field, ids] of [
|
|
353
|
+
["scopeResourceIds", record.scopeResourceIds || []],
|
|
354
|
+
["templateResourceId", record.templateResourceId ? [record.templateResourceId] : []]
|
|
355
|
+
]) {
|
|
356
|
+
for (const id of ids) {
|
|
357
|
+
const target = byId.get(id);
|
|
358
|
+
if (!target || allowedScopeTypes.has(target.type)) continue;
|
|
359
|
+
diagnostics.push(error(
|
|
360
|
+
"invalid-obligation-scope",
|
|
361
|
+
path,
|
|
362
|
+
`${field} references ${target.type} "${id}", but ${record.activityType} allows ${[...allowedScopeTypes].join(" or ")} scope.`
|
|
363
|
+
));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
244
367
|
if (recurrence.mode === "calendar") {
|
|
245
368
|
const normalized = { ...recurrence, anchorDate: recurrence.anchorDate || record.startsOn };
|
|
246
369
|
if (!validCalendarRecurrence(normalized)) {
|
|
@@ -251,11 +374,11 @@ function validateObligation(record, path, diagnostics) {
|
|
|
251
374
|
));
|
|
252
375
|
}
|
|
253
376
|
} else if (recurrence.mode === "event") {
|
|
254
|
-
if (
|
|
377
|
+
if (!model.policyEvents?.[recurrence.eventType]) {
|
|
255
378
|
diagnostics.push(error(
|
|
256
379
|
"invalid-obligation-recurrence",
|
|
257
380
|
path,
|
|
258
|
-
"Event recurrence
|
|
381
|
+
"Event recurrence must use a policy event defined by the model."
|
|
259
382
|
));
|
|
260
383
|
}
|
|
261
384
|
} else {
|
|
@@ -267,53 +390,287 @@ function validateObligation(record, path, diagnostics) {
|
|
|
267
390
|
}
|
|
268
391
|
|
|
269
392
|
const window = record.window;
|
|
270
|
-
if (!window || Array.isArray(window) || typeof window !== "object")
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
393
|
+
if (!window || Array.isArray(window) || typeof window !== "object") {
|
|
394
|
+
if (recurrence.mode === "event") {
|
|
395
|
+
diagnostics.push(error("invalid-obligation-window", path, "Event obligations require an explicit deadline window."));
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
for (const name of ["startsAfter", "dueAfter"]) {
|
|
400
|
+
if (window[name] === undefined) continue;
|
|
274
401
|
if (!Number.isInteger(window[name])) {
|
|
275
402
|
diagnostics.push(error("invalid-obligation-window", path, `window.${name} must be an integer.`));
|
|
276
403
|
}
|
|
277
404
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
405
|
+
const limit = window.precision === "timestamp" ? MAX_OBLIGATION_OFFSET_HOURS : MAX_OBLIGATION_OFFSET_DAYS;
|
|
406
|
+
const unit = window.precision === "timestamp" ? "hours" : "days";
|
|
407
|
+
for (const name of ["startsAfter", "dueAfter"]) {
|
|
408
|
+
if (Number.isInteger(window[name]) && Math.abs(window[name]) > limit) {
|
|
409
|
+
diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${limit.toLocaleString("en-US")} ${unit} of the policy event.`));
|
|
281
410
|
}
|
|
282
411
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
412
|
+
if (recurrence.mode === "calendar" && window.precision !== "date") {
|
|
413
|
+
diagnostics.push(error("invalid-obligation-window", path, "Calendar obligations require a date-precision window."));
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
Number.isInteger(window.dueAfter)
|
|
417
|
+
&& window.dueAfter < (Number.isInteger(window.startsAfter) ? window.startsAfter : 0)
|
|
418
|
+
) {
|
|
419
|
+
diagnostics.push(error("invalid-obligation-window", path, "window.dueAfter must be on or after window.startsAfter."));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function validatePolicyEvent(record, model, byId, path, diagnostics) {
|
|
424
|
+
const event = model.policyEvents?.[record.eventType];
|
|
425
|
+
if (!event) return;
|
|
426
|
+
const rules = event.subjectRules || [];
|
|
427
|
+
const allowedTypes = new Set(rules.map(({ resourceType }) => resourceType));
|
|
428
|
+
const counts = new Map();
|
|
429
|
+
for (const id of new Set(record.subjectResourceIds || [])) {
|
|
430
|
+
const target = byId.get(id);
|
|
431
|
+
if (!target) continue;
|
|
432
|
+
counts.set(target.type, (counts.get(target.type) || 0) + 1);
|
|
433
|
+
if (!allowedTypes.has(target.type)) {
|
|
434
|
+
diagnostics.push(error(
|
|
435
|
+
"invalid-policy-event-subject",
|
|
436
|
+
path,
|
|
437
|
+
`${record.eventType} cannot use ${target.type} "${id}" as a subject.`
|
|
438
|
+
));
|
|
286
439
|
}
|
|
287
440
|
}
|
|
288
|
-
|
|
289
|
-
|
|
441
|
+
for (const { resourceType, minimum = 0, maximum } of rules) {
|
|
442
|
+
const count = counts.get(resourceType) || 0;
|
|
443
|
+
if (count < minimum) {
|
|
444
|
+
diagnostics.push(error(
|
|
445
|
+
"invalid-policy-event-subject",
|
|
446
|
+
path,
|
|
447
|
+
`${record.eventType} requires at least ${minimum} ${resourceType} subject${minimum === 1 ? "" : "s"}.`
|
|
448
|
+
));
|
|
449
|
+
}
|
|
450
|
+
if (Number.isInteger(maximum) && count > maximum) {
|
|
451
|
+
diagnostics.push(error(
|
|
452
|
+
"invalid-policy-event-subject",
|
|
453
|
+
path,
|
|
454
|
+
`${record.eventType} allows at most ${maximum} ${resourceType} subject${maximum === 1 ? "" : "s"}.`
|
|
455
|
+
));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function validateCompletionDates(record, path, diagnostics) {
|
|
461
|
+
if (record.startedAt && record.completedAt && record.completedAt < record.startedAt) {
|
|
462
|
+
diagnostics.push(error("invalid-completion-order", path, "completedAt cannot be before startedAt."));
|
|
463
|
+
}
|
|
464
|
+
if (record.completedOn && record.reviewedOn && record.reviewedOn < record.completedOn) {
|
|
465
|
+
diagnostics.push(error("invalid-completion-order", path, "reviewedOn cannot be before completedOn."));
|
|
466
|
+
}
|
|
467
|
+
if (record.completedOn && record.approvedOn && record.approvedOn < record.completedOn) {
|
|
468
|
+
diagnostics.push(error("invalid-completion-order", path, "approvedOn cannot be before completedOn."));
|
|
290
469
|
}
|
|
291
|
-
|
|
292
|
-
|
|
470
|
+
validateOrderedDates(record, path, diagnostics, [
|
|
471
|
+
"requestedOn",
|
|
472
|
+
"approvedOn",
|
|
473
|
+
"provisionedOn",
|
|
474
|
+
"deprovisionedOn"
|
|
475
|
+
]);
|
|
476
|
+
validateOrderedDates(record, path, diagnostics, [
|
|
477
|
+
"detectedAt",
|
|
478
|
+
"declaredAt",
|
|
479
|
+
"containedAt",
|
|
480
|
+
"eradicatedAt",
|
|
481
|
+
"recoveredAt",
|
|
482
|
+
"closedAt"
|
|
483
|
+
]);
|
|
484
|
+
validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
|
|
485
|
+
validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
|
|
486
|
+
if (record.acceptance) {
|
|
487
|
+
validateOrderedDates(record.acceptance, path, diagnostics, ["acceptedOn", "expiresOn"], "acceptance.");
|
|
293
488
|
}
|
|
489
|
+
if (record.approval) {
|
|
490
|
+
validateOrderedDates(record.approval, path, diagnostics, ["approvedOn", "expiresOn"], "approval.");
|
|
491
|
+
if (
|
|
492
|
+
record.resolution?.resolvedOn
|
|
493
|
+
&& record.approval.approvedOn
|
|
494
|
+
&& record.resolution.resolvedOn < record.approval.approvedOn
|
|
495
|
+
) {
|
|
496
|
+
diagnostics.push(error(
|
|
497
|
+
"invalid-completion-order",
|
|
498
|
+
path,
|
|
499
|
+
"resolution.resolvedOn cannot be before approval.approvedOn."
|
|
500
|
+
));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function validateOrderedDates(record, path, diagnostics, fields, prefix = "") {
|
|
506
|
+
let previous = null;
|
|
507
|
+
for (const field of fields) {
|
|
508
|
+
const value = record[field];
|
|
509
|
+
if (!value) continue;
|
|
510
|
+
if (previous && value < previous.value) {
|
|
511
|
+
diagnostics.push(error(
|
|
512
|
+
"invalid-completion-order",
|
|
513
|
+
path,
|
|
514
|
+
`${prefix}${field} cannot be before ${prefix}${previous.field}.`
|
|
515
|
+
));
|
|
516
|
+
}
|
|
517
|
+
previous = { field, value };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function validateAttestationBinding(record, model, root, byId, path, diagnostics) {
|
|
294
522
|
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
523
|
+
record.type !== "attestation"
|
|
524
|
+
|| record.status !== "completed"
|
|
525
|
+
|| record.attestationMethod !== "git-approval"
|
|
526
|
+
|| !record.contentRevisions
|
|
527
|
+
) return;
|
|
528
|
+
const expectedPaths = new Set();
|
|
529
|
+
const subjectPaths = new Map();
|
|
530
|
+
for (const id of record.subjectResourceIds || []) {
|
|
531
|
+
const subject = byId.get(id);
|
|
532
|
+
if (!subject) continue;
|
|
533
|
+
const paths = [];
|
|
534
|
+
for (const item of markdownEntries(model, subject)) {
|
|
535
|
+
try {
|
|
536
|
+
if ((await stat(resolveDataPath(root, item.path))).isFile()) paths.push(item.path);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
if (error.code !== "ENOENT") throw error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
subjectPaths.set(id, paths);
|
|
542
|
+
for (const item of paths) expectedPaths.add(item);
|
|
543
|
+
}
|
|
544
|
+
const actualPaths = Object.keys(record.contentRevisions);
|
|
545
|
+
if (!expectedPaths.size) {
|
|
546
|
+
diagnostics.push(error(
|
|
547
|
+
"invalid-attestation-binding",
|
|
548
|
+
path,
|
|
549
|
+
"A git-approval Attestation must reference authored Policy, Document, or Training content."
|
|
550
|
+
));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (!actualPaths.length) {
|
|
554
|
+
diagnostics.push(error(
|
|
555
|
+
"invalid-attestation-binding",
|
|
556
|
+
path,
|
|
557
|
+
"A git-approval Attestation must bind at least one subject Markdown file."
|
|
558
|
+
));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const invalid = actualPaths.filter((item) => (
|
|
562
|
+
!expectedPaths.has(item)
|
|
563
|
+
|| !/^[a-f0-9]{64}$/.test(String(record.contentRevisions[item] || ""))
|
|
564
|
+
));
|
|
565
|
+
const unboundSubjects = [...subjectPaths].filter(([, paths]) => (
|
|
566
|
+
paths.length && !paths.some((item) => actualPaths.includes(item))
|
|
567
|
+
)).map(([id]) => id);
|
|
568
|
+
if (invalid.length || unboundSubjects.length) {
|
|
569
|
+
diagnostics.push(error(
|
|
570
|
+
"invalid-attestation-binding",
|
|
571
|
+
path,
|
|
572
|
+
unboundSubjects.length
|
|
573
|
+
? `Attestation contentRevisions must bind authored Markdown for every subject; missing ${unboundSubjects.join(", ")}.`
|
|
574
|
+
: "Attestation contentRevisions must contain valid SHA-256 hashes for subject Markdown paths and no unrelated paths."
|
|
575
|
+
));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
580
|
+
if (!approvalBound(record) || !record.approvedContentRevisions) return;
|
|
581
|
+
const actual = {};
|
|
582
|
+
for (const item of markdownEntries(model, record)) {
|
|
583
|
+
try {
|
|
584
|
+
const source = await readFile(resolveDataPath(root, item.path), "utf8");
|
|
585
|
+
actual[item.path] = createHash("sha256").update(source).digest("hex");
|
|
586
|
+
} catch (error) {
|
|
587
|
+
if (error.code !== "ENOENT") throw error;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const expected = record.approvedContentRevisions;
|
|
591
|
+
const paths = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort();
|
|
592
|
+
const invalid = paths.filter((item) => (
|
|
593
|
+
!/^[a-f0-9]{64}$/.test(String(expected[item] || ""))
|
|
594
|
+
|| expected[item] !== actual[item]
|
|
595
|
+
));
|
|
596
|
+
if (invalid.length) {
|
|
300
597
|
diagnostics.push(error(
|
|
301
|
-
"
|
|
598
|
+
"approval-content-changed",
|
|
302
599
|
path,
|
|
303
|
-
|
|
600
|
+
`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.`
|
|
304
601
|
));
|
|
305
602
|
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function approvalBound(record) {
|
|
606
|
+
if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
|
|
607
|
+
if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function validateCoverage(record, path, diagnostics) {
|
|
612
|
+
const coverage = record.type === "workspace" ? record.candidateCoverage : record.coverage;
|
|
613
|
+
if (!coverage) return;
|
|
614
|
+
if (coverage.kind === "range" && coverage.startsOn && coverage.endsOn && coverage.endsOn < coverage.startsOn) {
|
|
615
|
+
diagnostics.push(error("invalid-date-range", path, "coverage.endsOn cannot be before coverage.startsOn."));
|
|
616
|
+
}
|
|
617
|
+
if (
|
|
618
|
+
record.type === "audit"
|
|
619
|
+
&& record.auditKind === "soc-2-type-1"
|
|
620
|
+
&& coverage.kind !== "as-of"
|
|
621
|
+
) {
|
|
622
|
+
diagnostics.push(error("invalid-audit-coverage", path, "A SOC 2 Type 1 Audit requires as-of coverage."));
|
|
623
|
+
}
|
|
624
|
+
if (
|
|
625
|
+
record.type === "audit"
|
|
626
|
+
&& record.auditKind === "soc-2-type-2"
|
|
627
|
+
&& coverage.kind !== "range"
|
|
628
|
+
) {
|
|
629
|
+
diagnostics.push(error("invalid-audit-coverage", path, "A SOC 2 Type 2 Audit requires range coverage."));
|
|
630
|
+
}
|
|
631
|
+
if (
|
|
632
|
+
record.type === "workspace"
|
|
633
|
+
&& record.assuranceGoal === "soc-2-type-1"
|
|
634
|
+
&& coverage.kind !== "as-of"
|
|
635
|
+
) {
|
|
636
|
+
diagnostics.push(error("invalid-candidate-coverage", path, "A Type 1 management goal requires as-of candidate coverage."));
|
|
637
|
+
}
|
|
638
|
+
if (
|
|
639
|
+
record.type === "workspace"
|
|
640
|
+
&& record.assuranceGoal === "soc-2-type-2"
|
|
641
|
+
&& coverage.kind !== "range"
|
|
642
|
+
) {
|
|
643
|
+
diagnostics.push(error("invalid-candidate-coverage", path, "A Type 2 management goal requires range candidate coverage."));
|
|
644
|
+
}
|
|
645
|
+
if (
|
|
646
|
+
["audit-population", "penetration-test"].includes(record.type)
|
|
647
|
+
&& coverage.kind !== "range"
|
|
648
|
+
) {
|
|
649
|
+
diagnostics.push(error("invalid-coverage", path, `${record.type} requires range coverage.`));
|
|
650
|
+
}
|
|
306
651
|
if (
|
|
307
|
-
|
|
308
|
-
&&
|
|
652
|
+
record.type === "evidence"
|
|
653
|
+
&& record.artifactKind === "population-export"
|
|
654
|
+
&& coverage.kind !== "range"
|
|
309
655
|
) {
|
|
310
|
-
diagnostics.push(error("invalid-
|
|
656
|
+
diagnostics.push(error("invalid-coverage", path, "Population Export Evidence requires range coverage."));
|
|
311
657
|
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function validateClassification(record, workspace, path, diagnostics) {
|
|
661
|
+
if (!record.classificationId) return;
|
|
662
|
+
const definitions = workspace?.classificationDefinitions;
|
|
312
663
|
if (
|
|
313
|
-
|
|
314
|
-
|
|
664
|
+
!definitions
|
|
665
|
+
|| Array.isArray(definitions)
|
|
666
|
+
|| typeof definitions !== "object"
|
|
667
|
+
|| !Object.hasOwn(definitions, record.classificationId)
|
|
315
668
|
) {
|
|
316
|
-
diagnostics.push(error(
|
|
669
|
+
diagnostics.push(error(
|
|
670
|
+
"unknown-classification",
|
|
671
|
+
path,
|
|
672
|
+
`classificationId references undefined Workspace classification "${record.classificationId}".`
|
|
673
|
+
));
|
|
317
674
|
}
|
|
318
675
|
}
|
|
319
676
|
|
|
@@ -339,6 +696,13 @@ function validateRecord(record, definition, model, path, diagnostics) {
|
|
|
339
696
|
]);
|
|
340
697
|
for (const [name, field] of Object.entries(fields)) {
|
|
341
698
|
if (field.requiredWhen && conditionMatches(record, field.requiredWhen)) required.add(name);
|
|
699
|
+
if (!isMissing(record[name]) && field.allowedWhen && !conditionMatches(record, field.allowedWhen)) {
|
|
700
|
+
diagnostics.push(error(
|
|
701
|
+
"invalid-field",
|
|
702
|
+
path,
|
|
703
|
+
`${name} is not allowed for the selected ${Object.keys(field.allowedWhen).join(" and ")}.`
|
|
704
|
+
));
|
|
705
|
+
}
|
|
342
706
|
if (field.disjointFrom) {
|
|
343
707
|
const values = normalizedValues(record[name]);
|
|
344
708
|
const otherValues = new Set(normalizedValues(record[field.disjointFrom]));
|
|
@@ -364,7 +728,11 @@ function validateRecord(record, definition, model, path, diagnostics) {
|
|
|
364
728
|
for (const [name, value] of Object.entries(record)) {
|
|
365
729
|
const field = fields[name];
|
|
366
730
|
if (!field) {
|
|
367
|
-
diagnostics.push(
|
|
731
|
+
diagnostics.push(error(
|
|
732
|
+
"unknown-field",
|
|
733
|
+
path,
|
|
734
|
+
`Field "${name}" is not defined by model v${model.modelVersion}. Put organization-specific data under extensions.`
|
|
735
|
+
));
|
|
368
736
|
continue;
|
|
369
737
|
}
|
|
370
738
|
validateValue(name, value, field, model, path, diagnostics);
|
|
@@ -377,6 +745,109 @@ function normalizedValues(value) {
|
|
|
377
745
|
return typeof value === "string" ? [value] : [];
|
|
378
746
|
}
|
|
379
747
|
|
|
748
|
+
function validateNestedRelations(name, value, field, model, byId, path, diagnostics) {
|
|
749
|
+
if (
|
|
750
|
+
field.type === "object"
|
|
751
|
+
&& field.objectType
|
|
752
|
+
&& value
|
|
753
|
+
&& !Array.isArray(value)
|
|
754
|
+
&& typeof value === "object"
|
|
755
|
+
) {
|
|
756
|
+
validateObjectRelations(name, value, model.objectTypes?.[field.objectType], model, byId, path, diagnostics);
|
|
757
|
+
}
|
|
758
|
+
if (field.type === "array" && field.itemObjectType && Array.isArray(value)) {
|
|
759
|
+
for (const [index, item] of value.entries()) {
|
|
760
|
+
if (!item || Array.isArray(item) || typeof item !== "object") continue;
|
|
761
|
+
validateObjectRelations(
|
|
762
|
+
`${name}[${index}]`,
|
|
763
|
+
item,
|
|
764
|
+
model.objectTypes?.[field.itemObjectType],
|
|
765
|
+
model,
|
|
766
|
+
byId,
|
|
767
|
+
path,
|
|
768
|
+
diagnostics
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function validateObjectRelations(name, value, schema, model, byId, path, diagnostics) {
|
|
775
|
+
if (!schema) return;
|
|
776
|
+
for (const [propertyName, property] of Object.entries(schema.properties || {})) {
|
|
777
|
+
const nested = value[propertyName];
|
|
778
|
+
if (nested === undefined || nested === null) continue;
|
|
779
|
+
if (property.relation) {
|
|
780
|
+
const ids = Array.isArray(nested) ? nested : [nested];
|
|
781
|
+
for (const id of ids) {
|
|
782
|
+
const target = byId.get(id);
|
|
783
|
+
if (!target) {
|
|
784
|
+
diagnostics.push(error(
|
|
785
|
+
"missing-reference",
|
|
786
|
+
path,
|
|
787
|
+
`${name}.${propertyName} references unknown ID "${id}".`
|
|
788
|
+
));
|
|
789
|
+
} else if (!property.relation.includes("*") && !property.relation.includes(target.type)) {
|
|
790
|
+
diagnostics.push(error(
|
|
791
|
+
"wrong-reference-type",
|
|
792
|
+
path,
|
|
793
|
+
`${name}.${propertyName} references ${target.type} "${id}", expected ${property.relation.join(" or ")}.`
|
|
794
|
+
));
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
validateNestedRelations(`${name}.${propertyName}`, nested, property, model, byId, path, diagnostics);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function validateRelationshipConstraints(resources, model, byId, pathById, diagnostics) {
|
|
803
|
+
for (const constraint of model.relationshipConstraints?.acyclic || []) {
|
|
804
|
+
const candidates = resources.filter(({ type }) => type === constraint.resourceType);
|
|
805
|
+
const visited = new Set();
|
|
806
|
+
for (const record of candidates) {
|
|
807
|
+
if (visited.has(record.id)) continue;
|
|
808
|
+
const chain = [];
|
|
809
|
+
const positions = new Map();
|
|
810
|
+
let current = record;
|
|
811
|
+
while (current?.type === constraint.resourceType && !visited.has(current.id)) {
|
|
812
|
+
if (positions.has(current.id)) {
|
|
813
|
+
const cycle = [...chain.slice(positions.get(current.id)), current.id];
|
|
814
|
+
const cycleRecord = chain[positions.get(current.id)];
|
|
815
|
+
diagnostics.push(error(
|
|
816
|
+
"cyclic-relationship",
|
|
817
|
+
pathById.get(cycleRecord) || `data/${cycleRecord}`,
|
|
818
|
+
`${constraint.field} forms a cycle: ${cycle.join(" -> ")}.`
|
|
819
|
+
));
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
positions.set(current.id, chain.length);
|
|
823
|
+
chain.push(current.id);
|
|
824
|
+
current = byId.get(current[constraint.field]);
|
|
825
|
+
}
|
|
826
|
+
for (const id of chain) visited.add(id);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
for (const constraint of model.relationshipConstraints?.unique || []) {
|
|
831
|
+
const keys = new Map();
|
|
832
|
+
for (const record of resources) {
|
|
833
|
+
if (record.type !== constraint.resourceType) continue;
|
|
834
|
+
if (constraint.statuses && !constraint.statuses.includes(record.status)) continue;
|
|
835
|
+
const key = JSON.stringify((constraint.fields || []).map((field) => {
|
|
836
|
+
const value = record[field];
|
|
837
|
+
return Array.isArray(value) ? [...value].sort() : value ?? null;
|
|
838
|
+
}));
|
|
839
|
+
const previous = keys.get(key);
|
|
840
|
+
if (previous) {
|
|
841
|
+
diagnostics.push(error(
|
|
842
|
+
"duplicate-active-relationship",
|
|
843
|
+
pathById.get(record.id) || `data/${record.id}`,
|
|
844
|
+
`${record.title} duplicates ${previous.title} for ${constraint.fields.join(", ")} while both are ${record.status}.`
|
|
845
|
+
));
|
|
846
|
+
} else keys.set(key, record);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
380
851
|
async function validateMarkdown(record, definition, model, root, path, diagnostics) {
|
|
381
852
|
const present = new Set();
|
|
382
853
|
for (const item of markdownEntries(model, record)) {
|
|
@@ -451,14 +922,28 @@ function validateValue(name, value, field, model, path, diagnostics) {
|
|
|
451
922
|
if (typeof value !== "boolean") fail("must be a boolean.");
|
|
452
923
|
return;
|
|
453
924
|
case "object":
|
|
454
|
-
if (!value || Array.isArray(value) || typeof value !== "object")
|
|
925
|
+
if (!value || Array.isArray(value) || typeof value !== "object") {
|
|
926
|
+
fail("must be an object.");
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (field.objectType) {
|
|
930
|
+
validateObjectValue(name, value, field.objectType, model, path, diagnostics);
|
|
931
|
+
}
|
|
455
932
|
return;
|
|
456
933
|
case "array":
|
|
457
934
|
if (!Array.isArray(value)) {
|
|
458
935
|
fail("must be an array.");
|
|
459
936
|
return;
|
|
460
937
|
}
|
|
461
|
-
|
|
938
|
+
if (
|
|
939
|
+
["id", "string", "data-path"].includes(field.items)
|
|
940
|
+
&& new Set(value).size !== value.length
|
|
941
|
+
) {
|
|
942
|
+
fail("must not contain duplicate values.");
|
|
943
|
+
}
|
|
944
|
+
for (const [index, item] of value.entries()) {
|
|
945
|
+
validateArrayItem(name, item, field, model, path, diagnostics, index);
|
|
946
|
+
}
|
|
462
947
|
return;
|
|
463
948
|
default:
|
|
464
949
|
fail(`uses unsupported model type "${field.type}".`);
|
|
@@ -476,6 +961,7 @@ function validateValue(name, value, field, model, path, diagnostics) {
|
|
|
476
961
|
}
|
|
477
962
|
if (field.format === "email" && !EMAIL_PATTERN.test(value)) fail("must be an email address.");
|
|
478
963
|
if (field.format === "timezone" && !isTimezone(value)) fail("must be an IANA time zone.");
|
|
964
|
+
if (field.format === "git-name" && !isSafeGitName(value)) fail("must be a safe Git name.");
|
|
479
965
|
}
|
|
480
966
|
|
|
481
967
|
function validateNumericRange(value, field, fail) {
|
|
@@ -487,9 +973,12 @@ function validateNumericRange(value, field, fail) {
|
|
|
487
973
|
}
|
|
488
974
|
}
|
|
489
975
|
|
|
490
|
-
function validateArrayItem(name, value,
|
|
976
|
+
function validateArrayItem(name, value, field, model, path, diagnostics, index) {
|
|
977
|
+
const type = field.items;
|
|
491
978
|
if (type === "object" && (!value || Array.isArray(value) || typeof value !== "object")) {
|
|
492
979
|
diagnostics.push(error("invalid-field", path, `${name} items must be objects.`));
|
|
980
|
+
} else if (type === "object" && field.itemObjectType) {
|
|
981
|
+
validateObjectValue(`${name}[${index}]`, value, field.itemObjectType, model, path, diagnostics);
|
|
493
982
|
} else if ((type === "string" || type === "data-path") && typeof value !== "string") {
|
|
494
983
|
diagnostics.push(error("invalid-field", path, `${name} items must be strings.`));
|
|
495
984
|
} else if (type === "id" && (typeof value !== "string" || !ID_PATTERN.test(value))) {
|
|
@@ -497,6 +986,85 @@ function validateArrayItem(name, value, type, path, diagnostics) {
|
|
|
497
986
|
}
|
|
498
987
|
}
|
|
499
988
|
|
|
989
|
+
function validateObjectValue(name, value, objectType, model, path, diagnostics) {
|
|
990
|
+
const schema = model.objectTypes?.[objectType];
|
|
991
|
+
if (!schema) {
|
|
992
|
+
diagnostics.push(error("invalid-field", path, `${name}: uses unknown object type "${objectType}".`));
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const properties = schema.properties || {};
|
|
996
|
+
const required = new Set(schema.required || []);
|
|
997
|
+
for (const [propertyName, property] of Object.entries(properties)) {
|
|
998
|
+
if (property.requiredWhen && conditionMatches(value, property.requiredWhen)) required.add(propertyName);
|
|
999
|
+
if (!isMissing(value[propertyName]) && property.allowedWhen && !conditionMatches(value, property.allowedWhen)) {
|
|
1000
|
+
diagnostics.push(error(
|
|
1001
|
+
"invalid-field",
|
|
1002
|
+
path,
|
|
1003
|
+
`${name}.${propertyName} is not allowed for the selected ${Object.keys(property.allowedWhen).join(" and ")}.`
|
|
1004
|
+
));
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
for (const propertyName of required) {
|
|
1008
|
+
if (isMissing(value[propertyName])) {
|
|
1009
|
+
diagnostics.push(error("missing-field", path, `Required field "${name}.${propertyName}" is missing.`));
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
for (const [propertyName, propertyValue] of Object.entries(value)) {
|
|
1013
|
+
const property = properties[propertyName];
|
|
1014
|
+
if (property) {
|
|
1015
|
+
validateValue(`${name}.${propertyName}`, propertyValue, property, model, path, diagnostics);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (schema.additionalProperties === true) continue;
|
|
1019
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
1020
|
+
validateValue(`${name}.${propertyName}`, propertyValue, schema.additionalProperties, model, path, diagnostics);
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
diagnostics.push(error(
|
|
1024
|
+
"unknown-field",
|
|
1025
|
+
path,
|
|
1026
|
+
`Field "${name}.${propertyName}" is not defined by object type "${objectType}".`
|
|
1027
|
+
));
|
|
1028
|
+
}
|
|
1029
|
+
for (const propertyName of Object.keys(value)) {
|
|
1030
|
+
if (schema.keyFormat === "namespace" && !NAMESPACE_PATTERN.test(propertyName)) {
|
|
1031
|
+
diagnostics.push(error(
|
|
1032
|
+
"invalid-field",
|
|
1033
|
+
path,
|
|
1034
|
+
`${name}.${propertyName}: extension namespaces must use lowercase dot-separated names.`
|
|
1035
|
+
));
|
|
1036
|
+
}
|
|
1037
|
+
if (schema.keyFormat === "data-path" && !isCanonicalDataPath(propertyName)) {
|
|
1038
|
+
diagnostics.push(error("invalid-field", path, `${name}.${propertyName}: must be a canonical data-relative path.`));
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
validateObjectDateRanges(name, value, path, diagnostics);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function validateObjectDateRanges(name, value, path, diagnostics) {
|
|
1045
|
+
for (const [startField, endField] of [
|
|
1046
|
+
["startsOn", "dueOn"],
|
|
1047
|
+
["dueOn", "overdueOn"],
|
|
1048
|
+
["startsAt", "dueAt"],
|
|
1049
|
+
["dueAt", "overdueAt"],
|
|
1050
|
+
["startsOn", "endsOn"]
|
|
1051
|
+
]) {
|
|
1052
|
+
const start = value[startField];
|
|
1053
|
+
const end = value[endField];
|
|
1054
|
+
if (!start || !end) continue;
|
|
1055
|
+
const invalid = startField.endsWith("At")
|
|
1056
|
+
? new Date(end) < new Date(start)
|
|
1057
|
+
: parseCalendarDate(start) && parseCalendarDate(end) && end < start;
|
|
1058
|
+
if (invalid) {
|
|
1059
|
+
diagnostics.push(error(
|
|
1060
|
+
"invalid-date-range",
|
|
1061
|
+
path,
|
|
1062
|
+
`${name}.${endField} cannot be before ${name}.${startField}.`
|
|
1063
|
+
));
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
500
1068
|
function isDate(value) {
|
|
501
1069
|
return DATE_PATTERN.test(value) && Boolean(parseCalendarDate(value));
|
|
502
1070
|
}
|