@scientific-method/standard-checker 0.1.0 → 0.2.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 +130 -0
- package/dist/checks/evidence-entries.d.ts +6 -0
- package/dist/checks/evidence-entries.js +167 -0
- package/dist/checks/formats.d.ts +13 -0
- package/dist/checks/formats.js +194 -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 +4 -0
- package/dist/checks/rules.js +259 -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 +51 -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,189 @@
|
|
|
1
|
+
// The parity matrix. The parity rows live in parity/, one file per area, split by kind and then by
|
|
2
|
+
// block where the limit requires it. PARITY.md holds the totals and is written by the check.
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { collectPlaceholders } from "../code-files.js";
|
|
6
|
+
import { mayBeInterrupted, onlyEmulatedRuns } from "../evidence.js";
|
|
7
|
+
import { markdownTree, walk } from "../files.js";
|
|
8
|
+
import { areaOf, compareIds, kindOf } from "../ids.js";
|
|
9
|
+
import { readText, tables } from "../markdown.js";
|
|
10
|
+
import { LINE_LIMIT } from "../standard.js";
|
|
11
|
+
import { blockOf, layout } from "../generate/layout.js";
|
|
12
|
+
/** The columns of a parity table. */
|
|
13
|
+
export const PARITY_HEADER = ["Spec ID", "Title", "Spec status", "Code", "Tests", "Deviations", "Status", "Notes"];
|
|
14
|
+
/**
|
|
15
|
+
* Checks the rows in parity/ against the spec, the deviations and the code, and that each area is
|
|
16
|
+
* split exactly where the line limit requires. Then reports a live deviation that departs from no
|
|
17
|
+
* entry with a row.
|
|
18
|
+
*/
|
|
19
|
+
export function checkParity(ctx, deviations) {
|
|
20
|
+
const { problem } = ctx;
|
|
21
|
+
const { entries, areas } = ctx.spec;
|
|
22
|
+
const { repoDir } = ctx.config;
|
|
23
|
+
const parityDir = join(repoDir, "parity");
|
|
24
|
+
const parityRows = new Map(); // spec ID -> { cells, file }
|
|
25
|
+
const parityCounts = { status: {}, code: {} };
|
|
26
|
+
const validatedTests = new Map(); // marked test file of a validated row -> [{ specId, file }]
|
|
27
|
+
// A test file that reads the original's files through GAME_DIR says so with this comment. It runs
|
|
28
|
+
// only on a maintainer's machine, so its validated rows need it in VALIDATION.md; every other test
|
|
29
|
+
// runs in CI.
|
|
30
|
+
const NEEDS_GAME = /needs:\s*GAME_DIR/;
|
|
31
|
+
const needsGame = (p) => existsSync(p) && NEEDS_GAME.test(readFileSync(p, "utf8"));
|
|
32
|
+
// A PARITY.md that still holds the rows is left alone until they have moved, so the check does not
|
|
33
|
+
// overwrite them with the totals.
|
|
34
|
+
const legacyParity = existsSync(join(repoDir, "PARITY.md")) &&
|
|
35
|
+
tables(readText(join(repoDir, "PARITY.md"))).some((t) => t.header.join("|") === PARITY_HEADER.join("|"));
|
|
36
|
+
{
|
|
37
|
+
if (!existsSync(parityDir))
|
|
38
|
+
problem(null, "parity/ is missing");
|
|
39
|
+
if (legacyParity)
|
|
40
|
+
problem(join(repoDir, "PARITY.md"), "the rows move to parity/, one <AREA>.md per area, and the check writes PARITY.md");
|
|
41
|
+
const placeholders = collectPlaceholders(ctx.codeFiles());
|
|
42
|
+
const files = existsSync(parityDir) ? markdownTree(parityDir) : new Map();
|
|
43
|
+
walk(parityDir, (f) => {
|
|
44
|
+
if (!f.endsWith(".md") && basename(f) !== ".gitkeep")
|
|
45
|
+
problem(f, "is not a parity file; parity/ holds one <AREA>.md per area");
|
|
46
|
+
});
|
|
47
|
+
const byArea = new Map(); // area -> Map of path -> ids in the file
|
|
48
|
+
for (const [path, file] of files) {
|
|
49
|
+
const text = readText(file);
|
|
50
|
+
if (!text.startsWith(`# ${path}\n`))
|
|
51
|
+
problem(file, `opens with its path as a # heading: # ${path}`);
|
|
52
|
+
const [area, kind, block, ...rest] = path.split("/");
|
|
53
|
+
if (!areas.includes(area)) {
|
|
54
|
+
problem(file, `${area} is not an area in the area list`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (rest.length > 0 || (kind !== undefined && !["RULE", "FMT", "SCR"].includes(kind))) {
|
|
58
|
+
problem(file, "is not an area, kind or block file of parity/");
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const ts = tables(text);
|
|
62
|
+
if (ts.length !== 1 || ts[0].header.join("|") !== PARITY_HEADER.join("|")) {
|
|
63
|
+
problem(file, `holds one table with the columns ${PARITY_HEADER.join(" | ")}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!byArea.has(area))
|
|
67
|
+
byArea.set(area, new Map());
|
|
68
|
+
const inFile = [];
|
|
69
|
+
byArea.get(area).set(path, inFile);
|
|
70
|
+
let previous = "";
|
|
71
|
+
for (const row of ts[0].rows) {
|
|
72
|
+
if (row.length !== PARITY_HEADER.length) {
|
|
73
|
+
problem(file, `the row ${row.join(" | ")} has ${row.length} cells, not ${PARITY_HEADER.length}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const cells = row.map((c) => c.replaceAll("`", "").trim());
|
|
77
|
+
const [specId, title, specStatus, code, tests, devs, status, notes] = cells;
|
|
78
|
+
if (parityRows.has(specId))
|
|
79
|
+
problem(file, `${specId} has more than one row`);
|
|
80
|
+
parityRows.set(specId, { cells: row, file });
|
|
81
|
+
inFile.push(specId);
|
|
82
|
+
if (areaOf(specId) !== area || (kind && kindOf(specId) !== kind) || (block && blockOf(specId) !== block))
|
|
83
|
+
problem(file, `${specId} does not belong in parity/${path}.md`);
|
|
84
|
+
if (compareIds(specId, previous) < 0)
|
|
85
|
+
problem(file, `${specId} is out of ID order`);
|
|
86
|
+
previous = specId;
|
|
87
|
+
const e = entries.get(specId);
|
|
88
|
+
if (!e) {
|
|
89
|
+
problem(file, `${specId} does not exist in the spec`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!["RULE", "FMT", "SCR"].includes(e.kind) || e.meta.status === "superseded")
|
|
93
|
+
problem(file, `${specId} cannot have a row`);
|
|
94
|
+
if (title !== e.meta.title)
|
|
95
|
+
problem(file, `${specId}: Title must be "${e.meta.title}"`);
|
|
96
|
+
if (specStatus !== e.meta.status)
|
|
97
|
+
problem(file, `${specId}: Spec status must be ${e.meta.status}`);
|
|
98
|
+
if (!["missing", "partial", "complete"].includes(code))
|
|
99
|
+
problem(file, `${specId}: Code must be missing, partial or complete`);
|
|
100
|
+
if (code === "complete" && e.meta.status === "unknown")
|
|
101
|
+
problem(file, `${specId}: an unknown entry cannot be complete`);
|
|
102
|
+
if (code === "complete" && placeholders.has(specId))
|
|
103
|
+
problem(file, `${specId}: a PLACEHOLDER comment cites it, so it cannot be complete`);
|
|
104
|
+
const testFiles = tests === "None"
|
|
105
|
+
? []
|
|
106
|
+
: tests
|
|
107
|
+
.split(",")
|
|
108
|
+
.map((x) => x.trim())
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
for (const tf of testFiles) {
|
|
111
|
+
const p = join(repoDir, tf);
|
|
112
|
+
if (!existsSync(p))
|
|
113
|
+
problem(file, `${specId}: test file ${tf} does not exist`);
|
|
114
|
+
else {
|
|
115
|
+
const text = readFileSync(p, "utf8");
|
|
116
|
+
if (!text.includes(specId))
|
|
117
|
+
problem(file, `${specId}: test file ${tf} does not mention ${specId}`);
|
|
118
|
+
if (text.includes("GAME_DIR") && !NEEDS_GAME.test(text))
|
|
119
|
+
problem(file, `${specId}: test file ${tf} mentions GAME_DIR without a "needs: GAME_DIR" comment, so CI would skip it unseen`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const listedDevs = devs === "None"
|
|
123
|
+
? []
|
|
124
|
+
: devs
|
|
125
|
+
.split(",")
|
|
126
|
+
.map((x) => x.trim())
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
const expectedDevs = [...deviations]
|
|
129
|
+
.filter(([, d]) => !d.dropped && d.departs.includes(specId))
|
|
130
|
+
.map(([k]) => k)
|
|
131
|
+
.sort(compareIds);
|
|
132
|
+
if (listedDevs.slice().sort(compareIds).join(",") !== expectedDevs.join(","))
|
|
133
|
+
problem(file, `${specId}: Deviations must be ${expectedDevs.join(", ") || "None"}`);
|
|
134
|
+
let expectedStatus;
|
|
135
|
+
if (code !== "complete" || e.meta.status === "disputed")
|
|
136
|
+
expectedStatus = e.meta.status;
|
|
137
|
+
else if (testFiles.length === 0)
|
|
138
|
+
expectedStatus = "implemented";
|
|
139
|
+
else if (["supported", "established"].includes(e.meta.status))
|
|
140
|
+
expectedStatus = "validated";
|
|
141
|
+
else {
|
|
142
|
+
problem(file, `${specId}: complete with tests while the spec status is ${e.meta.status}; the evidence belongs in the spec entry first`);
|
|
143
|
+
expectedStatus = status;
|
|
144
|
+
}
|
|
145
|
+
if (status !== expectedStatus)
|
|
146
|
+
problem(file, `${specId}: Status must be ${expectedStatus}`);
|
|
147
|
+
if (expectedStatus === "validated")
|
|
148
|
+
for (const tf of testFiles.filter((x) => needsGame(join(repoDir, x)))) {
|
|
149
|
+
if (!validatedTests.has(tf))
|
|
150
|
+
validatedTests.set(tf, []);
|
|
151
|
+
validatedTests.get(tf).push({ specId, file });
|
|
152
|
+
}
|
|
153
|
+
if (expectedStatus === "validated" && mayBeInterrupted(e) && onlyEmulatedRuns(entries, e))
|
|
154
|
+
problem(file, `${specId}: another rule may interrupt it (# may run:), so tests against emulated calls alone cannot validate it`, "STATUS-15");
|
|
155
|
+
for (const cell of [code, tests, devs, notes])
|
|
156
|
+
if (cell === "")
|
|
157
|
+
problem(file, `${specId}: an empty cell says None`);
|
|
158
|
+
parityCounts.status[status] = (parityCounts.status[status] ?? 0) + 1;
|
|
159
|
+
parityCounts.code[code] = (parityCounts.code[code] ?? 0) + 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const [id, e] of entries)
|
|
163
|
+
if (["RULE", "FMT", "SCR"].includes(e.kind) && e.meta.status !== "superseded" && !parityRows.has(id))
|
|
164
|
+
problem(parityDir, `${id} has no row`);
|
|
165
|
+
// Each area is split exactly where the limit requires it, going by the rows it has.
|
|
166
|
+
for (const [area, actual] of byArea) {
|
|
167
|
+
const ids = [...actual.values()]
|
|
168
|
+
.flat()
|
|
169
|
+
.filter((x) => entries.has(x))
|
|
170
|
+
.sort(compareIds);
|
|
171
|
+
const render = (subset, path) => [
|
|
172
|
+
`# ${path}`,
|
|
173
|
+
"",
|
|
174
|
+
`| ${PARITY_HEADER.join(" | ")} |`,
|
|
175
|
+
`|${"---|".repeat(PARITY_HEADER.length)}`,
|
|
176
|
+
...subset.map((x) => `| ${parityRows.get(x).cells.join(" | ")} |`),
|
|
177
|
+
"",
|
|
178
|
+
].join("\n");
|
|
179
|
+
const expected = [...layout(ctx, area, ids, render, 1).keys()];
|
|
180
|
+
const found = [...actual.keys()].sort();
|
|
181
|
+
if (expected.slice().sort().join(",") !== found.join(","))
|
|
182
|
+
problem(parityDir, `the rows of ${area} belong in ${expected.map((p) => `parity/${p}.md`).join(", ")}, split only where the ${LINE_LIMIT}-line limit requires it; found ${found.map((p) => `parity/${p}.md`).join(", ")}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const [dev, d] of deviations)
|
|
186
|
+
if (!d.dropped && !d.departs.some((x) => parityRows.has(x)))
|
|
187
|
+
problem(d.file, `${dev} departs from no entry that has a parity row`);
|
|
188
|
+
return { rows: parityRows, counts: parityCounts, validatedTests, legacy: legacyParity };
|
|
189
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import type { Deviation } from "./deviations.ts";
|
|
3
|
+
/** Checks the IDs that the code, the references, parity/ and deviations/ cite. */
|
|
4
|
+
export declare function checkReferences(ctx: Context, deviations: Map<string, Deviation>): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Implementation references: every spec and deviation ID in code, tests, the parity files and the
|
|
2
|
+
// deviation files resolves. A deviation keeps citing what it departed from after that is superseded.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join, resolve, sep } from "node:path";
|
|
5
|
+
import { isSuperseded } from "../evidence.js";
|
|
6
|
+
import { walk } from "../files.js";
|
|
7
|
+
import { idsIn, isAlias } from "../ids.js";
|
|
8
|
+
import { DEV_RE } from "../standard.js";
|
|
9
|
+
/** Checks the IDs that the code, the references, parity/ and deviations/ cite. */
|
|
10
|
+
export function checkReferences(ctx, deviations) {
|
|
11
|
+
const { problem } = ctx;
|
|
12
|
+
const { entries } = ctx.spec;
|
|
13
|
+
const { repoDir } = ctx.config;
|
|
14
|
+
const parityDir = join(repoDir, "parity");
|
|
15
|
+
const devDir = join(repoDir, "deviations");
|
|
16
|
+
const isDeviationFile = (f) => resolve(f).startsWith(devDir + sep);
|
|
17
|
+
const scan = ctx.codeFiles().filter(({ file }) => !file.endsWith(".fs"));
|
|
18
|
+
for (const dir of [parityDir, devDir])
|
|
19
|
+
walk(dir, (f) => {
|
|
20
|
+
if (f.endsWith(".md"))
|
|
21
|
+
scan.push({ file: f, text: readFileSync(f, "utf8") });
|
|
22
|
+
});
|
|
23
|
+
for (const { file: f, text } of scan) {
|
|
24
|
+
for (const x of idsIn(text)) {
|
|
25
|
+
if (isAlias(x) && !entries.has(x))
|
|
26
|
+
continue; // aliases can collide with ordinary words
|
|
27
|
+
if (!entries.has(x))
|
|
28
|
+
problem(f, `cites ${x}, which does not exist in the spec`);
|
|
29
|
+
else if (isSuperseded(entries, x) && !isDeviationFile(f))
|
|
30
|
+
problem(f, `cites ${x}, which is superseded; cite what replaced it`);
|
|
31
|
+
}
|
|
32
|
+
if (!isDeviationFile(f))
|
|
33
|
+
for (const x of new Set(text.match(DEV_RE) ?? []))
|
|
34
|
+
if (!deviations.has(x))
|
|
35
|
+
problem(f, `cites ${x}, which is not in deviations/`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import type { FormatNames } from "./formats.ts";
|
|
3
|
+
/** Checks every rule's procedure. Sets Entry.code on every rule entry, superseded ones included. */
|
|
4
|
+
export declare function checkRules(ctx: Context, { enumNames }: FormatNames): void;
|
|
@@ -0,0 +1,259 @@
|
|
|
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
|
+
const BUILTINS = new Set([
|
|
10
|
+
"min",
|
|
11
|
+
"max",
|
|
12
|
+
"abs",
|
|
13
|
+
"count",
|
|
14
|
+
"append",
|
|
15
|
+
"insert",
|
|
16
|
+
"remove_at",
|
|
17
|
+
"copy",
|
|
18
|
+
"stable_sort",
|
|
19
|
+
"sprintf",
|
|
20
|
+
"floor",
|
|
21
|
+
"ceil",
|
|
22
|
+
"round_even",
|
|
23
|
+
"draw",
|
|
24
|
+
"resource",
|
|
25
|
+
"read_file",
|
|
26
|
+
"write_file",
|
|
27
|
+
"free",
|
|
28
|
+
"fmod",
|
|
29
|
+
"UINT8",
|
|
30
|
+
"INT8",
|
|
31
|
+
"UINT16",
|
|
32
|
+
"INT16",
|
|
33
|
+
"UINT32",
|
|
34
|
+
"INT32",
|
|
35
|
+
"UINT64",
|
|
36
|
+
"INT64",
|
|
37
|
+
"FLOAT32",
|
|
38
|
+
"FLOAT64",
|
|
39
|
+
"FLOAT80",
|
|
40
|
+
"REAL48",
|
|
41
|
+
]);
|
|
42
|
+
const KEYWORDS = new Set([
|
|
43
|
+
"for",
|
|
44
|
+
"each",
|
|
45
|
+
"in",
|
|
46
|
+
"if",
|
|
47
|
+
"else",
|
|
48
|
+
"while",
|
|
49
|
+
"break",
|
|
50
|
+
"continue",
|
|
51
|
+
"return",
|
|
52
|
+
"let",
|
|
53
|
+
"and",
|
|
54
|
+
"or",
|
|
55
|
+
"not",
|
|
56
|
+
"true",
|
|
57
|
+
"false",
|
|
58
|
+
"call",
|
|
59
|
+
"define",
|
|
60
|
+
"emit",
|
|
61
|
+
"drain",
|
|
62
|
+
"show",
|
|
63
|
+
"new",
|
|
64
|
+
"table",
|
|
65
|
+
"clock",
|
|
66
|
+
"from",
|
|
67
|
+
"Hz",
|
|
68
|
+
]);
|
|
69
|
+
/** Checks every rule's procedure. Sets Entry.code on every rule entry, superseded ones included. */
|
|
70
|
+
export function checkRules(ctx, { enumNames }) {
|
|
71
|
+
const { problem } = ctx;
|
|
72
|
+
const { entries, glossary, glossaryDir } = ctx.spec;
|
|
73
|
+
const defined = new Map(); // function/table/clock name -> rule IDs
|
|
74
|
+
// A superseded rule keeps its procedure for history, but its declarations own no active name.
|
|
75
|
+
const historical = new Map(); // name declared by superseded rules -> rule IDs
|
|
76
|
+
for (const [id, e] of entries) {
|
|
77
|
+
if (e.kind !== "RULE")
|
|
78
|
+
continue;
|
|
79
|
+
const proc = e.sections.find((s) => s.title === "Procedure")?.text ?? "";
|
|
80
|
+
e.code = [...proc.matchAll(/```text\n([\s\S]*?)```/g)].map((m) => m[1]).join("\n");
|
|
81
|
+
const owners = e.meta.status === "superseded" ? historical : defined;
|
|
82
|
+
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)) {
|
|
83
|
+
const name = m[1] ?? m[2] ?? m[3];
|
|
84
|
+
if (!owners.has(name))
|
|
85
|
+
owners.set(name, []);
|
|
86
|
+
owners.get(name).push(id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// A live procedure cannot rely on a name only a superseded rule declares.
|
|
90
|
+
const onlyHistorical = (name) => !defined.has(name) && historical.has(name);
|
|
91
|
+
for (const [name, ids] of defined) {
|
|
92
|
+
const splitGroup = asList(entries.get(ids[0]).meta.split_with).concat(ids[0]);
|
|
93
|
+
if (ids.length > 1 && !ids.every((x) => splitGroup.includes(x)))
|
|
94
|
+
problem(null, `${name} is defined by more than one rule: ${ids.join(", ")}`);
|
|
95
|
+
if (!glossary.has(name))
|
|
96
|
+
problem(glossaryDir, `${name}, defined by ${ids[0]}, has no glossary entry`);
|
|
97
|
+
}
|
|
98
|
+
for (const [id, e] of entries) {
|
|
99
|
+
if (e.kind !== "RULE" || e.meta.status === "superseded")
|
|
100
|
+
continue;
|
|
101
|
+
const { file, meta } = e;
|
|
102
|
+
// Types in a define's signature (`define roll(n: UINT16) -> char[]:`) are not names the
|
|
103
|
+
// procedure reads, so they are dropped before the name checks.
|
|
104
|
+
const TYPE = String.raw `(?:[A-Za-z][A-Za-z0-9]*|FMT-[A-Z0-9]+-\d+)(?:\[[^\]]*\])?`;
|
|
105
|
+
const code = e
|
|
106
|
+
.code.replace(/#.*$/gm, "")
|
|
107
|
+
.replace(/"[^"]*"/g, '""')
|
|
108
|
+
.replace(new RegExp(String.raw `(\bdefine\s+[a-z_][a-z0-9_]*\s*\()([^)]*)\)(\s*->\s*${TYPE})?`, "g"), (_, head, params) => `${head}${params
|
|
109
|
+
.split(",")
|
|
110
|
+
.map((p) => p.split(":")[0].trim())
|
|
111
|
+
.join(", ")})`);
|
|
112
|
+
const related = asList(meta.related);
|
|
113
|
+
const openQuestions = e.sections.find((s) => s.title === "Open questions")?.text ?? "";
|
|
114
|
+
// Names are letters, digits and underscores, so best_score does not count as listing score.
|
|
115
|
+
const listsOpen = (name) => new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`).test(openQuestions);
|
|
116
|
+
if (meta.status !== "unknown" && e.code.trim() === "")
|
|
117
|
+
problem(file, "Procedure has no ```text block");
|
|
118
|
+
const usedTerms = new Set();
|
|
119
|
+
const useTerm = (name) => {
|
|
120
|
+
if (glossary.has(name))
|
|
121
|
+
usedTerms.add(name);
|
|
122
|
+
return glossary.has(name);
|
|
123
|
+
};
|
|
124
|
+
// Lists written out hold at most LIST_LIMIT values; a table of more takes them from a value file.
|
|
125
|
+
for (const m of code.matchAll(/=\s*\[([^\]]*)\]/g)) {
|
|
126
|
+
const count = m[1].split(",").filter((x) => x.trim() !== "").length;
|
|
127
|
+
if (count > LIST_LIMIT)
|
|
128
|
+
problem(file, `writes out a list of ${count} values; a list of more than ${LIST_LIMIT} is a table with a value file`);
|
|
129
|
+
}
|
|
130
|
+
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)) {
|
|
131
|
+
const [, name, count, csvName] = m;
|
|
132
|
+
const expected = `${id}.${name}.csv`;
|
|
133
|
+
if (csvName !== expected) {
|
|
134
|
+
problem(file, `table ${name} takes its values from ${expected}, not ${csvName}`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const path = join(dirname(file), csvName);
|
|
138
|
+
if (!existsSync(path)) {
|
|
139
|
+
problem(file, `value file ${csvName} does not exist`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const csv = readCsv(path, problem);
|
|
143
|
+
if (!csv)
|
|
144
|
+
continue;
|
|
145
|
+
if (csv.header.join("|") !== "value")
|
|
146
|
+
problem(path, "a table's value file has the single column value");
|
|
147
|
+
if (/^\d+$/.test(count.trim()) && csv.rows.length !== Number(count))
|
|
148
|
+
problem(path, `has ${csv.rows.length} values, but table ${name} has ${count}`);
|
|
149
|
+
for (const [v] of csv.rows)
|
|
150
|
+
if (!/^-?(?:0x[0-9A-Fa-f]+|\d+(?:\.\d+)?)$/.test(v.trim())) {
|
|
151
|
+
problem(path, `${v} is not a number`);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const m of code.matchAll(/\bcall\s+(RULE-[A-Z0-9]+-\d+)/g))
|
|
156
|
+
if (!related.includes(m[1]))
|
|
157
|
+
problem(file, `calls ${m[1]}; add it to related`, "ENTRY-TYPES-6");
|
|
158
|
+
for (const m of code.matchAll(/\bshow\s+(SCR-[A-Z0-9]+-\d+)/g))
|
|
159
|
+
if (!related.includes(m[1]))
|
|
160
|
+
problem(file, `shows ${m[1]}; add it to related`, "ENTRY-TYPES-6");
|
|
161
|
+
for (const m of code.matchAll(/\b(FMT-[A-Z0-9]+-\d+)/g))
|
|
162
|
+
if (!related.includes(m[1]))
|
|
163
|
+
problem(file, `uses ${m[1]}; add it to related`, "ENTRY-TYPES-6");
|
|
164
|
+
for (const m of e.code.matchAll(/# may run: (RULE-[A-Z0-9]+-\d+)/g))
|
|
165
|
+
if (!related.includes(m[1]))
|
|
166
|
+
problem(file, `may be interrupted by ${m[1]}; add it to related`, "ENTRY-TYPES-6");
|
|
167
|
+
if (meta.status === "established" && mayBeInterrupted(e) && onlyEmulatedRuns(entries, e))
|
|
168
|
+
problem(file, "another rule may interrupt this procedure (# may run:), so emulated calls alone cannot establish it", "STATUS-15");
|
|
169
|
+
if (asList(meta.complete_reading).length > 0 && /# may run: RULE-/.test(e.code))
|
|
170
|
+
problem(file, "another rule may interrupt this procedure (# may run:), so a complete reading cannot establish it; leave complete_reading empty", "STATUS-4");
|
|
171
|
+
for (const x of idsIn(code))
|
|
172
|
+
if (!entries.has(x))
|
|
173
|
+
problem(file, `procedure names ${x}, which does not exist`);
|
|
174
|
+
for (const m of code.matchAll(/\bemit\s+([A-Za-z_][A-Za-z0-9_]*)/g))
|
|
175
|
+
if (!useTerm(m[1]))
|
|
176
|
+
problem(file, `emits ${m[1]}, which has no glossary entry`);
|
|
177
|
+
for (const m of code.matchAll(/\bdrain\s+([A-Za-z_][A-Za-z0-9_]*)/g))
|
|
178
|
+
if (!useTerm(m[1]))
|
|
179
|
+
problem(file, `drains ${m[1]}, which has no glossary entry`);
|
|
180
|
+
for (const m of code.matchAll(/\b((?:fn|g|scr)_[A-Za-z0-9_]+)\b/g)) {
|
|
181
|
+
if (!useTerm(m[1]))
|
|
182
|
+
problem(file, `uses the neutral name ${m[1]}, which has no glossary entry`);
|
|
183
|
+
if (!listsOpen(m[1]))
|
|
184
|
+
problem(file, `uses the neutral name ${m[1]}; list it in Open questions`);
|
|
185
|
+
}
|
|
186
|
+
const noNeutral = code.replace(/\b(?:fn|g|scr)_[A-Za-z0-9_]+\b/g, "");
|
|
187
|
+
if (/\b0x[0-9A-Fa-f]{6,}\b/.test(noNeutral) && /\b0x00[4-9A-F][0-9A-F]{5}\b/.test(noNeutral))
|
|
188
|
+
problem(file, "the procedure contains what looks like an address outside a neutral name");
|
|
189
|
+
// Functions called without `call`
|
|
190
|
+
const locals = new Set();
|
|
191
|
+
for (const m of code.matchAll(/\blet\s+([a-z_][a-z0-9_]*)/g))
|
|
192
|
+
locals.add(m[1]);
|
|
193
|
+
for (const m of code.matchAll(/\bfor\s+(?:each\s+)?([a-z_][a-z0-9_]*)\s+in\b/g))
|
|
194
|
+
locals.add(m[1]);
|
|
195
|
+
for (const m of code.matchAll(/\bdefine\s+[a-z_][a-z0-9_]*\s*\(([^)]*)\)/g))
|
|
196
|
+
for (const p of m[1].split(","))
|
|
197
|
+
locals.add(p.split(":")[0].trim());
|
|
198
|
+
const params = e.sections.find((s) => s.title === "Parameters")?.text ?? "";
|
|
199
|
+
for (const m of params.matchAll(/`([a-z_][a-z0-9_]*)`/g))
|
|
200
|
+
locals.add(m[1]);
|
|
201
|
+
for (const m of code.matchAll(/(?<![.\w])([a-z_][a-z0-9_]*)\s*\(/g)) {
|
|
202
|
+
const name = m[1];
|
|
203
|
+
if (BUILTINS.has(name) || KEYWORDS.has(name) || locals.has(name))
|
|
204
|
+
continue;
|
|
205
|
+
if (onlyHistorical(name)) {
|
|
206
|
+
problem(file, `calls ${name}(), which only superseded ${historical.get(name).join(", ")} defines`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!defined.has(name) && !useTerm(name)) {
|
|
210
|
+
problem(file, `calls ${name}(), which no rule defines and the glossary does not list`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
for (const owner of defined.get(name) ?? [])
|
|
214
|
+
if (owner !== id && !related.includes(owner))
|
|
215
|
+
problem(file, `uses ${name} from ${owner}; add ${owner} to related`, "ENTRY-TYPES-6");
|
|
216
|
+
}
|
|
217
|
+
for (const m of code.matchAll(/(?<![.\w])([A-Z][A-Z0-9_]*[A-Z0-9])(?![\w-])/g)) {
|
|
218
|
+
const name = m[1];
|
|
219
|
+
if (BUILTINS.has(name) || KINDS[name] || /^(?:FMT|RULE|SCR)$/.test(name))
|
|
220
|
+
continue;
|
|
221
|
+
if (!enumNames.has(name)) {
|
|
222
|
+
problem(file, `upper-case name ${name} is not an enumeration name of any format`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
for (const fmt of enumNames.get(name))
|
|
226
|
+
if (!related.includes(fmt))
|
|
227
|
+
problem(file, `uses ${name} from ${fmt}; add it to related`, "ENTRY-TYPES-6");
|
|
228
|
+
}
|
|
229
|
+
// Names read or assigned without let that are neither locals nor glossary terms
|
|
230
|
+
for (const m of code.matchAll(/(?<![.\w])([a-z_][a-z0-9_]*)(?=\s*(?:\.|\[|=[^=]|$))/gm)) {
|
|
231
|
+
const name = m[1];
|
|
232
|
+
if (locals.has(name) || KEYWORDS.has(name) || BUILTINS.has(name) || defined.has(name))
|
|
233
|
+
continue;
|
|
234
|
+
if (onlyHistorical(name)) {
|
|
235
|
+
problem(file, `${name} is declared only by superseded ${historical.get(name).join(", ")}`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!useTerm(name))
|
|
239
|
+
problem(file, `${name} is neither a local nor a glossary term`);
|
|
240
|
+
}
|
|
241
|
+
// A glossary claim a procedure relies on counts toward the rule's status: its evidence is the
|
|
242
|
+
// rule's evidence, and while it is (unknown) the rule lists it as an open question and is not
|
|
243
|
+
// established.
|
|
244
|
+
for (const term of usedTerms) {
|
|
245
|
+
const text = glossary.get(term);
|
|
246
|
+
for (const b of text.matchAll(/\[([^\]]*)\]/g))
|
|
247
|
+
for (const x of idsIn(b[1])) {
|
|
248
|
+
if (["FND", "EXP"].includes(kindOf(x)) && !asList(meta.evidence).includes(x))
|
|
249
|
+
problem(file, `relies on ${term}, whose glossary entry cites ${x}; add it to evidence`);
|
|
250
|
+
}
|
|
251
|
+
if (text.includes("(unknown)")) {
|
|
252
|
+
if (!listsOpen(term))
|
|
253
|
+
problem(file, `relies on ${term}, a glossary claim that is (unknown); list it in Open questions`);
|
|
254
|
+
if (meta.status === "established")
|
|
255
|
+
problem(file, `relies on ${term}, a glossary claim that is (unknown), so it cannot be established`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -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;
|