filegrc 0.1.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/src/state.js ADDED
@@ -0,0 +1,84 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { assessAuditPreparation } from "./audit-preparation.js";
4
+ import { getGitSummary, getWorkspaceHistories } from "./git.js";
5
+ import { renderMarkdown } from "./markdown.js";
6
+ import { planObligations } from "./obligations.js";
7
+ import { resolveDataPath } from "./paths.js";
8
+ import { markdownEntries } from "./resource-markdown.js";
9
+ import { currentCalendarDate } from "./time.js";
10
+ import { validateWorkspace } from "./validate.js";
11
+
12
+ export async function createAppState(input = process.cwd(), options = {}) {
13
+ const validation = await validateWorkspace(input);
14
+ const { loaded } = validation;
15
+ const entries = [];
16
+ const relativePaths = loaded.entries.map((entry) => `data/${entry.relativePath}`);
17
+ const histories = getWorkspaceHistories(loaded.root, relativePaths, 12);
18
+
19
+ for (const entry of loaded.entries) {
20
+ const record = structuredClone(entry.record);
21
+ const content = {};
22
+ if (loaded.model.resources[record.type]) {
23
+ for (const item of markdownEntries(loaded.model, record)) {
24
+ try {
25
+ const path = resolveDataPath(loaded.root, item.path);
26
+ const source = await readFile(path, "utf8");
27
+ content[item.name] = { source, html: renderMarkdown(source), path: item.path, revision: contentRevision(source) };
28
+ } catch {
29
+ // Validation reports missing required Markdown.
30
+ }
31
+ }
32
+ }
33
+ entries.push({
34
+ record,
35
+ relativePath: `data/${entry.relativePath}`,
36
+ revision: contentRevision(entry.source),
37
+ content,
38
+ history: histories.get(`data/${entry.relativePath}`) ?? []
39
+ });
40
+ }
41
+
42
+ const git = getGitSummary(loaded.root);
43
+ delete git.root;
44
+ const workspace = loaded.workspace ?? {
45
+ schemaVersion: 1,
46
+ dataModelVersion: loaded.model.modelVersion,
47
+ id: "workspace",
48
+ type: "workspace",
49
+ title: "FileGRC workspace",
50
+ organizationName: "Workspace configuration unavailable",
51
+ timezone: "UTC"
52
+ };
53
+ const asOf = options.asOf ?? currentCalendarDate(workspace.timezone);
54
+ const generatedAt = new Date().toISOString();
55
+ const audits = loaded.resources.filter((record) => record.type === "audit");
56
+ const auditPreparations = Object.fromEntries(await Promise.all(
57
+ (audits.length ? audits : [null]).map(async (audit) => {
58
+ const preparation = await assessAuditPreparation(loaded, {
59
+ auditId: audit?.id,
60
+ generatedAt
61
+ });
62
+ return [audit?.id || "none", preparation];
63
+ })
64
+ ));
65
+ return {
66
+ generatedAt,
67
+ readOnly: Boolean(options.readOnly),
68
+ workspace,
69
+ model: loaded.model,
70
+ resources: entries,
71
+ validation: {
72
+ ok: validation.ok,
73
+ counts: validation.counts,
74
+ diagnostics: validation.diagnostics
75
+ },
76
+ obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt }),
77
+ auditPreparations,
78
+ git
79
+ };
80
+ }
81
+
82
+ function contentRevision(source) {
83
+ return createHash("sha256").update(source).digest("hex");
84
+ }
package/src/time.js ADDED
@@ -0,0 +1,62 @@
1
+ export function formatCalendarDate(value, locale) {
2
+ const source = String(value);
3
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(source);
4
+ if (!match) return source;
5
+ const [, year, month, day] = match.map(Number);
6
+ const date = new Date(0);
7
+ date.setUTCFullYear(year, month - 1, day);
8
+ date.setUTCHours(0, 0, 0, 0);
9
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return source;
10
+ return new Intl.DateTimeFormat(locale, {
11
+ month: "short",
12
+ day: "numeric",
13
+ year: "numeric",
14
+ timeZone: "UTC"
15
+ }).format(date);
16
+ }
17
+
18
+ export function formatLocalDateTime(value, locale, timeZone) {
19
+ const source = String(value);
20
+ const date = new Date(source);
21
+ if (Number.isNaN(date.valueOf())) return source;
22
+ const options = {
23
+ year: "numeric",
24
+ month: "short",
25
+ day: "numeric",
26
+ hour: "numeric",
27
+ minute: "2-digit",
28
+ second: "2-digit",
29
+ timeZoneName: "short"
30
+ };
31
+ if (timeZone) options.timeZone = timeZone;
32
+ return new Intl.DateTimeFormat(locale, options).format(date);
33
+ }
34
+
35
+ export function currentCalendarDate(timeZone, now = new Date()) {
36
+ try {
37
+ const parts = new Intl.DateTimeFormat("en-US", {
38
+ timeZone,
39
+ year: "numeric",
40
+ month: "2-digit",
41
+ day: "2-digit"
42
+ }).formatToParts(now);
43
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
44
+ return `${values.year}-${values.month}-${values.day}`;
45
+ } catch {
46
+ return now.toISOString().slice(0, 10);
47
+ }
48
+ }
49
+
50
+ export function isRfc3339Timestamp(value) {
51
+ const match = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.exec(value || "");
52
+ if (!match) return false;
53
+ const [year, month, day] = match[1].split("-").map(Number);
54
+ if (year < 1) return false;
55
+ const date = new Date(0);
56
+ date.setUTCFullYear(year, month - 1, day);
57
+ date.setUTCHours(0, 0, 0, 0);
58
+ return date.getUTCFullYear() === year
59
+ && date.getUTCMonth() === month - 1
60
+ && date.getUTCDate() === day
61
+ && !Number.isNaN(Date.parse(value));
62
+ }
@@ -0,0 +1,496 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { getResourceDefinition } from "../model/index.js";
3
+ import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
4
+ import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
5
+ import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
6
+ import { isRfc3339Timestamp } from "./time.js";
7
+ import { indexResources, loadWorkspace } from "./workspace.js";
8
+
9
+ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
11
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12
+ const MAX_OBLIGATION_OFFSET_DAYS = 36_600;
13
+ const MAX_OBLIGATION_OFFSET_HOURS = MAX_OBLIGATION_OFFSET_DAYS * 24;
14
+
15
+ export async function validateWorkspace(input = process.cwd()) {
16
+ const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
17
+ const diagnostics = [...loaded.diagnostics];
18
+ const { byId } = indexResources(loaded.resources);
19
+ const seen = new Map();
20
+
21
+ for (const entry of loaded.entries) {
22
+ const { record } = entry;
23
+ const displayPath = `data/${entry.relativePath}`;
24
+ if (!record || Array.isArray(record) || typeof record !== "object") {
25
+ diagnostics.push(error("invalid-record", displayPath, "A resource must be a JSON object."));
26
+ continue;
27
+ }
28
+ if (typeof record.id === "string") {
29
+ if (seen.has(record.id)) {
30
+ diagnostics.push(error("duplicate-id", displayPath, `ID "${record.id}" is already used by ${seen.get(record.id)}.`));
31
+ } else {
32
+ seen.set(record.id, displayPath);
33
+ }
34
+ }
35
+
36
+ let definition;
37
+ try {
38
+ definition = getResourceDefinition(loaded.model, record.type);
39
+ } catch {
40
+ diagnostics.push(error("unknown-type", displayPath, `Unknown resource type "${record.type ?? ""}".`));
41
+ continue;
42
+ }
43
+
44
+ validateLocation(record, definition, entry.relativePath, diagnostics);
45
+ validateRecord(record, definition, loaded.model, displayPath, diagnostics);
46
+ validateDateRanges(record, displayPath, diagnostics);
47
+ if (record.type === "obligation") validateObligation(record, displayPath, diagnostics);
48
+ if (record.type === "evidence") validateEvidencePaths(record, displayPath, diagnostics);
49
+
50
+ const fields = { ...loaded.model.commonFields, ...definition.fields };
51
+ for (const [fieldName, field] of Object.entries(fields)) {
52
+ const value = record[fieldName];
53
+ if (value === undefined || value === null) continue;
54
+ if (field.format === "data-path" || field.items === "data-path") {
55
+ const values = Array.isArray(value) ? value : [value];
56
+ for (const item of values) {
57
+ if (typeof item !== "string") continue;
58
+ try {
59
+ const path = resolveDataPath(loaded.root, item);
60
+ if (!(await stat(path)).isFile()) throw new Error("The data path is not a file.");
61
+ } catch {
62
+ diagnostics.push(error(
63
+ "missing-content",
64
+ displayPath,
65
+ `${fieldName} points to unavailable data path "${item}".`
66
+ ));
67
+ }
68
+ }
69
+ }
70
+ if (field.relation) {
71
+ const ids = Array.isArray(value) ? value : [value];
72
+ for (const id of ids) {
73
+ const target = byId.get(id);
74
+ if (!target) {
75
+ diagnostics.push(error("missing-reference", displayPath, `${fieldName} references unknown ID "${id}".`));
76
+ continue;
77
+ }
78
+ const allowed = field.relation;
79
+ if (!allowed.includes("*") && !allowed.includes(target.type)) {
80
+ diagnostics.push(error(
81
+ "wrong-reference-type",
82
+ displayPath,
83
+ `${fieldName} references ${target.type} "${id}", expected ${allowed.join(" or ")}.`
84
+ ));
85
+ }
86
+ }
87
+ }
88
+ }
89
+ validateIndependentApproval(record, byId, displayPath, diagnostics);
90
+ validateCompletedObligationEvent(record, byId, displayPath, diagnostics);
91
+ await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
92
+ }
93
+
94
+ diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
95
+ return {
96
+ ok: !diagnostics.some(({ severity }) => severity === "error"),
97
+ diagnostics,
98
+ counts: {
99
+ resources: loaded.resources.length,
100
+ errors: diagnostics.filter(({ severity }) => severity === "error").length,
101
+ warnings: diagnostics.filter(({ severity }) => severity === "warning").length
102
+ },
103
+ loaded
104
+ };
105
+ }
106
+
107
+ function validateDateRanges(record, path, diagnostics) {
108
+ for (const [startField, endField] of [
109
+ ["startDate", "endDate"],
110
+ ["periodStart", "periodEnd"],
111
+ ["dueWindowStart", "dueWindowEnd"]
112
+ ]) {
113
+ const start = record[startField];
114
+ const end = record[endField];
115
+ if (parseCalendarDate(start) && parseCalendarDate(end) && end < start) {
116
+ diagnostics.push(error(
117
+ "invalid-date-range",
118
+ path,
119
+ `${endField} cannot be before ${startField}.`
120
+ ));
121
+ }
122
+ }
123
+ }
124
+
125
+ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
126
+ if (record.type !== "obligation-event" || record.status !== "complete") return;
127
+ if (record.completedOn && record.occurredOn && record.completedOn < record.occurredOn) {
128
+ diagnostics.push(error(
129
+ "incomplete-obligation-event",
130
+ path,
131
+ "completedOn cannot be before occurredOn."
132
+ ));
133
+ }
134
+ const actionIds = record.actionItemIds || [];
135
+ if (actionIds.length === 0) {
136
+ diagnostics.push(error("incomplete-obligation-event", path, "A complete obligation event must have action items."));
137
+ return;
138
+ }
139
+ for (const actionId of actionIds) {
140
+ const action = byId.get(actionId);
141
+ if (!action || action.type !== "action-item") continue;
142
+ if (action.status !== "done") {
143
+ diagnostics.push(error(
144
+ "incomplete-obligation-event",
145
+ path,
146
+ `Action item "${actionId}" must be done before the event is complete.`
147
+ ));
148
+ continue;
149
+ }
150
+ const obligation = byId.get(action.obligationId);
151
+ const expectedTypes = obligation?.type === "obligation" ? obligation.completionResourceTypes || [] : [];
152
+ if (!expectedTypes.length) continue;
153
+ const linked = [...new Set([...(action.completionResourceIds || []), ...(action.evidenceIds || [])])]
154
+ .map((id) => byId.get(id))
155
+ .filter(Boolean);
156
+ if (!linked.some((item) => expectedTypes.includes(item.type))) {
157
+ diagnostics.push(error(
158
+ "incomplete-obligation-event",
159
+ path,
160
+ `Action item "${actionId}" needs a linked completion of type ${expectedTypes.join(" or ")}.`
161
+ ));
162
+ }
163
+ }
164
+ }
165
+
166
+ function validateEvidencePaths(record, path, diagnostics) {
167
+ const expectedPrefix = `evidence/${record.id}/`;
168
+ for (const filePath of record.filePaths || []) {
169
+ if (
170
+ typeof filePath === "string"
171
+ && (!isCanonicalDataPath(filePath) || !filePath.startsWith(expectedPrefix))
172
+ ) {
173
+ diagnostics.push(error(
174
+ "misplaced-evidence-attachment",
175
+ path,
176
+ `filePaths attachments must stay under data/${expectedPrefix}.`
177
+ ));
178
+ }
179
+ }
180
+ }
181
+
182
+ function validateIndependentApproval(record, byId, path, diagnostics) {
183
+ if (!["policy", "document"].includes(record.type) || !(record.approverIds || []).length) return;
184
+ const owners = expandPeople(record.ownerIds || [], byId);
185
+ const approvers = expandPeople(record.approverIds || [], byId);
186
+ const overlap = [...owners].filter((id) => approvers.has(id));
187
+ if (overlap.length) {
188
+ diagnostics.push(error(
189
+ "overlapping-approval-participants",
190
+ path,
191
+ `Approvers must be separate from owners, including through team membership: ${overlap.join(", ")}.`
192
+ ));
193
+ }
194
+ }
195
+
196
+ function expandPeople(ids, byId, seen = new Set()) {
197
+ const people = new Set();
198
+ for (const id of ids) {
199
+ if (seen.has(id)) continue;
200
+ seen.add(id);
201
+ const record = byId.get(id);
202
+ if (record?.type === "person") people.add(id);
203
+ if (record?.type === "team") {
204
+ for (const personId of expandPeople([...(record.memberIds || []), ...(record.chairIds || [])], byId, seen)) {
205
+ people.add(personId);
206
+ }
207
+ }
208
+ }
209
+ return people;
210
+ }
211
+
212
+ function validateObligation(record, path, diagnostics) {
213
+ const recurrence = record.recurrence;
214
+ if (!recurrence || Array.isArray(recurrence) || typeof recurrence !== "object") return;
215
+ if (recurrence.mode === "calendar") {
216
+ const normalized = { ...recurrence, anchorDate: recurrence.anchorDate || record.startsOn };
217
+ if (!validCalendarRecurrence(normalized)) {
218
+ diagnostics.push(error(
219
+ "invalid-obligation-recurrence",
220
+ path,
221
+ "Calendar recurrence requires a positive safe-integer interval, day/week/month/year unit, and a valid anchorDate or startsOn date."
222
+ ));
223
+ }
224
+ } else if (recurrence.mode === "event") {
225
+ if (typeof recurrence.eventType !== "string" || !ID_PATTERN.test(recurrence.eventType)) {
226
+ diagnostics.push(error(
227
+ "invalid-obligation-recurrence",
228
+ path,
229
+ "Event recurrence requires a lowercase kebab-case eventType."
230
+ ));
231
+ }
232
+ } else {
233
+ diagnostics.push(error(
234
+ "invalid-obligation-recurrence",
235
+ path,
236
+ 'Obligation recurrence mode must be "calendar" or "event".'
237
+ ));
238
+ }
239
+
240
+ const window = record.window;
241
+ if (!window || Array.isArray(window) || typeof window !== "object") return;
242
+ const dayFields = ["startOffsetDays", "endOffsetDays"].filter((name) => window[name] !== undefined);
243
+ const hourFields = ["startOffsetHours", "endOffsetHours"].filter((name) => window[name] !== undefined);
244
+ for (const name of [...dayFields, ...hourFields]) {
245
+ if (!Number.isInteger(window[name])) {
246
+ diagnostics.push(error("invalid-obligation-window", path, `window.${name} must be an integer.`));
247
+ }
248
+ }
249
+ for (const name of dayFields) {
250
+ if (Number.isInteger(window[name]) && Math.abs(window[name]) > MAX_OBLIGATION_OFFSET_DAYS) {
251
+ diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${MAX_OBLIGATION_OFFSET_DAYS.toLocaleString("en-US")} days of the policy event.`));
252
+ }
253
+ }
254
+ for (const name of hourFields) {
255
+ if (Number.isInteger(window[name]) && Math.abs(window[name]) > MAX_OBLIGATION_OFFSET_HOURS) {
256
+ diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${MAX_OBLIGATION_OFFSET_HOURS.toLocaleString("en-US")} hours of the policy event.`));
257
+ }
258
+ }
259
+ if (dayFields.length && hourFields.length) {
260
+ diagnostics.push(error("invalid-obligation-window", path, "An obligation window cannot mix day and hour offsets."));
261
+ }
262
+ if (recurrence.mode === "calendar" && hourFields.length) {
263
+ diagnostics.push(error("invalid-obligation-window", path, "Calendar obligations use day offsets; hour offsets are only valid for event obligations."));
264
+ }
265
+ if (
266
+ recurrence.mode === "calendar"
267
+ && Number.isInteger(window.startOffsetDays)
268
+ && window.startOffsetDays > 0
269
+ && window.endOffsetDays === undefined
270
+ ) {
271
+ diagnostics.push(error(
272
+ "invalid-obligation-window",
273
+ path,
274
+ "Calendar obligations with a positive window.startOffsetDays must set window.endOffsetDays."
275
+ ));
276
+ }
277
+ if (
278
+ Number.isInteger(window.endOffsetDays)
279
+ && window.endOffsetDays < (Number.isInteger(window.startOffsetDays) ? window.startOffsetDays : 0)
280
+ ) {
281
+ diagnostics.push(error("invalid-obligation-window", path, "window.endOffsetDays must be on or after window.startOffsetDays."));
282
+ }
283
+ if (
284
+ Number.isInteger(window.endOffsetHours)
285
+ && window.endOffsetHours < (Number.isInteger(window.startOffsetHours) ? window.startOffsetHours : 0)
286
+ ) {
287
+ diagnostics.push(error("invalid-obligation-window", path, "window.endOffsetHours must be on or after window.startOffsetHours."));
288
+ }
289
+ }
290
+
291
+ function validateLocation(record, definition, relativePath, diagnostics) {
292
+ if (definition.singleton) {
293
+ if (relativePath !== definition.singleton) {
294
+ diagnostics.push(error("wrong-location", `data/${relativePath}`, `${record.type} belongs at data/${definition.singleton}.`));
295
+ }
296
+ return;
297
+ }
298
+ const recordPath = (definition.recordPath ?? "{id}.json").replaceAll("{id}", record.id);
299
+ const expected = `${definition.collection}/${recordPath}`;
300
+ if (relativePath !== expected) {
301
+ diagnostics.push(error("wrong-location", `data/${relativePath}`, `${record.type} belongs at data/${expected}.`));
302
+ }
303
+ }
304
+
305
+ function validateRecord(record, definition, model, path, diagnostics) {
306
+ const fields = { ...model.commonFields, ...definition.fields };
307
+ const required = new Set([
308
+ ...Object.entries(model.commonFields).filter(([, field]) => field.required).map(([name]) => name),
309
+ ...(definition.required ?? [])
310
+ ]);
311
+ for (const [name, field] of Object.entries(fields)) {
312
+ if (field.requiredWhen && conditionMatches(record, field.requiredWhen)) required.add(name);
313
+ if (field.disjointFrom) {
314
+ const values = normalizedValues(record[name]);
315
+ const otherValues = new Set(normalizedValues(record[field.disjointFrom]));
316
+ const overlap = values.filter((value) => otherValues.has(value));
317
+ if (overlap.length) {
318
+ diagnostics.push(error(
319
+ "overlapping-fields",
320
+ path,
321
+ `${name} must not contain the same IDs as ${field.disjointFrom}: ${overlap.join(", ")}.`
322
+ ));
323
+ }
324
+ }
325
+ }
326
+ for (const name of required) {
327
+ if (isMissing(record[name])) {
328
+ diagnostics.push(error("missing-field", path, `Required field "${name}" is missing.`));
329
+ }
330
+ }
331
+ if (record.type && record.type !== findDefinitionType(model, definition)) {
332
+ diagnostics.push(error("wrong-type", path, `Resource type "${record.type}" does not match its model definition.`));
333
+ }
334
+
335
+ for (const [name, value] of Object.entries(record)) {
336
+ const field = fields[name];
337
+ if (!field) {
338
+ diagnostics.push(warning("unknown-field", path, `Field "${name}" is not defined by model v${model.modelVersion}.`));
339
+ continue;
340
+ }
341
+ validateValue(name, value, field, model, path, diagnostics);
342
+ }
343
+
344
+ }
345
+
346
+ function normalizedValues(value) {
347
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
348
+ return typeof value === "string" ? [value] : [];
349
+ }
350
+
351
+ async function validateMarkdown(record, definition, model, root, path, diagnostics) {
352
+ const present = new Set();
353
+ for (const item of markdownEntries(model, record)) {
354
+ try {
355
+ if ((await stat(resolveDataPath(root, item.path))).isFile()) present.add(item.name);
356
+ else throw new Error("The Markdown path is not a file.");
357
+ } catch (cause) {
358
+ if (item.required || cause.code !== "ENOENT") {
359
+ const message = item.required && cause.code === "ENOENT"
360
+ ? `Required ${item.label} Markdown is missing at data/${item.path}.`
361
+ : `${item.label} Markdown must be a regular file at data/${item.path}.`;
362
+ diagnostics.push(error("missing-markdown", path, message));
363
+ }
364
+ }
365
+ }
366
+
367
+ for (const choices of definition.oneOf ?? []) {
368
+ const satisfied = choices.some((name) => (
369
+ isMarkdownChoice(name)
370
+ ? present.has(name.slice("$markdown:".length))
371
+ : !isMissing(record[name])
372
+ ));
373
+ if (!satisfied) {
374
+ const labels = choices.map((name) => (
375
+ isMarkdownChoice(name) ? `${name.slice("$markdown:".length)} Markdown` : name
376
+ ));
377
+ diagnostics.push(error("missing-choice", path, `At least one of ${labels.join(", ")} is required.`));
378
+ }
379
+ }
380
+ }
381
+
382
+ function isMissing(value) {
383
+ return value === undefined
384
+ || value === null
385
+ || (typeof value === "string" && value.trim() === "")
386
+ || (Array.isArray(value) && value.length === 0);
387
+ }
388
+
389
+ function validateValue(name, value, field, model, path, diagnostics) {
390
+ const fail = (message) => diagnostics.push(error("invalid-field", path, `${name}: ${message}`));
391
+ if (field.const !== undefined && value !== field.const) fail(`must equal ${JSON.stringify(field.const)}.`);
392
+ switch (field.type) {
393
+ case "string":
394
+ case "id":
395
+ case "date":
396
+ case "timestamp":
397
+ case "enum":
398
+ case "rating":
399
+ case "outcome":
400
+ if (typeof value !== "string") {
401
+ fail("must be a string.");
402
+ return;
403
+ }
404
+ break;
405
+ case "integer":
406
+ if (!Number.isInteger(value)) {
407
+ fail("must be an integer.");
408
+ return;
409
+ }
410
+ validateNumericRange(value, field, fail);
411
+ return;
412
+ case "number":
413
+ if (typeof value !== "number" || !Number.isFinite(value)) {
414
+ fail("must be a finite number.");
415
+ return;
416
+ }
417
+ validateNumericRange(value, field, fail);
418
+ return;
419
+ case "boolean":
420
+ if (typeof value !== "boolean") fail("must be a boolean.");
421
+ return;
422
+ case "object":
423
+ if (!value || Array.isArray(value) || typeof value !== "object") fail("must be an object.");
424
+ return;
425
+ case "array":
426
+ if (!Array.isArray(value)) {
427
+ fail("must be an array.");
428
+ return;
429
+ }
430
+ for (const item of value) validateArrayItem(name, item, field.items, path, diagnostics);
431
+ return;
432
+ default:
433
+ fail(`uses unsupported model type "${field.type}".`);
434
+ return;
435
+ }
436
+
437
+ const enumValues = field.values
438
+ ?? (field.type === "rating" ? model.primitives?.rating : undefined)
439
+ ?? (field.type === "outcome" ? model.primitives?.outcome : undefined);
440
+ if (enumValues && !enumValues.includes(value)) fail(`must be one of ${enumValues.join(", ")}.`);
441
+ if ((field.type === "id" || field.format === "id") && !ID_PATTERN.test(value)) fail("must use lowercase kebab-case.");
442
+ if ((field.type === "date" || field.format === "date") && !isDate(value)) fail("must be an ISO 8601 date (YYYY-MM-DD).");
443
+ if (field.type === "timestamp" && !isRfc3339Timestamp(value)) {
444
+ fail("must be an RFC 3339 timestamp with a timezone.");
445
+ }
446
+ if (field.format === "email" && !EMAIL_PATTERN.test(value)) fail("must be an email address.");
447
+ if (field.format === "timezone" && !isTimezone(value)) fail("must be an IANA time zone.");
448
+ }
449
+
450
+ function validateNumericRange(value, field, fail) {
451
+ if (field.minimum !== undefined && value < field.minimum) {
452
+ fail(`must be at least ${field.minimum}.`);
453
+ }
454
+ if (field.maximum !== undefined && value > field.maximum) {
455
+ fail(`must be at most ${field.maximum}.`);
456
+ }
457
+ }
458
+
459
+ function validateArrayItem(name, value, type, path, diagnostics) {
460
+ if (type === "object" && (!value || Array.isArray(value) || typeof value !== "object")) {
461
+ diagnostics.push(error("invalid-field", path, `${name} items must be objects.`));
462
+ } else if ((type === "string" || type === "data-path") && typeof value !== "string") {
463
+ diagnostics.push(error("invalid-field", path, `${name} items must be strings.`));
464
+ } else if (type === "id" && (typeof value !== "string" || !ID_PATTERN.test(value))) {
465
+ diagnostics.push(error("invalid-field", path, `${name} items must be lowercase kebab-case IDs.`));
466
+ }
467
+ }
468
+
469
+ function isDate(value) {
470
+ return DATE_PATTERN.test(value) && Boolean(parseCalendarDate(value));
471
+ }
472
+
473
+ function isTimezone(value) {
474
+ try {
475
+ new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
476
+ return true;
477
+ } catch {
478
+ return false;
479
+ }
480
+ }
481
+
482
+ function conditionMatches(record, condition) {
483
+ return Object.entries(condition).every(([name, value]) => record[name] === value);
484
+ }
485
+
486
+ function findDefinitionType(model, definition) {
487
+ return Object.entries(model.resources).find(([, item]) => item === definition)?.[0];
488
+ }
489
+
490
+ function error(code, path, message) {
491
+ return { severity: "error", code, path, message };
492
+ }
493
+
494
+ function warning(code, path, message) {
495
+ return { severity: "warning", code, path, message };
496
+ }