-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcross-validate.ts
More file actions
142 lines (125 loc) · 4.21 KB
/
Copy pathcross-validate.ts
File metadata and controls
142 lines (125 loc) · 4.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import { collectPaths, parseCondition } from "./condition-parser.ts";
import type { QuestionsFile, SchemaFile } from "./meta-schema.ts";
export type CrossValidationIssue = {
kind:
| "maps_to-unknown-path"
| "required-uncovered"
| "section-unknown"
| "option-outside-enum"
| "question-field-type-mismatch"
| "question-section-mismatch"
| "when-unknown-path";
where: string;
detail: string;
};
type Field = SchemaFile["sections"][number]["fields"][number];
type NonInfoQuestionType = "text" | "number" | "bool" | "single" | "multi";
const QUESTION_TO_FIELD_TYPE: Record<NonInfoQuestionType, Field["type"]> = {
text: "string",
number: "number",
bool: "boolean",
single: "enum",
multi: "array",
};
export function crossValidate(schema: SchemaFile, questions: QuestionsFile): CrossValidationIssue[] {
const issues: CrossValidationIssue[] = [];
const fieldByPath = new Map<string, Field>();
const sectionByFieldPath = new Map<string, string>();
const sectionIds = new Set<string>();
for (const section of schema.sections) {
sectionIds.add(section.id);
for (const field of section.fields) {
fieldByPath.set(field.path, field);
sectionByFieldPath.set(field.path, section.id);
}
}
const coveredPaths = new Set<string>();
for (const q of questions.questions) {
const sectionKnown = sectionIds.has(q.section);
if (!sectionKnown) {
issues.push({
kind: "section-unknown",
where: q.id,
detail: `question references section '${q.section}' not declared in schema`,
});
}
if (q.when !== undefined) {
try {
const ast = parseCondition(q.when);
for (const p of collectPaths(ast)) {
if (!fieldByPath.has(p)) {
issues.push({
kind: "when-unknown-path",
where: q.id,
detail: `when references unknown path '${p}'`,
});
}
}
} catch {
// syntactic errors are surfaced by meta-schema; skip here.
}
}
if (q.type === "info") continue;
const field = fieldByPath.get(q.maps_to);
if (!field) {
issues.push({
kind: "maps_to-unknown-path",
where: q.id,
detail: `maps_to '${q.maps_to}' does not match any field in schema`,
});
continue;
}
coveredPaths.add(q.maps_to);
const expectedFieldType = QUESTION_TO_FIELD_TYPE[q.type];
if (field.type !== expectedFieldType) {
issues.push({
kind: "question-field-type-mismatch",
where: q.id,
detail: `question type '${q.type}' expects field type '${expectedFieldType}' but '${field.path}' is '${field.type}'`,
});
}
if (sectionKnown) {
const fieldSection = sectionByFieldPath.get(field.path);
if (fieldSection && fieldSection !== q.section) {
issues.push({
kind: "question-section-mismatch",
where: q.id,
detail: `question is in section '${q.section}' but field '${field.path}' lives in section '${fieldSection}'`,
});
}
}
if (q.type === "single" && field.type === "enum") {
for (const opt of q.options) {
if (!field.values.includes(opt.value as string | number | boolean)) {
issues.push({
kind: "option-outside-enum",
where: q.id,
detail: `option value '${String(opt.value)}' is not in enum values of '${field.path}'`,
});
}
}
}
if (q.type === "multi" && field.type === "array" && field.values !== undefined) {
for (const opt of q.options) {
if (!field.values.includes(opt.value as string | number | boolean)) {
issues.push({
kind: "option-outside-enum",
where: q.id,
detail: `option value '${String(opt.value)}' is not in values allowlist of array field '${field.path}'`,
});
}
}
}
}
for (const [path, field] of fieldByPath) {
if (!field.required) continue;
if (coveredPaths.has(path)) continue;
if ("default" in field && field.default !== undefined) continue;
issues.push({
kind: "required-uncovered",
where: path,
detail: `required field '${path}' has no question and no default`,
});
}
return issues;
}