@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.
Files changed (79) hide show
  1. package/README.md +6 -0
  2. package/dist/checks/base.d.ts +7 -0
  3. package/dist/checks/base.js +72 -0
  4. package/dist/checks/claims.d.ts +4 -0
  5. package/dist/checks/claims.js +83 -0
  6. package/dist/checks/comment-addresses.d.ts +3 -0
  7. package/dist/checks/comment-addresses.js +111 -0
  8. package/dist/checks/cross-entry.d.ts +4 -0
  9. package/dist/checks/cross-entry.js +177 -0
  10. package/dist/checks/deviations.d.ts +12 -0
  11. package/dist/checks/deviations.js +101 -0
  12. package/dist/checks/entries.d.ts +7 -0
  13. package/dist/checks/entries.js +131 -0
  14. package/dist/checks/evidence-entries.d.ts +6 -0
  15. package/dist/checks/evidence-entries.js +167 -0
  16. package/dist/checks/fields.d.ts +4 -0
  17. package/dist/checks/fields.js +314 -0
  18. package/dist/checks/formats.d.ts +19 -0
  19. package/dist/checks/formats.js +221 -0
  20. package/dist/checks/kaitai.d.ts +6 -0
  21. package/dist/checks/kaitai.js +103 -0
  22. package/dist/checks/parity.d.ts +30 -0
  23. package/dist/checks/parity.js +189 -0
  24. package/dist/checks/references.d.ts +4 -0
  25. package/dist/checks/references.js +37 -0
  26. package/dist/checks/rules.d.ts +11 -0
  27. package/dist/checks/rules.js +263 -0
  28. package/dist/checks/screens.d.ts +4 -0
  29. package/dist/checks/screens.js +44 -0
  30. package/dist/checks/validation.d.ts +7 -0
  31. package/dist/checks/validation.js +116 -0
  32. package/dist/code-comments.d.ts +3 -0
  33. package/dist/code-comments.js +150 -0
  34. package/dist/code-files.d.ts +11 -0
  35. package/dist/code-files.js +41 -0
  36. package/dist/context.d.ts +34 -0
  37. package/dist/context.js +2 -0
  38. package/dist/evidence.d.ts +37 -0
  39. package/dist/evidence.js +91 -0
  40. package/dist/files.d.ts +11 -0
  41. package/dist/files.js +35 -0
  42. package/dist/generate/indexes.d.ts +3 -0
  43. package/dist/generate/indexes.js +135 -0
  44. package/dist/generate/layout.d.ts +18 -0
  45. package/dist/generate/layout.js +49 -0
  46. package/dist/generate/parity-md.d.ts +7 -0
  47. package/dist/generate/parity-md.js +44 -0
  48. package/dist/generate/write.d.ts +8 -0
  49. package/dist/generate/write.js +75 -0
  50. package/dist/ids.d.ts +13 -0
  51. package/dist/ids.js +22 -0
  52. package/dist/load/builds.d.ts +9 -0
  53. package/dist/load/builds.js +124 -0
  54. package/dist/load/code-ranges.d.ts +4 -0
  55. package/dist/load/code-ranges.js +79 -0
  56. package/dist/load/entries.d.ts +7 -0
  57. package/dist/load/entries.js +57 -0
  58. package/dist/load/glossary.d.ts +12 -0
  59. package/dist/load/glossary.js +70 -0
  60. package/dist/load/readme.d.ts +3 -0
  61. package/dist/load/readme.js +36 -0
  62. package/dist/load/spec.d.ts +6 -0
  63. package/dist/load/spec.js +25 -0
  64. package/dist/locations.d.ts +19 -0
  65. package/dist/locations.js +57 -0
  66. package/dist/markdown.d.ts +27 -0
  67. package/dist/markdown.js +162 -0
  68. package/dist/options.d.ts +34 -0
  69. package/dist/options.js +87 -0
  70. package/dist/problems.d.ts +27 -0
  71. package/dist/problems.js +27 -0
  72. package/dist/standard-checker.js +53 -3033
  73. package/dist/standard.d.ts +65 -0
  74. package/dist/standard.js +173 -0
  75. package/dist/types.d.ts +91 -0
  76. package/dist/types.js +2 -0
  77. package/dist/yaml.d.ts +6 -0
  78. package/dist/yaml.js +173 -0
  79. package/package.json +1 -1
@@ -0,0 +1,221 @@
1
+ // The checks of a format entry: its fields, its layout and enumeration tables (including those kept
2
+ // in value files), the status of each row, and its Kaitai definition's meta.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { checkResolves, checkStatusCitations, completeReading, rowFacts, statusIndex } from "../evidence.js";
6
+ import { asList, idsIn, kindOf } from "../ids.js";
7
+ import { readCsv, tables } from "../markdown.js";
8
+ import { BINARY_LAYOUT, CLAIM_STATUSES, ENUM_TABLE, ROW_STATUSES, TEXT_LAYOUT } from "../standard.js";
9
+ /** The IDs cited in the last column of the tables of an entry's sections (of every section when sectionTitles is left out). */
10
+ export function tableIds(e, sectionTitles) {
11
+ const ids = new Set();
12
+ for (const s of e.sections)
13
+ if (!sectionTitles || sectionTitles.includes(s.title))
14
+ for (const t of tables(s.text))
15
+ for (const r of t.rows)
16
+ for (const x of idsIn(r[t.header.length - 1] ?? ""))
17
+ ids.add(x);
18
+ return ids;
19
+ }
20
+ /** Checks a format entry, adding the names its tables define to formatNames. */
21
+ export function checkFormat(ctx, e, formatNames) {
22
+ const { problem } = ctx;
23
+ const { entries, buildFiles } = ctx.spec;
24
+ const { enumNames, fieldNames, layouts } = formatNames;
25
+ const { file, meta } = e;
26
+ const id = meta.id;
27
+ const first = asList(meta.builds)[0];
28
+ if (meta.text === true) {
29
+ if (meta.definition !== null || meta.size !== null || meta.byte_order !== null)
30
+ problem(file, "a text format has definition, size and byte_order null");
31
+ }
32
+ else {
33
+ if (!["little", "big"].includes(meta.byte_order))
34
+ problem(file, "byte_order must be little or big for a binary format");
35
+ if (meta.status !== "unknown" && meta.status !== "superseded") {
36
+ const expected = `${id.toLowerCase().replaceAll("-", "_")}.ksy`;
37
+ if (meta.definition !== expected)
38
+ problem(file, `definition must be ${expected}`);
39
+ else if (!existsSync(join(dirname(file), expected)))
40
+ problem(file, `definition ${expected} does not exist`);
41
+ }
42
+ }
43
+ for (const pattern of asList(meta.files)) {
44
+ const re = new RegExp("^" +
45
+ String(pattern)
46
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
47
+ .replaceAll("*", "[^/]*")
48
+ .replaceAll("?", "[^/]") +
49
+ "$");
50
+ for (const b of asList(meta.builds))
51
+ if (!(buildFiles.get(b) ?? []).some((f) => re.test(f.path)))
52
+ problem(file, `files pattern ${pattern} matches no file of ${b}`);
53
+ }
54
+ const layout = e.sections.find((s) => s.title === "Layout");
55
+ const enums = e.sections.find((s) => s.title === "Enumerations and flags");
56
+ const names = new Set();
57
+ fieldNames.set(id, names);
58
+ let lowest = null;
59
+ let disputed = false;
60
+ const visit = (t, kindLabel) => {
61
+ const statusCol = t.header.indexOf("Status");
62
+ const evCol = t.header.indexOf("Evidence");
63
+ const nameCol = t.header.indexOf("Name");
64
+ for (const row of t.rows) {
65
+ if (row.length !== t.header.length) {
66
+ problem(file, `${kindLabel} row ${row.join(" | ")} has ${row.length} cells, not ${t.header.length}`);
67
+ continue;
68
+ }
69
+ const isTotal = (row[t.header.indexOf("Meaning")] ?? "").startsWith("Total") && (row[statusCol] ?? "") === "";
70
+ if (isTotal)
71
+ continue;
72
+ const st = row[statusCol];
73
+ if (!ROW_STATUSES.includes(st)) {
74
+ // STATUS-1 lists the statuses. That a row is never superseded is a rule of Formats, which has no
75
+ // numbered rules yet.
76
+ problem(file, `${kindLabel} row ${row[nameCol] ?? row[0]}: status ${st} is not allowed in a row`, CLAIM_STATUSES.includes(st) ? undefined : "STATUS-1");
77
+ continue;
78
+ }
79
+ if (st === "disputed")
80
+ disputed = true;
81
+ else if (lowest === null || statusIndex(st) < statusIndex(lowest))
82
+ lowest = st;
83
+ const ids = idsIn(row[evCol]);
84
+ checkResolves(ctx, file, ids, `${kindLabel} row ${row[nameCol] ?? row[0]}`);
85
+ const conflicting = ids.filter((x) => asList(meta.conflicting).includes(x));
86
+ checkStatusCitations(problem, file, st, rowFacts(entries, ids, first, completeReading(entries, e)), conflicting, `${kindLabel} row ${(row[nameCol] ?? row[0]).replaceAll("`", "")}: status`);
87
+ if (nameCol >= 0 && row[nameCol])
88
+ names.add(row[nameCol].replaceAll("`", ""));
89
+ }
90
+ };
91
+ // A row whose status is wrong still names a field, so the field checks of rules read every row.
92
+ const layoutTypes = new Map();
93
+ let wellFormed = true;
94
+ if (layout) {
95
+ const ts = tables(layout.text);
96
+ const wanted = meta.text === true ? TEXT_LAYOUT : BINARY_LAYOUT;
97
+ if (meta.status !== "unknown" && ts.length === 0)
98
+ problem(file, "Layout has no table");
99
+ for (const t of ts) {
100
+ if (t.header.join("|") !== wanted.join("|")) {
101
+ problem(file, `a layout table has the columns ${wanted.join(" | ")}`);
102
+ wellFormed = false;
103
+ continue;
104
+ }
105
+ visit(t, "layout");
106
+ const nameCol = t.header.indexOf("Name");
107
+ const typeCol = t.header.indexOf("Type");
108
+ for (const row of t.rows) {
109
+ // A row with the wrong number of cells may hold a field whose Name cell cannot be found.
110
+ if (row.length !== t.header.length) {
111
+ wellFormed = false;
112
+ continue;
113
+ }
114
+ if (!row[nameCol])
115
+ continue;
116
+ // A cell that names more than one field, or a path into a field (`items[i].count`), still
117
+ // names the field it starts with, though not its type.
118
+ for (const name of (row[nameCol].match(/`[^`]+`/g) ?? [row[nameCol]]).map((n) => n.replaceAll("`", "").trim())) {
119
+ const lead = /^[A-Za-z_][A-Za-z0-9_]*/.exec(name)?.[0];
120
+ if (lead === name)
121
+ layoutTypes.set(name, row[typeCol]);
122
+ else if (lead && !layoutTypes.has(lead))
123
+ layoutTypes.set(lead, "");
124
+ }
125
+ }
126
+ }
127
+ }
128
+ if (wellFormed)
129
+ layouts.set(id, layoutTypes);
130
+ // An enumeration table kept in a value file counts as one of the entry's tables.
131
+ const enumTables = enums ? [...tables(enums.text), ...valueFileTables(ctx, e, enums.text)] : [];
132
+ e.valueTables = enumTables.filter((t) => t.file);
133
+ for (const t of enumTables) {
134
+ if (t.header.join("|") !== ENUM_TABLE.join("|")) {
135
+ problem(t.file ?? file, `an enumeration table has the columns ${ENUM_TABLE.join(" | ")}`);
136
+ continue;
137
+ }
138
+ if (!t.heading)
139
+ problem(file, "an enumeration table sits under a ### heading naming its fields");
140
+ else
141
+ for (const f of (t.heading.match(/`([^`]+)`/g) ?? t.heading.split(/\s*,\s*|\s+and\s+/))
142
+ .map((x) => x.replaceAll("`", "").trim())
143
+ .filter(Boolean))
144
+ if (!names.has(f))
145
+ problem(file, `enumeration heading names ${f}, which is not a field of the layout`);
146
+ visit(t, "enumeration");
147
+ for (const row of t.rows) {
148
+ if (row.length !== t.header.length)
149
+ continue; // visit reported it
150
+ const n = row[1].replaceAll("`", "");
151
+ if (!/^[A-Z][A-Z0-9_]*$/.test(n))
152
+ problem(file, `enumeration name ${n} must be upper-case letters, digits and underscores`);
153
+ if (!enumNames.has(n))
154
+ enumNames.set(n, []);
155
+ enumNames.get(n).push(id);
156
+ }
157
+ }
158
+ if (meta.status !== "superseded" && meta.status !== "unknown") {
159
+ const expected = disputed ? "disputed" : lowest;
160
+ if (expected && meta.status !== expected)
161
+ problem(file, `status must be ${expected}, the lowest status among its rows`);
162
+ }
163
+ const cited = tableIds(e, ["Layout", "Enumerations and flags"]);
164
+ for (const t of e.valueTables)
165
+ for (const row of t.rows)
166
+ for (const x of idsIn(row[t.header.length - 1]))
167
+ cited.add(x);
168
+ const listed = new Set([...asList(meta.evidence), ...asList(meta.conflicting)]);
169
+ for (const x of cited)
170
+ if (!listed.has(x) && ["FND", "EXP", "SRC"].includes(kindOf(x)))
171
+ problem(file, `${x} is cited in a table but not in evidence or conflicting`);
172
+ for (const t of tables(layout?.text ?? ""))
173
+ for (const row of t.rows)
174
+ for (const x of idsIn(row[t.header.indexOf("Meaning")]))
175
+ if (kindOf(x) === "RULE" && !asList(meta.related).includes(x))
176
+ problem(file, `layout names ${x}; add it to related`, "ENTRY-TYPES-6");
177
+ // Kaitai definition
178
+ if (meta.definition && existsSync(join(dirname(file), meta.definition))) {
179
+ const ksy = readFileSync(join(dirname(file), meta.definition), "utf8");
180
+ const expectedId = id.toLowerCase().replaceAll("-", "_");
181
+ if (!new RegExp(`^\\s*id:\\s*${expectedId}\\s*$`, "m").test(ksy))
182
+ problem(join(dirname(file), meta.definition), `meta/id must be ${expectedId}`);
183
+ if (!/^\s*license:\s*\S+/m.test(ksy))
184
+ problem(join(dirname(file), meta.definition), "meta/license must name the licence");
185
+ }
186
+ }
187
+ // The enumeration tables an entry keeps in value files: each ### heading stays in the entry, followed
188
+ // by a sentence naming its file, formats/<ID>.<table>.csv.
189
+ function valueFileTables(ctx, e, text) {
190
+ const { problem } = ctx;
191
+ const out = [];
192
+ let heading = null;
193
+ let fence = false;
194
+ for (const line of text.split("\n")) {
195
+ if (/^(```|~~~)/.test(line))
196
+ fence = !fence;
197
+ if (fence)
198
+ continue;
199
+ const h = /^### (.+)$/.exec(line);
200
+ if (h) {
201
+ heading = h[1].trim();
202
+ continue;
203
+ }
204
+ for (const m of line.matchAll(/\b([A-Z]+-[A-Z0-9]+-\d{3,}\.[A-Za-z0-9_]+\.csv)\b/g)) {
205
+ // Another entry's value file holds that entry's rows, so it is not read as one of these.
206
+ if (!m[1].startsWith(`${e.meta.id}.`)) {
207
+ problem(e.file, `value file ${m[1]} belongs to another entry; an entry's value files are named ${e.meta.id}.<table>.csv`);
208
+ continue;
209
+ }
210
+ const path = join(dirname(e.file), m[1]);
211
+ if (!existsSync(path)) {
212
+ problem(e.file, `value file ${m[1]} does not exist`);
213
+ continue;
214
+ }
215
+ const csv = readCsv(path, problem);
216
+ if (csv)
217
+ out.push({ heading, header: csv.header, rows: csv.rows, file: path });
218
+ }
219
+ }
220
+ return out;
221
+ }
@@ -0,0 +1,6 @@
1
+ import type { Context } from "../context.ts";
2
+ /**
3
+ * Checks that each .ksy file in spec/formats/ belongs to a format entry, and compiles them all with
4
+ * the Kaitai Struct compiler, warning when there is none. Does nothing with --no-ksy.
5
+ */
6
+ export declare function compileKaitai(ctx: Context): void;
@@ -0,0 +1,103 @@
1
+ // Kaitai compilation: every definition in spec/formats/ belongs to a format entry and compiles.
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, join } from "node:path";
6
+ /**
7
+ * Checks that each .ksy file in spec/formats/ belongs to a format entry, and compiles them all with
8
+ * the Kaitai Struct compiler, warning when there is none. Does nothing with --no-ksy.
9
+ */
10
+ export function compileKaitai(ctx) {
11
+ const { problem } = ctx;
12
+ const { entries } = ctx.spec;
13
+ const { specDir, skipKsy } = ctx.config;
14
+ if (!skipKsy) {
15
+ const ksys = [];
16
+ const fd = join(specDir, "formats");
17
+ if (existsSync(fd))
18
+ for (const f of readdirSync(fd))
19
+ if (f.endsWith(".ksy"))
20
+ ksys.push(join(fd, f));
21
+ for (const k of ksys) {
22
+ const id = basename(k, ".ksy")
23
+ .toUpperCase()
24
+ .replace(/^FMT_([A-Z0-9]+)_(\d+)$/, "FMT-$1-$2");
25
+ if (!entries.has(id))
26
+ problem(k, `belongs to no format entry (${id})`);
27
+ }
28
+ const compiler = findKaitai();
29
+ if (compiler && ksys.length) {
30
+ const out = mkdtempSync(join(tmpdir(), "ksy-check-"));
31
+ const fixed = [...compiler.args, "--target", "python", "--outdir", out, "--import-path", fd];
32
+ try {
33
+ for (const batch of kaitaiBatches([compiler.cmd, ...fixed], ksys)) {
34
+ try {
35
+ runTool(compiler.cmd, [...fixed, ...batch]);
36
+ }
37
+ catch (err) {
38
+ const failure = err;
39
+ problem(null, `Kaitai definitions do not compile:\n${String(failure.stdout ?? "")}${String(failure.stderr ?? "")}`);
40
+ }
41
+ }
42
+ }
43
+ finally {
44
+ rmSync(out, { recursive: true, force: true });
45
+ }
46
+ }
47
+ else if (ksys.length)
48
+ console.warn("warning: no Kaitai Struct compiler found (set KSC or install kaitai-struct-compiler); definitions were not compiled.");
49
+ }
50
+ }
51
+ // cmd.exe takes a command line of at most 8,191 characters, and the compiler's .bat launcher adds
52
+ // its class path to the arguments it is given. On Windows the definitions are compiled in batches
53
+ // whose quoted command line stays under 4,000 characters. Every batch gets the same --import-path,
54
+ // so imports between definitions still resolve.
55
+ function kaitaiBatches(fixed, files) {
56
+ if (process.platform !== "win32")
57
+ return [files];
58
+ const limit = 4000;
59
+ const quoted = (a) => a.length + 3;
60
+ const start = fixed.reduce((n, a) => n + quoted(a), 0);
61
+ const batches = [];
62
+ let batch = [];
63
+ let length = start;
64
+ for (const f of files) {
65
+ if (batch.length > 0 && length + quoted(f) > limit) {
66
+ batches.push(batch);
67
+ batch = [];
68
+ length = start;
69
+ }
70
+ batch.push(f);
71
+ length += quoted(f);
72
+ }
73
+ if (batch.length > 0)
74
+ batches.push(batch);
75
+ return batches;
76
+ }
77
+ function findKaitai() {
78
+ if (process.env.KSC)
79
+ return { cmd: process.env.KSC, args: [] };
80
+ for (const cmd of ["kaitai-struct-compiler", "ksc"]) {
81
+ try {
82
+ runTool(cmd, ["--version"]);
83
+ return { cmd, args: [] };
84
+ }
85
+ catch { }
86
+ }
87
+ return null;
88
+ }
89
+ // On Windows the compiler is a .bat file, which only cmd.exe can run. Node's shell: true joins the
90
+ // arguments without quoting them, so a path with a space would split. This quotes every argument
91
+ // and hands cmd.exe the line as is. A % in an argument would still expand; paths here have none.
92
+ function runTool(cmd, args) {
93
+ if (process.platform !== "win32")
94
+ return execFileSync(cmd, args, { stdio: "pipe" });
95
+ const line = [cmd, ...args].map((a) => `"${a}"`).join(" ");
96
+ // execFileSync hands its options to spawn, which reads windowsVerbatimArguments; the Node types
97
+ // leave it off ExecFileSyncOptions.
98
+ const verbatim = {
99
+ stdio: "pipe",
100
+ windowsVerbatimArguments: true,
101
+ };
102
+ return execFileSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${line}"`], verbatim);
103
+ }
@@ -0,0 +1,30 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { Deviation } from "./deviations.ts";
3
+ /** The columns of a parity table. */
4
+ export declare const PARITY_HEADER: string[];
5
+ /** What the parity check read from parity/. */
6
+ export interface Parity {
7
+ /** Spec ID -> the row's cells as written, and its file. */
8
+ rows: Map<string, {
9
+ cells: string[];
10
+ file: string;
11
+ }>;
12
+ /** How many rows have each Status and each Code. */
13
+ counts: {
14
+ status: Record<string, number>;
15
+ code: Record<string, number>;
16
+ };
17
+ /** Marked test file of a validated row -> the rows that list it. */
18
+ validatedTests: Map<string, Array<{
19
+ specId: string;
20
+ file: string;
21
+ }>>;
22
+ /** Whether PARITY.md still holds the rows, so the check leaves it alone. */
23
+ legacy: boolean;
24
+ }
25
+ /**
26
+ * Checks the rows in parity/ against the spec, the deviations and the code, and that each area is
27
+ * split exactly where the line limit requires. Then reports a live deviation that departs from no
28
+ * entry with a row.
29
+ */
30
+ export declare function checkParity(ctx: Context, deviations: Map<string, Deviation>): Parity;
@@ -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,11 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { FormatNames } from "./formats.ts";
3
+ /**
4
+ * A procedure with each string literal emptied and its comments dropped. Strings go first, so a `#`
5
+ * inside one does not start a comment, and a string ends on the line it starts on.
6
+ */
7
+ export declare const withoutCommentsAndStrings: (code: string) => string;
8
+ /** The names a rule's Parameters section declares: each lower-case name that opens a code span, as `n` or `n: type`. */
9
+ export declare const parameterNames: (params: string) => string[];
10
+ /** Checks every rule's procedure. Sets Entry.code on every rule entry, superseded ones included. */
11
+ export declare function checkRules(ctx: Context, { enumNames }: FormatNames): void;