filegrc 0.3.4 → 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 +16 -6
- package/model/index.js +37 -3
- package/model/v1.json +81 -47
- 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 +168 -110
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +115 -75
- package/src/files.js +230 -28
- package/src/git.js +239 -41
- package/src/index.js +5 -5
- package/src/model-docs.js +88 -7
- package/src/model-migration.js +1463 -0
- package/src/mutation.js +42 -0
- 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 +110 -39
- package/src/setup.js +27 -28
- package/src/state.js +86 -25
- package/src/timing.js +41 -0
- package/src/validate.js +609 -43
- package/src/web.js +506 -129
- 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,14 @@ 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 === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
|
|
77
|
+
if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
|
|
59
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);
|
|
60
83
|
|
|
61
84
|
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
62
85
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
@@ -96,12 +119,21 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
96
119
|
}
|
|
97
120
|
}
|
|
98
121
|
}
|
|
122
|
+
validateNestedRelations(fieldName, value, field, loaded.model, byId, displayPath, diagnostics);
|
|
99
123
|
}
|
|
100
124
|
validateIndependentApproval(record, byId, displayPath, diagnostics);
|
|
101
|
-
validateCompletedObligationEvent(record, byId, displayPath, diagnostics);
|
|
125
|
+
validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
|
|
102
126
|
validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, displayPath, diagnostics);
|
|
103
127
|
await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
|
|
128
|
+
await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
|
|
104
129
|
}
|
|
130
|
+
validateRelationshipConstraints(
|
|
131
|
+
loaded.resources,
|
|
132
|
+
loaded.model,
|
|
133
|
+
byId,
|
|
134
|
+
pathById,
|
|
135
|
+
diagnostics
|
|
136
|
+
);
|
|
105
137
|
|
|
106
138
|
diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
|
|
107
139
|
return {
|
|
@@ -116,10 +148,53 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
116
148
|
};
|
|
117
149
|
}
|
|
118
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
|
+
|
|
119
185
|
function validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, path, diagnostics) {
|
|
120
186
|
if (record.type !== "control" || record.status !== "implemented") return;
|
|
121
187
|
const schedules = obligationsByControl.get(record.id) || [];
|
|
122
|
-
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;
|
|
123
198
|
const stopped = schedules.filter((obligation) => !obligationIsRunning(obligation, byId, asOf));
|
|
124
199
|
const paused = stopped.filter((obligation) => obligation.status === "paused");
|
|
125
200
|
const waiting = stopped.filter((obligation) => obligation.status === "active");
|
|
@@ -149,9 +224,7 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
|
|
|
149
224
|
function validateDateRanges(record, path, diagnostics) {
|
|
150
225
|
for (const [startField, endField] of [
|
|
151
226
|
["startDate", "endDate"],
|
|
152
|
-
["
|
|
153
|
-
["candidatePeriodStart", "candidatePeriodEnd"],
|
|
154
|
-
["dueWindowStart", "dueWindowEnd"]
|
|
227
|
+
["startsOn", "endsOn"]
|
|
155
228
|
]) {
|
|
156
229
|
const start = record[startField];
|
|
157
230
|
const end = record[endField];
|
|
@@ -165,7 +238,25 @@ function validateDateRanges(record, path, diagnostics) {
|
|
|
165
238
|
}
|
|
166
239
|
}
|
|
167
240
|
|
|
168
|
-
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) {
|
|
169
260
|
if (record.type !== "obligation-event" || record.status !== "complete") return;
|
|
170
261
|
if (record.completedOn && record.occurredOn && record.completedOn < record.occurredOn) {
|
|
171
262
|
diagnostics.push(error(
|
|
@@ -193,7 +284,9 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
|
|
|
193
284
|
continue;
|
|
194
285
|
}
|
|
195
286
|
const obligation = byId.get(action.obligationId);
|
|
196
|
-
const expectedTypes = obligation?.type === "obligation"
|
|
287
|
+
const expectedTypes = obligation?.type === "obligation"
|
|
288
|
+
? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
|
|
289
|
+
: [];
|
|
197
290
|
if (!expectedTypes.length) continue;
|
|
198
291
|
const linked = [...new Set([...(action.completionResourceIds || []), ...(action.evidenceIds || [])])]
|
|
199
292
|
.map((id) => byId.get(id))
|
|
@@ -239,9 +332,38 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
|
|
|
239
332
|
}
|
|
240
333
|
}
|
|
241
334
|
|
|
242
|
-
function validateObligation(record, path, diagnostics) {
|
|
335
|
+
function validateObligation(record, model, byId, path, diagnostics) {
|
|
243
336
|
const recurrence = record.recurrence;
|
|
244
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
|
+
}
|
|
245
367
|
if (recurrence.mode === "calendar") {
|
|
246
368
|
const normalized = { ...recurrence, anchorDate: recurrence.anchorDate || record.startsOn };
|
|
247
369
|
if (!validCalendarRecurrence(normalized)) {
|
|
@@ -252,11 +374,11 @@ function validateObligation(record, path, diagnostics) {
|
|
|
252
374
|
));
|
|
253
375
|
}
|
|
254
376
|
} else if (recurrence.mode === "event") {
|
|
255
|
-
if (
|
|
377
|
+
if (!model.policyEvents?.[recurrence.eventType]) {
|
|
256
378
|
diagnostics.push(error(
|
|
257
379
|
"invalid-obligation-recurrence",
|
|
258
380
|
path,
|
|
259
|
-
"Event recurrence
|
|
381
|
+
"Event recurrence must use a policy event defined by the model."
|
|
260
382
|
));
|
|
261
383
|
}
|
|
262
384
|
} else {
|
|
@@ -268,53 +390,287 @@ function validateObligation(record, path, diagnostics) {
|
|
|
268
390
|
}
|
|
269
391
|
|
|
270
392
|
const window = record.window;
|
|
271
|
-
if (!window || Array.isArray(window) || typeof window !== "object")
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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;
|
|
275
401
|
if (!Number.isInteger(window[name])) {
|
|
276
402
|
diagnostics.push(error("invalid-obligation-window", path, `window.${name} must be an integer.`));
|
|
277
403
|
}
|
|
278
404
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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.`));
|
|
282
410
|
}
|
|
283
411
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
));
|
|
287
439
|
}
|
|
288
440
|
}
|
|
289
|
-
|
|
290
|
-
|
|
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."));
|
|
291
469
|
}
|
|
292
|
-
|
|
293
|
-
|
|
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.");
|
|
294
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) {
|
|
295
522
|
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
)
|
|
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) {
|
|
301
597
|
diagnostics.push(error(
|
|
302
|
-
"
|
|
598
|
+
"approval-content-changed",
|
|
303
599
|
path,
|
|
304
|
-
|
|
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.`
|
|
305
601
|
));
|
|
306
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
|
+
}
|
|
307
651
|
if (
|
|
308
|
-
|
|
309
|
-
&&
|
|
652
|
+
record.type === "evidence"
|
|
653
|
+
&& record.artifactKind === "population-export"
|
|
654
|
+
&& coverage.kind !== "range"
|
|
310
655
|
) {
|
|
311
|
-
diagnostics.push(error("invalid-
|
|
656
|
+
diagnostics.push(error("invalid-coverage", path, "Population Export Evidence requires range coverage."));
|
|
312
657
|
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function validateClassification(record, workspace, path, diagnostics) {
|
|
661
|
+
if (!record.classificationId) return;
|
|
662
|
+
const definitions = workspace?.classificationDefinitions;
|
|
313
663
|
if (
|
|
314
|
-
|
|
315
|
-
|
|
664
|
+
!definitions
|
|
665
|
+
|| Array.isArray(definitions)
|
|
666
|
+
|| typeof definitions !== "object"
|
|
667
|
+
|| !Object.hasOwn(definitions, record.classificationId)
|
|
316
668
|
) {
|
|
317
|
-
diagnostics.push(error(
|
|
669
|
+
diagnostics.push(error(
|
|
670
|
+
"unknown-classification",
|
|
671
|
+
path,
|
|
672
|
+
`classificationId references undefined Workspace classification "${record.classificationId}".`
|
|
673
|
+
));
|
|
318
674
|
}
|
|
319
675
|
}
|
|
320
676
|
|
|
@@ -340,6 +696,13 @@ function validateRecord(record, definition, model, path, diagnostics) {
|
|
|
340
696
|
]);
|
|
341
697
|
for (const [name, field] of Object.entries(fields)) {
|
|
342
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
|
+
}
|
|
343
706
|
if (field.disjointFrom) {
|
|
344
707
|
const values = normalizedValues(record[name]);
|
|
345
708
|
const otherValues = new Set(normalizedValues(record[field.disjointFrom]));
|
|
@@ -365,7 +728,11 @@ function validateRecord(record, definition, model, path, diagnostics) {
|
|
|
365
728
|
for (const [name, value] of Object.entries(record)) {
|
|
366
729
|
const field = fields[name];
|
|
367
730
|
if (!field) {
|
|
368
|
-
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
|
+
));
|
|
369
736
|
continue;
|
|
370
737
|
}
|
|
371
738
|
validateValue(name, value, field, model, path, diagnostics);
|
|
@@ -378,6 +745,109 @@ function normalizedValues(value) {
|
|
|
378
745
|
return typeof value === "string" ? [value] : [];
|
|
379
746
|
}
|
|
380
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
|
+
|
|
381
851
|
async function validateMarkdown(record, definition, model, root, path, diagnostics) {
|
|
382
852
|
const present = new Set();
|
|
383
853
|
for (const item of markdownEntries(model, record)) {
|
|
@@ -452,14 +922,28 @@ function validateValue(name, value, field, model, path, diagnostics) {
|
|
|
452
922
|
if (typeof value !== "boolean") fail("must be a boolean.");
|
|
453
923
|
return;
|
|
454
924
|
case "object":
|
|
455
|
-
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
|
+
}
|
|
456
932
|
return;
|
|
457
933
|
case "array":
|
|
458
934
|
if (!Array.isArray(value)) {
|
|
459
935
|
fail("must be an array.");
|
|
460
936
|
return;
|
|
461
937
|
}
|
|
462
|
-
|
|
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
|
+
}
|
|
463
947
|
return;
|
|
464
948
|
default:
|
|
465
949
|
fail(`uses unsupported model type "${field.type}".`);
|
|
@@ -489,9 +973,12 @@ function validateNumericRange(value, field, fail) {
|
|
|
489
973
|
}
|
|
490
974
|
}
|
|
491
975
|
|
|
492
|
-
function validateArrayItem(name, value,
|
|
976
|
+
function validateArrayItem(name, value, field, model, path, diagnostics, index) {
|
|
977
|
+
const type = field.items;
|
|
493
978
|
if (type === "object" && (!value || Array.isArray(value) || typeof value !== "object")) {
|
|
494
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);
|
|
495
982
|
} else if ((type === "string" || type === "data-path") && typeof value !== "string") {
|
|
496
983
|
diagnostics.push(error("invalid-field", path, `${name} items must be strings.`));
|
|
497
984
|
} else if (type === "id" && (typeof value !== "string" || !ID_PATTERN.test(value))) {
|
|
@@ -499,6 +986,85 @@ function validateArrayItem(name, value, type, path, diagnostics) {
|
|
|
499
986
|
}
|
|
500
987
|
}
|
|
501
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
|
+
|
|
502
1068
|
function isDate(value) {
|
|
503
1069
|
return DATE_PATTERN.test(value) && Boolean(parseCalendarDate(value));
|
|
504
1070
|
}
|