@scientific-method/standard-checker 0.1.0 → 0.3.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 +6 -0
- package/dist/checks/base.d.ts +7 -0
- package/dist/checks/base.js +72 -0
- package/dist/checks/claims.d.ts +4 -0
- package/dist/checks/claims.js +83 -0
- package/dist/checks/comment-addresses.d.ts +3 -0
- package/dist/checks/comment-addresses.js +111 -0
- package/dist/checks/cross-entry.d.ts +4 -0
- package/dist/checks/cross-entry.js +177 -0
- package/dist/checks/deviations.d.ts +12 -0
- package/dist/checks/deviations.js +101 -0
- package/dist/checks/entries.d.ts +7 -0
- package/dist/checks/entries.js +131 -0
- package/dist/checks/evidence-entries.d.ts +6 -0
- package/dist/checks/evidence-entries.js +167 -0
- package/dist/checks/fields.d.ts +4 -0
- package/dist/checks/fields.js +314 -0
- package/dist/checks/formats.d.ts +19 -0
- package/dist/checks/formats.js +221 -0
- package/dist/checks/kaitai.d.ts +6 -0
- package/dist/checks/kaitai.js +103 -0
- package/dist/checks/parity.d.ts +30 -0
- package/dist/checks/parity.js +189 -0
- package/dist/checks/references.d.ts +4 -0
- package/dist/checks/references.js +37 -0
- package/dist/checks/rules.d.ts +11 -0
- package/dist/checks/rules.js +263 -0
- package/dist/checks/screens.d.ts +4 -0
- package/dist/checks/screens.js +44 -0
- package/dist/checks/validation.d.ts +7 -0
- package/dist/checks/validation.js +116 -0
- package/dist/code-comments.d.ts +3 -0
- package/dist/code-comments.js +150 -0
- package/dist/code-files.d.ts +11 -0
- package/dist/code-files.js +41 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +2 -0
- package/dist/evidence.d.ts +37 -0
- package/dist/evidence.js +91 -0
- package/dist/files.d.ts +11 -0
- package/dist/files.js +35 -0
- package/dist/generate/indexes.d.ts +3 -0
- package/dist/generate/indexes.js +135 -0
- package/dist/generate/layout.d.ts +18 -0
- package/dist/generate/layout.js +49 -0
- package/dist/generate/parity-md.d.ts +7 -0
- package/dist/generate/parity-md.js +44 -0
- package/dist/generate/write.d.ts +8 -0
- package/dist/generate/write.js +75 -0
- package/dist/ids.d.ts +13 -0
- package/dist/ids.js +22 -0
- package/dist/load/builds.d.ts +9 -0
- package/dist/load/builds.js +124 -0
- package/dist/load/code-ranges.d.ts +4 -0
- package/dist/load/code-ranges.js +79 -0
- package/dist/load/entries.d.ts +7 -0
- package/dist/load/entries.js +57 -0
- package/dist/load/glossary.d.ts +12 -0
- package/dist/load/glossary.js +70 -0
- package/dist/load/readme.d.ts +3 -0
- package/dist/load/readme.js +36 -0
- package/dist/load/spec.d.ts +6 -0
- package/dist/load/spec.js +25 -0
- package/dist/locations.d.ts +19 -0
- package/dist/locations.js +57 -0
- package/dist/markdown.d.ts +27 -0
- package/dist/markdown.js +162 -0
- package/dist/options.d.ts +34 -0
- package/dist/options.js +87 -0
- package/dist/problems.d.ts +27 -0
- package/dist/problems.js +27 -0
- package/dist/standard-checker.js +53 -3033
- package/dist/standard.d.ts +65 -0
- package/dist/standard.js +173 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +2 -0
- package/dist/yaml.d.ts +6 -0
- package/dist/yaml.js +173 -0
- package/package.json +1 -1
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Rules: procedures against the glossary and the formats. Reads each rule's Procedure into
|
|
2
|
+
// Entry.code, then checks the names every live procedure declares, calls, reads and relies on.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { mayBeInterrupted, onlyEmulatedRuns } from "../evidence.js";
|
|
6
|
+
import { asList, idsIn, kindOf } from "../ids.js";
|
|
7
|
+
import { readCsv } from "../markdown.js";
|
|
8
|
+
import { KINDS, LIST_LIMIT } from "../standard.js";
|
|
9
|
+
/**
|
|
10
|
+
* A procedure with each string literal emptied and its comments dropped. Strings go first, so a `#`
|
|
11
|
+
* inside one does not start a comment, and a string ends on the line it starts on.
|
|
12
|
+
*/
|
|
13
|
+
export const withoutCommentsAndStrings = (code) => code.replace(/"[^"\n]*"/g, '""').replace(/#.*$/gm, "");
|
|
14
|
+
/** The names a rule's Parameters section declares: each lower-case name that opens a code span, as `n` or `n: type`. */
|
|
15
|
+
export const parameterNames = (params) => [...params.matchAll(/`([a-z_][a-z0-9_]*)(?=`|\s*:)/g)].map((m) => m[1]);
|
|
16
|
+
const BUILTINS = new Set([
|
|
17
|
+
"min",
|
|
18
|
+
"max",
|
|
19
|
+
"abs",
|
|
20
|
+
"count",
|
|
21
|
+
"append",
|
|
22
|
+
"insert",
|
|
23
|
+
"remove_at",
|
|
24
|
+
"copy",
|
|
25
|
+
"stable_sort",
|
|
26
|
+
"sprintf",
|
|
27
|
+
"floor",
|
|
28
|
+
"ceil",
|
|
29
|
+
"round_even",
|
|
30
|
+
"draw",
|
|
31
|
+
"resource",
|
|
32
|
+
"read_file",
|
|
33
|
+
"write_file",
|
|
34
|
+
"free",
|
|
35
|
+
"fmod",
|
|
36
|
+
"UINT8",
|
|
37
|
+
"INT8",
|
|
38
|
+
"UINT16",
|
|
39
|
+
"INT16",
|
|
40
|
+
"UINT32",
|
|
41
|
+
"INT32",
|
|
42
|
+
"UINT64",
|
|
43
|
+
"INT64",
|
|
44
|
+
"FLOAT32",
|
|
45
|
+
"FLOAT64",
|
|
46
|
+
"FLOAT80",
|
|
47
|
+
"REAL48",
|
|
48
|
+
]);
|
|
49
|
+
const KEYWORDS = new Set([
|
|
50
|
+
"for",
|
|
51
|
+
"each",
|
|
52
|
+
"in",
|
|
53
|
+
"if",
|
|
54
|
+
"else",
|
|
55
|
+
"while",
|
|
56
|
+
"break",
|
|
57
|
+
"continue",
|
|
58
|
+
"return",
|
|
59
|
+
"let",
|
|
60
|
+
"and",
|
|
61
|
+
"or",
|
|
62
|
+
"not",
|
|
63
|
+
"true",
|
|
64
|
+
"false",
|
|
65
|
+
"call",
|
|
66
|
+
"define",
|
|
67
|
+
"emit",
|
|
68
|
+
"drain",
|
|
69
|
+
"show",
|
|
70
|
+
"new",
|
|
71
|
+
"table",
|
|
72
|
+
"clock",
|
|
73
|
+
"from",
|
|
74
|
+
"Hz",
|
|
75
|
+
]);
|
|
76
|
+
/** Checks every rule's procedure. Sets Entry.code on every rule entry, superseded ones included. */
|
|
77
|
+
export function checkRules(ctx, { enumNames }) {
|
|
78
|
+
const { problem } = ctx;
|
|
79
|
+
const { entries, glossary, glossaryDir } = ctx.spec;
|
|
80
|
+
const defined = new Map(); // function/table/clock name -> rule IDs
|
|
81
|
+
// A superseded rule keeps its procedure for history, but its declarations own no active name.
|
|
82
|
+
const historical = new Map(); // name declared by superseded rules -> rule IDs
|
|
83
|
+
for (const [id, e] of entries) {
|
|
84
|
+
if (e.kind !== "RULE")
|
|
85
|
+
continue;
|
|
86
|
+
const proc = e.sections.find((s) => s.title === "Procedure")?.text ?? "";
|
|
87
|
+
e.code = [...proc.matchAll(/```text\n([\s\S]*?)```/g)].map((m) => m[1]).join("\n");
|
|
88
|
+
const owners = e.meta.status === "superseded" ? historical : defined;
|
|
89
|
+
for (const m of e.code.matchAll(/^\s*(?:define\s+([a-z_][a-z0-9_]*)\s*\(|table\s+([a-z_][a-z0-9_]*)\s*:|clock\s+([a-z_][a-z0-9_]*)\s*:)/gm)) {
|
|
90
|
+
const name = m[1] ?? m[2] ?? m[3];
|
|
91
|
+
if (!owners.has(name))
|
|
92
|
+
owners.set(name, []);
|
|
93
|
+
owners.get(name).push(id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// A live procedure cannot rely on a name only a superseded rule declares.
|
|
97
|
+
const onlyHistorical = (name) => !defined.has(name) && historical.has(name);
|
|
98
|
+
for (const [name, ids] of defined) {
|
|
99
|
+
const splitGroup = asList(entries.get(ids[0]).meta.split_with).concat(ids[0]);
|
|
100
|
+
if (ids.length > 1 && !ids.every((x) => splitGroup.includes(x)))
|
|
101
|
+
problem(null, `${name} is defined by more than one rule: ${ids.join(", ")}`);
|
|
102
|
+
if (!glossary.has(name))
|
|
103
|
+
problem(glossaryDir, `${name}, defined by ${ids[0]}, has no glossary entry`);
|
|
104
|
+
}
|
|
105
|
+
for (const [id, e] of entries) {
|
|
106
|
+
if (e.kind !== "RULE" || e.meta.status === "superseded")
|
|
107
|
+
continue;
|
|
108
|
+
const { file, meta } = e;
|
|
109
|
+
// Types in a define's signature (`define roll(n: UINT16) -> char[]:`) are not names the
|
|
110
|
+
// procedure reads, so they are dropped before the name checks.
|
|
111
|
+
const TYPE = String.raw `(?:[A-Za-z][A-Za-z0-9]*|FMT-[A-Z0-9]+-\d+)(?:\[[^\]]*\])?`;
|
|
112
|
+
const code = withoutCommentsAndStrings(e.code).replace(new RegExp(String.raw `(\bdefine\s+[a-z_][a-z0-9_]*\s*\()([^)]*)\)(\s*->\s*${TYPE})?`, "g"), (_, head, params) => `${head}${params
|
|
113
|
+
.split(",")
|
|
114
|
+
.map((p) => p.split(":")[0].trim())
|
|
115
|
+
.join(", ")})`);
|
|
116
|
+
const related = asList(meta.related);
|
|
117
|
+
const openQuestions = e.sections.find((s) => s.title === "Open questions")?.text ?? "";
|
|
118
|
+
// Names are letters, digits and underscores, so best_score does not count as listing score.
|
|
119
|
+
const listsOpen = (name) => new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`).test(openQuestions);
|
|
120
|
+
if (meta.status !== "unknown" && e.code.trim() === "")
|
|
121
|
+
problem(file, "Procedure has no ```text block");
|
|
122
|
+
const usedTerms = new Set();
|
|
123
|
+
const useTerm = (name) => {
|
|
124
|
+
if (glossary.has(name))
|
|
125
|
+
usedTerms.add(name);
|
|
126
|
+
return glossary.has(name);
|
|
127
|
+
};
|
|
128
|
+
// Lists written out hold at most LIST_LIMIT values; a table of more takes them from a value file.
|
|
129
|
+
for (const m of code.matchAll(/=\s*\[([^\]]*)\]/g)) {
|
|
130
|
+
const count = m[1].split(",").filter((x) => x.trim() !== "").length;
|
|
131
|
+
if (count > LIST_LIMIT)
|
|
132
|
+
problem(file, `writes out a list of ${count} values; a list of more than ${LIST_LIMIT} is a table with a value file`);
|
|
133
|
+
}
|
|
134
|
+
for (const m of e.code.matchAll(/^\s*table\s+([a-z_][a-z0-9_]*)\s*:\s*[A-Za-z0-9]+\[([^\]]*)\]\s*from\s*"([^"]*)"/gm)) {
|
|
135
|
+
const [, name, count, csvName] = m;
|
|
136
|
+
const expected = `${id}.${name}.csv`;
|
|
137
|
+
if (csvName !== expected) {
|
|
138
|
+
problem(file, `table ${name} takes its values from ${expected}, not ${csvName}`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const path = join(dirname(file), csvName);
|
|
142
|
+
if (!existsSync(path)) {
|
|
143
|
+
problem(file, `value file ${csvName} does not exist`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const csv = readCsv(path, problem);
|
|
147
|
+
if (!csv)
|
|
148
|
+
continue;
|
|
149
|
+
if (csv.header.join("|") !== "value")
|
|
150
|
+
problem(path, "a table's value file has the single column value");
|
|
151
|
+
if (/^\d+$/.test(count.trim()) && csv.rows.length !== Number(count))
|
|
152
|
+
problem(path, `has ${csv.rows.length} values, but table ${name} has ${count}`);
|
|
153
|
+
for (const [v] of csv.rows)
|
|
154
|
+
if (!/^-?(?:0x[0-9A-Fa-f]+|\d+(?:\.\d+)?)$/.test(v.trim())) {
|
|
155
|
+
problem(path, `${v} is not a number`);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const m of code.matchAll(/\bcall\s+(RULE-[A-Z0-9]+-\d+)/g))
|
|
160
|
+
if (!related.includes(m[1]))
|
|
161
|
+
problem(file, `calls ${m[1]}; add it to related`, "ENTRY-TYPES-6");
|
|
162
|
+
for (const m of code.matchAll(/\bshow\s+(SCR-[A-Z0-9]+-\d+)/g))
|
|
163
|
+
if (!related.includes(m[1]))
|
|
164
|
+
problem(file, `shows ${m[1]}; add it to related`, "ENTRY-TYPES-6");
|
|
165
|
+
for (const m of code.matchAll(/\b(FMT-[A-Z0-9]+-\d+)/g))
|
|
166
|
+
if (!related.includes(m[1]))
|
|
167
|
+
problem(file, `uses ${m[1]}; add it to related`, "ENTRY-TYPES-6");
|
|
168
|
+
for (const m of e.code.matchAll(/# may run: (RULE-[A-Z0-9]+-\d+)/g))
|
|
169
|
+
if (!related.includes(m[1]))
|
|
170
|
+
problem(file, `may be interrupted by ${m[1]}; add it to related`, "ENTRY-TYPES-6");
|
|
171
|
+
if (meta.status === "established" && mayBeInterrupted(e) && onlyEmulatedRuns(entries, e))
|
|
172
|
+
problem(file, "another rule may interrupt this procedure (# may run:), so emulated calls alone cannot establish it", "STATUS-15");
|
|
173
|
+
if (asList(meta.complete_reading).length > 0 && /# may run: RULE-/.test(e.code))
|
|
174
|
+
problem(file, "another rule may interrupt this procedure (# may run:), so a complete reading cannot establish it; leave complete_reading empty", "STATUS-4");
|
|
175
|
+
for (const x of idsIn(code))
|
|
176
|
+
if (!entries.has(x))
|
|
177
|
+
problem(file, `procedure names ${x}, which does not exist`);
|
|
178
|
+
for (const m of code.matchAll(/\bemit\s+([A-Za-z_][A-Za-z0-9_]*)/g))
|
|
179
|
+
if (!useTerm(m[1]))
|
|
180
|
+
problem(file, `emits ${m[1]}, which has no glossary entry`);
|
|
181
|
+
for (const m of code.matchAll(/\bdrain\s+([A-Za-z_][A-Za-z0-9_]*)/g))
|
|
182
|
+
if (!useTerm(m[1]))
|
|
183
|
+
problem(file, `drains ${m[1]}, which has no glossary entry`);
|
|
184
|
+
for (const m of code.matchAll(/\b((?:fn|g|scr)_[A-Za-z0-9_]+)\b/g)) {
|
|
185
|
+
if (!useTerm(m[1]))
|
|
186
|
+
problem(file, `uses the neutral name ${m[1]}, which has no glossary entry`);
|
|
187
|
+
if (!listsOpen(m[1]))
|
|
188
|
+
problem(file, `uses the neutral name ${m[1]}; list it in Open questions`);
|
|
189
|
+
}
|
|
190
|
+
const noNeutral = code.replace(/\b(?:fn|g|scr)_[A-Za-z0-9_]+\b/g, "");
|
|
191
|
+
if (/\b0x[0-9A-Fa-f]{6,}\b/.test(noNeutral) && /\b0x00[4-9A-F][0-9A-F]{5}\b/.test(noNeutral))
|
|
192
|
+
problem(file, "the procedure contains what looks like an address outside a neutral name");
|
|
193
|
+
// Functions called without `call`
|
|
194
|
+
const locals = new Set();
|
|
195
|
+
for (const m of code.matchAll(/\blet\s+([a-z_][a-z0-9_]*)/g))
|
|
196
|
+
locals.add(m[1]);
|
|
197
|
+
for (const m of code.matchAll(/\bfor\s+(?:each\s+)?([a-z_][a-z0-9_]*)\s+in\b/g))
|
|
198
|
+
locals.add(m[1]);
|
|
199
|
+
for (const m of code.matchAll(/\bdefine\s+[a-z_][a-z0-9_]*\s*\(([^)]*)\)/g))
|
|
200
|
+
for (const p of m[1].split(","))
|
|
201
|
+
locals.add(p.split(":")[0].trim());
|
|
202
|
+
const params = e.sections.find((s) => s.title === "Parameters")?.text ?? "";
|
|
203
|
+
for (const name of parameterNames(params))
|
|
204
|
+
locals.add(name);
|
|
205
|
+
for (const m of code.matchAll(/(?<![.\w])([a-z_][a-z0-9_]*)\s*\(/g)) {
|
|
206
|
+
const name = m[1];
|
|
207
|
+
if (BUILTINS.has(name) || KEYWORDS.has(name) || locals.has(name))
|
|
208
|
+
continue;
|
|
209
|
+
if (onlyHistorical(name)) {
|
|
210
|
+
problem(file, `calls ${name}(), which only superseded ${historical.get(name).join(", ")} defines`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!defined.has(name) && !useTerm(name)) {
|
|
214
|
+
problem(file, `calls ${name}(), which no rule defines and the glossary does not list`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
for (const owner of defined.get(name) ?? [])
|
|
218
|
+
if (owner !== id && !related.includes(owner))
|
|
219
|
+
problem(file, `uses ${name} from ${owner}; add ${owner} to related`, "ENTRY-TYPES-6");
|
|
220
|
+
}
|
|
221
|
+
for (const m of code.matchAll(/(?<![.\w])([A-Z][A-Z0-9_]*[A-Z0-9])(?![\w-])/g)) {
|
|
222
|
+
const name = m[1];
|
|
223
|
+
if (BUILTINS.has(name) || KINDS[name] || /^(?:FMT|RULE|SCR)$/.test(name))
|
|
224
|
+
continue;
|
|
225
|
+
if (!enumNames.has(name)) {
|
|
226
|
+
problem(file, `upper-case name ${name} is not an enumeration name of any format`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
for (const fmt of enumNames.get(name))
|
|
230
|
+
if (!related.includes(fmt))
|
|
231
|
+
problem(file, `uses ${name} from ${fmt}; add it to related`, "ENTRY-TYPES-6");
|
|
232
|
+
}
|
|
233
|
+
// Names read or assigned without let that are neither locals nor glossary terms
|
|
234
|
+
for (const m of code.matchAll(/(?<![.\w])([a-z_][a-z0-9_]*)(?=\s*(?:\.|\[|=[^=]|$))/gm)) {
|
|
235
|
+
const name = m[1];
|
|
236
|
+
if (locals.has(name) || KEYWORDS.has(name) || BUILTINS.has(name) || defined.has(name))
|
|
237
|
+
continue;
|
|
238
|
+
if (onlyHistorical(name)) {
|
|
239
|
+
problem(file, `${name} is declared only by superseded ${historical.get(name).join(", ")}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!useTerm(name))
|
|
243
|
+
problem(file, `${name} is neither a local nor a glossary term`);
|
|
244
|
+
}
|
|
245
|
+
// A glossary claim a procedure relies on counts toward the rule's status: its evidence is the
|
|
246
|
+
// rule's evidence, and while it is (unknown) the rule lists it as an open question and is not
|
|
247
|
+
// established.
|
|
248
|
+
for (const term of usedTerms) {
|
|
249
|
+
const text = glossary.get(term);
|
|
250
|
+
for (const b of text.matchAll(/\[([^\]]*)\]/g))
|
|
251
|
+
for (const x of idsIn(b[1])) {
|
|
252
|
+
if (["FND", "EXP"].includes(kindOf(x)) && !asList(meta.evidence).includes(x))
|
|
253
|
+
problem(file, `relies on ${term}, whose glossary entry cites ${x}; add it to evidence`);
|
|
254
|
+
}
|
|
255
|
+
if (text.includes("(unknown)")) {
|
|
256
|
+
if (!listsOpen(term))
|
|
257
|
+
problem(file, `relies on ${term}, a glossary claim that is (unknown); list it in Open questions`);
|
|
258
|
+
if (meta.status === "established")
|
|
259
|
+
problem(file, `relies on ${term}, a glossary claim that is (unknown), so it cannot be established`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The checks of a screen entry: its resolution, the tables of its sections, and the evidence and
|
|
2
|
+
// related entries those tables cite.
|
|
3
|
+
import { checkResolves } from "../evidence.js";
|
|
4
|
+
import { asList, idsIn, kindOf } from "../ids.js";
|
|
5
|
+
import { tables } from "../markdown.js";
|
|
6
|
+
import { SCREEN_TABLES } from "../standard.js";
|
|
7
|
+
import { tableIds } from "./formats.js";
|
|
8
|
+
/** Checks a screen entry. */
|
|
9
|
+
export function checkScreen(ctx, e) {
|
|
10
|
+
const { problem } = ctx;
|
|
11
|
+
const { file, meta } = e;
|
|
12
|
+
if (!/^\d+x\d+$/.test(String(meta.resolution)))
|
|
13
|
+
problem(file, "resolution must be written WIDTHxHEIGHT");
|
|
14
|
+
for (const s of e.sections) {
|
|
15
|
+
const want = SCREEN_TABLES[s.title];
|
|
16
|
+
if (!want)
|
|
17
|
+
continue;
|
|
18
|
+
const ts = tables(s.text);
|
|
19
|
+
if (ts.length === 0 && !/^\s*None( known)?\.\s*$/.test(s.text))
|
|
20
|
+
problem(file, `${s.title} has neither a table nor None known.`);
|
|
21
|
+
for (const t of ts)
|
|
22
|
+
if (t.header.join("|") !== want.join("|"))
|
|
23
|
+
problem(file, `${s.title} table has the columns ${want.join(" | ")}`);
|
|
24
|
+
}
|
|
25
|
+
const cited = tableIds(e, Object.keys(SCREEN_TABLES));
|
|
26
|
+
const listed = new Set([...asList(meta.evidence), ...asList(meta.conflicting)]);
|
|
27
|
+
for (const x of cited)
|
|
28
|
+
if (!listed.has(x) && ["FND", "EXP", "SRC"].includes(kindOf(x)))
|
|
29
|
+
problem(file, `${x} is cited in a table but not in evidence or conflicting`);
|
|
30
|
+
for (const s of e.sections)
|
|
31
|
+
for (const t of tables(s.text)) {
|
|
32
|
+
for (const col of ["Effect", "Shows"]) {
|
|
33
|
+
const c = t.header.indexOf(col);
|
|
34
|
+
if (c < 0)
|
|
35
|
+
continue;
|
|
36
|
+
for (const row of t.rows)
|
|
37
|
+
for (const x of idsIn(row[c]))
|
|
38
|
+
if (["RULE", "SCR"].includes(kindOf(x)) && !asList(meta.related).includes(x))
|
|
39
|
+
problem(file, `${col} cell names ${x}; add it to related`, "ENTRY-TYPES-6");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const x of cited)
|
|
43
|
+
checkResolves(ctx, file, [x], "a table");
|
|
44
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import type { Parity } from "./parity.ts";
|
|
3
|
+
/**
|
|
4
|
+
* With --record-validation, writes VALIDATION.md (or prints why it cannot and exits with 2). Then
|
|
5
|
+
* checks VALIDATION.md against the marked test files of the validated rows.
|
|
6
|
+
*/
|
|
7
|
+
export declare function checkValidation(ctx: Context, { validatedTests }: Parity): void;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// VALIDATION.md records the marked test files of the validated rows as they were when a maintainer ran
|
|
2
|
+
// them against the original's files, which CI never holds. A file is hashed with CRLF read as LF,
|
|
3
|
+
// so a Windows checkout and a Linux one give the same hash.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { readText, tables } from "../markdown.js";
|
|
9
|
+
const VALIDATION_HEADER = ["Test file", "SHA-256"];
|
|
10
|
+
const testHash = (p) => createHash("sha256")
|
|
11
|
+
.update(Buffer.from(readFileSync(p).toString("latin1").replaceAll("\r\n", "\n"), "latin1"))
|
|
12
|
+
.digest("hex");
|
|
13
|
+
/**
|
|
14
|
+
* With --record-validation, writes VALIDATION.md (or prints why it cannot and exits with 2). Then
|
|
15
|
+
* checks VALIDATION.md against the marked test files of the validated rows.
|
|
16
|
+
*/
|
|
17
|
+
export function checkValidation(ctx, { validatedTests }) {
|
|
18
|
+
const { problem } = ctx;
|
|
19
|
+
const { entries } = ctx.spec;
|
|
20
|
+
const { repoDir } = ctx.config;
|
|
21
|
+
const validationPath = join(repoDir, "VALIDATION.md");
|
|
22
|
+
if (ctx.config.recordValidation !== undefined) {
|
|
23
|
+
const builds = ctx.config.recordValidation;
|
|
24
|
+
if (!builds.length) {
|
|
25
|
+
console.error("--record-validation needs at least one build ID");
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
for (const b of builds)
|
|
29
|
+
if (entries.get(b)?.kind !== "BLD") {
|
|
30
|
+
console.error(`--record-validation: ${b} is not a build entry`);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
let commit;
|
|
34
|
+
try {
|
|
35
|
+
commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8" }).trim();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
console.error("--record-validation: git rev-parse HEAD failed");
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
const files = [...validatedTests.keys()].sort();
|
|
42
|
+
if (!files.length) {
|
|
43
|
+
console.error('--record-validation: no validated row lists a test file with a "needs: GAME_DIR" comment, so there is nothing to record');
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
writeFileSync(validationPath, [
|
|
47
|
+
"# Validation record",
|
|
48
|
+
"",
|
|
49
|
+
"The test files of the validated parity rows that read the original's files, as they were when every test in them passed against those files.",
|
|
50
|
+
"",
|
|
51
|
+
`- Commit: ${commit}`,
|
|
52
|
+
`- Date: ${new Date().toISOString().slice(0, 10)}`,
|
|
53
|
+
`- Builds: ${builds.join(", ")}`,
|
|
54
|
+
"",
|
|
55
|
+
`| ${VALIDATION_HEADER.join(" | ")} |`,
|
|
56
|
+
`|${"---|".repeat(VALIDATION_HEADER.length)}`,
|
|
57
|
+
...files.map((f) => `| \`${f}\` | \`${testHash(join(repoDir, f))}\` |`),
|
|
58
|
+
"",
|
|
59
|
+
].join("\n"));
|
|
60
|
+
console.log("wrote VALIDATION.md");
|
|
61
|
+
}
|
|
62
|
+
{
|
|
63
|
+
const recorded = new Map(); // test file -> hash
|
|
64
|
+
if (existsSync(validationPath)) {
|
|
65
|
+
const text = readText(validationPath);
|
|
66
|
+
const item = (key, pattern) => {
|
|
67
|
+
const m = text.match(new RegExp(`^- ${key}: (.*)$`, "m"));
|
|
68
|
+
if (!m || !pattern.test(m[1].trim())) {
|
|
69
|
+
problem(validationPath, `needs a "- ${key}:" item in the form the standard gives`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return m[1].trim();
|
|
73
|
+
};
|
|
74
|
+
item("Commit", /^[0-9a-f]{40}$/);
|
|
75
|
+
item("Date", /^\d{4}-\d{2}-\d{2}$/);
|
|
76
|
+
const builds = item("Builds", /^\S.*$/);
|
|
77
|
+
if (builds !== null)
|
|
78
|
+
for (const b of builds.split(",").map((x) => x.trim()))
|
|
79
|
+
if (entries.get(b)?.kind !== "BLD")
|
|
80
|
+
problem(validationPath, `Builds names ${b}, which is not a build entry`);
|
|
81
|
+
const ts = tables(text);
|
|
82
|
+
if (ts.length !== 1 || ts[0].header.join("|") !== VALIDATION_HEADER.join("|"))
|
|
83
|
+
problem(validationPath, `holds one table with the columns ${VALIDATION_HEADER.join(" | ")}`);
|
|
84
|
+
else {
|
|
85
|
+
let previous = "";
|
|
86
|
+
for (const row of ts[0].rows) {
|
|
87
|
+
const [path, hash] = row.map((c) => c.replaceAll("`", "").trim());
|
|
88
|
+
if (row.length !== VALIDATION_HEADER.length || !/^[0-9a-f]{64}$/.test(hash ?? "")) {
|
|
89
|
+
problem(validationPath, `the row ${row.join(" | ")} needs a test file and its SHA-256 in lowercase hex`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (recorded.has(path))
|
|
93
|
+
problem(validationPath, `${path} is listed twice`);
|
|
94
|
+
if (path < previous)
|
|
95
|
+
problem(validationPath, `${path} is out of order; the files are sorted by path`);
|
|
96
|
+
previous = path;
|
|
97
|
+
recorded.set(path, hash);
|
|
98
|
+
if (!validatedTests.has(path))
|
|
99
|
+
problem(validationPath, `${path} is not a test file with a "needs: GAME_DIR" comment in a validated row's Tests; run the check with --record-validation again`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const [tf, rows] of validatedTests) {
|
|
104
|
+
const p = join(repoDir, tf);
|
|
105
|
+
if (!existsSync(p))
|
|
106
|
+
continue;
|
|
107
|
+
const hash = recorded.get(tf);
|
|
108
|
+
for (const { specId, file } of rows) {
|
|
109
|
+
if (hash === undefined)
|
|
110
|
+
problem(file, `${specId}: ${tf} is not in VALIDATION.md, so the row cannot be validated until its tests pass against the original's files and are recorded`);
|
|
111
|
+
else if (hash !== testHash(p))
|
|
112
|
+
problem(file, `${specId}: ${tf} has changed since VALIDATION.md recorded it; run its tests against the original's files and record them again`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Reading the comments of C#, TypeScript and JavaScript source.
|
|
2
|
+
// The comments of a C-family source file (C#, TypeScript, JavaScript), line by line: for each
|
|
3
|
+
// line, whether it holds code and the comment text on it with the column where each piece starts.
|
|
4
|
+
// String and character literals are skipped (regular, verbatim, interpolated and raw in C#;
|
|
5
|
+
// template literals in JavaScript), so a `//` inside one starts no comment. An interpolation hole is
|
|
6
|
+
// read as part of its string, which is enough to find comments. A JavaScript regular expression
|
|
7
|
+
// literal is skipped too, so a quote or a `/*` inside one starts nothing; a `/` is read as one
|
|
8
|
+
// where a value can begin. The lines of a `/* … */` after its first are marked continued.
|
|
9
|
+
/** Reads the comments of a C-family source file line by line, as the comment above describes. */
|
|
10
|
+
export function codeComments(source, javascript) {
|
|
11
|
+
const text = source.replace(/\r\n?/g, "\n");
|
|
12
|
+
const lines = [{ code: false, comments: [] }];
|
|
13
|
+
const line = () => lines[lines.length - 1];
|
|
14
|
+
let i = 0, column = 0;
|
|
15
|
+
const advance = (to) => {
|
|
16
|
+
for (; i < to; i++) {
|
|
17
|
+
if (text[i] === "\n") {
|
|
18
|
+
lines.push({ code: false, comments: [] });
|
|
19
|
+
column = 0;
|
|
20
|
+
}
|
|
21
|
+
else
|
|
22
|
+
column++;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const addComment = (from, to, col, continued = false) => line().comments.push({ column: col, text: text.slice(from, to), continued });
|
|
26
|
+
let lastCode = -1; // the index of the last character read as code
|
|
27
|
+
// A JavaScript `/` starts a regular expression unless it follows a value, where it divides.
|
|
28
|
+
const regexMayStart = () => {
|
|
29
|
+
const before = text.slice(Math.max(0, lastCode - 11), lastCode + 1);
|
|
30
|
+
return (lastCode < 0 ||
|
|
31
|
+
!/[\w$)\]}]$/.test(before) ||
|
|
32
|
+
/(?<![\w$])(?:return|typeof|case|do|else|in|of|new|delete|void|throw|instanceof|yield|await)$/.test(before));
|
|
33
|
+
};
|
|
34
|
+
// Past the closing `/` of the regular expression literal at i, if it closes on its line.
|
|
35
|
+
const regexEnd = () => {
|
|
36
|
+
let inClass = false;
|
|
37
|
+
for (let j = i + 1; j < text.length && text[j] !== "\n"; j++) {
|
|
38
|
+
if (text[j] === "\\") {
|
|
39
|
+
if (text[j + 1] === "\n")
|
|
40
|
+
return -1;
|
|
41
|
+
j++;
|
|
42
|
+
}
|
|
43
|
+
else if (text[j] === "[")
|
|
44
|
+
inClass = true;
|
|
45
|
+
else if (text[j] === "]")
|
|
46
|
+
inClass = false;
|
|
47
|
+
else if (text[j] === "/" && !inClass)
|
|
48
|
+
return j + 1;
|
|
49
|
+
}
|
|
50
|
+
return -1;
|
|
51
|
+
};
|
|
52
|
+
// i is past the opening quote(s); stops past the closing one(s). A regular string ends at the
|
|
53
|
+
// line when it is not closed.
|
|
54
|
+
const skipString = (end, escapes, multiline) => {
|
|
55
|
+
while (i < text.length) {
|
|
56
|
+
if (escapes && text[i] === "\\") {
|
|
57
|
+
advance(i + 2);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (text.startsWith(end, i)) {
|
|
61
|
+
if (!escapes && end === '"' && text[i + 1] === '"') {
|
|
62
|
+
advance(i + 2);
|
|
63
|
+
continue;
|
|
64
|
+
} // "" in a verbatim string
|
|
65
|
+
advance(i + end.length);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (text[i] === "\n" && !multiline) {
|
|
69
|
+
advance(i + 1);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
advance(i + 1);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
while (i < text.length) {
|
|
76
|
+
const c = text[i];
|
|
77
|
+
if (c === "\n" || c === " " || c === "\t") {
|
|
78
|
+
advance(i + 1);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (text.startsWith("//", i)) {
|
|
82
|
+
const nl = text.indexOf("\n", i);
|
|
83
|
+
const end = nl < 0 ? text.length : nl;
|
|
84
|
+
addComment(i, end, column);
|
|
85
|
+
advance(end);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (text.startsWith("/*", i)) {
|
|
89
|
+
const close = text.indexOf("*/", i + 2);
|
|
90
|
+
const end = close < 0 ? text.length : close + 2;
|
|
91
|
+
let from = i, col = column, continued = false;
|
|
92
|
+
while (i < end) {
|
|
93
|
+
const nl = text.indexOf("\n", i);
|
|
94
|
+
if (nl < 0 || nl >= end) {
|
|
95
|
+
addComment(from, end, col, continued);
|
|
96
|
+
advance(end);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
addComment(from, nl, col, continued);
|
|
100
|
+
advance(nl + 1);
|
|
101
|
+
continued = true;
|
|
102
|
+
while (i < end && (text[i] === " " || text[i] === "\t"))
|
|
103
|
+
advance(i + 1);
|
|
104
|
+
from = i;
|
|
105
|
+
col = column;
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
line().code = true;
|
|
110
|
+
if (javascript) {
|
|
111
|
+
const regex = c === "/" && regexMayStart() ? regexEnd() : -1;
|
|
112
|
+
if (regex >= 0)
|
|
113
|
+
advance(regex);
|
|
114
|
+
else if (c === '"' || c === "'") {
|
|
115
|
+
advance(i + 1);
|
|
116
|
+
skipString(c, true, false);
|
|
117
|
+
}
|
|
118
|
+
else if (c === "`") {
|
|
119
|
+
advance(i + 1);
|
|
120
|
+
skipString("`", true, true);
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
advance(i + 1);
|
|
124
|
+
lastCode = i - 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const prefix = /^(?:\$+@?|@\$*)?(?=")/.exec(text.slice(i, i + 4))?.[0] ?? null;
|
|
128
|
+
if (prefix !== null) {
|
|
129
|
+
advance(i + prefix.length);
|
|
130
|
+
const quotes = /^"{3,}/.exec(text.slice(i, i + 64))?.[0];
|
|
131
|
+
if (quotes) {
|
|
132
|
+
advance(i + quotes.length);
|
|
133
|
+
skipString(quotes, false, true);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const verbatim = prefix.includes("@");
|
|
137
|
+
advance(i + 1);
|
|
138
|
+
skipString('"', !verbatim, verbatim);
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (c === "'") {
|
|
143
|
+
advance(i + 1);
|
|
144
|
+
skipString("'", true, false);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
advance(i + 1);
|
|
148
|
+
}
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Config } from "./options.ts";
|
|
2
|
+
import type { CodeFile } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Returns a function that gives the files of the --code and --references directories, read on its
|
|
5
|
+
* first call. The checker's own source is left out, so a copy of the checker inside one of those
|
|
6
|
+
* directories does not flag the IDs its comments and messages give as examples. checkerDir is the
|
|
7
|
+
* directory that holds the checker's entry point and its modules, and nothing else.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createCodeFiles(config: Config, checkerDir: string): () => CodeFile[];
|
|
10
|
+
/** The spec IDs that PLACEHOLDER comments in the --code directories cite. */
|
|
11
|
+
export declare function collectPlaceholders(codeFiles: CodeFile[]): Set<string>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The --code and --references directories, walked and read once for the placeholder, the
|
|
2
|
+
// implementation-reference and the code comment checks.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join, sep } from "node:path";
|
|
5
|
+
import { walk } from "./files.js";
|
|
6
|
+
/**
|
|
7
|
+
* Returns a function that gives the files of the --code and --references directories, read on its
|
|
8
|
+
* first call. The checker's own source is left out, so a copy of the checker inside one of those
|
|
9
|
+
* directories does not flag the IDs its comments and messages give as examples. checkerDir is the
|
|
10
|
+
* directory that holds the checker's entry point and its modules, and nothing else.
|
|
11
|
+
*/
|
|
12
|
+
export function createCodeFiles(config, checkerDir) {
|
|
13
|
+
let cache;
|
|
14
|
+
const own = (f) => f.startsWith(checkerDir + sep);
|
|
15
|
+
return () => {
|
|
16
|
+
if (cache)
|
|
17
|
+
return cache;
|
|
18
|
+
const files = [];
|
|
19
|
+
const roots = [
|
|
20
|
+
...config.codeRoots.map((root) => ({ root, code: true })),
|
|
21
|
+
...config.referenceRoots.map((root) => ({ root, code: false })),
|
|
22
|
+
];
|
|
23
|
+
for (const { root, code } of roots)
|
|
24
|
+
walk(join(config.repoDir, root), (f) => {
|
|
25
|
+
if (!own(f) && /\.(cs|ts|mjs|js|ps1|fs|md|json)$/.test(f))
|
|
26
|
+
files.push({ code, file: f, text: readFileSync(f, "utf8") });
|
|
27
|
+
});
|
|
28
|
+
return (cache = files);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** The spec IDs that PLACEHOLDER comments in the --code directories cite. */
|
|
32
|
+
export function collectPlaceholders(codeFiles) {
|
|
33
|
+
const found = new Set();
|
|
34
|
+
for (const { code, file, text } of codeFiles) {
|
|
35
|
+
if (!code || !/\.(cs|ts|mjs|js|ps1|fs)$/.test(file))
|
|
36
|
+
continue;
|
|
37
|
+
for (const m of text.matchAll(/PLACEHOLDER:\s*((?:FMT|RULE|SCR)-[A-Z0-9]+-\d+)/g))
|
|
38
|
+
found.add(m[1]);
|
|
39
|
+
}
|
|
40
|
+
return found;
|
|
41
|
+
}
|