@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,131 @@
1
+ // The checks every entry gets: its ID, fields, sections, status and links. Each kind's own checks
2
+ // are called from here, in the order the standard lists them.
3
+ import { checkResolves, isSuperseded } from "../evidence.js";
4
+ import { asList, kindOf } from "../ids.js";
5
+ import { CLAIM_STATUSES, EVIDENCE_STATUSES, FIELD_RULES, FIELDS, KINDS, SECTIONS } from "../standard.js";
6
+ import { checkClaim } from "./claims.js";
7
+ import { checkExperiment, checkFinding } from "./evidence-entries.js";
8
+ import { checkFormat } from "./formats.js";
9
+ import { checkScreen } from "./screens.js";
10
+ function checkIdForm(ctx, file, id) {
11
+ const { problem } = ctx;
12
+ const { areas } = ctx.spec;
13
+ const kind = kindOf(id);
14
+ if (!KINDS[kind]) {
15
+ problem(file, `${id} is not an ID of a known kind`, "IDENTIFIERS-1");
16
+ return;
17
+ }
18
+ if (kind === "BLD" || kind === "SRC") {
19
+ if (!/^(BLD|SRC)-[A-Z][A-Z0-9.-]*$/.test(id))
20
+ problem(file, `${id}: an alias starts with an upper-case letter and holds only upper-case letters, digits, dots and hyphens`, "IDENTIFIERS-4");
21
+ return;
22
+ }
23
+ const m = /^[A-Z]+-([A-Z][A-Z0-9]*)-(\d+)$/.exec(id);
24
+ if (!m) {
25
+ problem(file, `${id} does not have the form KIND-AREA-NNN`, "IDENTIFIERS-1");
26
+ return;
27
+ }
28
+ if (!areas.includes(m[1]))
29
+ problem(file, `${id}: area ${m[1]} is not in the area list`, "IDENTIFIERS-2");
30
+ if (m[2].length < 3 || (m[2].length > 3 && m[2].startsWith("0")))
31
+ problem(file, `${id}: the number is zero-padded to exactly three digits until it passes 999`, "IDENTIFIERS-3");
32
+ }
33
+ /**
34
+ * Checks every entry, kind by kind, and returns the names the formats' tables define, which the
35
+ * rule checks read.
36
+ */
37
+ export function checkEntries(ctx) {
38
+ const { problem } = ctx;
39
+ const { entries } = ctx.spec;
40
+ const names = {
41
+ enumNames: new Map(), // name -> format IDs
42
+ fieldNames: new Map(), // format ID -> Set of names
43
+ layouts: new Map(), // format ID -> layout Name -> Type
44
+ };
45
+ for (const [id, e] of entries) {
46
+ const { file, meta, kind } = e;
47
+ checkIdForm(ctx, file, id);
48
+ for (const f of FIELDS[kind].required)
49
+ if (!(f in meta))
50
+ problem(file, `front matter lacks ${f}`, FIELD_RULES[f]);
51
+ if (!Array.isArray(meta.superseded_by))
52
+ problem(file, "superseded_by must be a list", FIELD_RULES.superseded_by);
53
+ const expectedSections = SECTIONS[kind];
54
+ const got = e.sections.map((s) => s.title);
55
+ if (got.join("|") !== expectedSections.join("|"))
56
+ problem(file, `sections must be ${expectedSections.join(", ")} in that order; found ${got.join(", ") || "none"}`, "ENTRY-TYPES-1");
57
+ for (const s of e.sections)
58
+ if (s.text.trim() === "")
59
+ problem(file, `section ${s.title} is empty; write None known. or None.`, "ENTRY-TYPES-2");
60
+ const superseded = asList(meta.superseded_by);
61
+ const status = meta.status;
62
+ if (KINDS[kind].statuses === "claim" && !CLAIM_STATUSES.includes(status))
63
+ problem(file, `status ${status} is not one of ${CLAIM_STATUSES.join(", ")}`, "STATUS-1");
64
+ if (KINDS[kind].statuses === "evidence" && !EVIDENCE_STATUSES.includes(status))
65
+ problem(file, `status ${status} is not one of ${EVIDENCE_STATUSES.join(", ")}`, "STATUS-21");
66
+ const isSup = status === "superseded" || ((kind === "BLD" || kind === "SRC") && superseded.length > 0);
67
+ if (status === "superseded" && superseded.length === 0)
68
+ problem(file, "a superseded entry names what replaced or disproved it in superseded_by", "IDENTIFIERS-7");
69
+ if (status && status !== "superseded" && superseded.length > 0)
70
+ problem(file, "superseded_by must be empty unless the status is superseded", "ENTRY-TYPES-4");
71
+ checkResolves(ctx, file, superseded, "superseded_by");
72
+ for (const s of superseded) {
73
+ const k = kindOf(s);
74
+ const ok = ["FND", "EXP"].includes(kind)
75
+ ? ["FND", "EXP"].includes(k)
76
+ : kind === "BLD"
77
+ ? k === "BLD"
78
+ : kind === "SRC"
79
+ ? k === "SRC"
80
+ : kind === "BUG"
81
+ ? true
82
+ : k !== "SRC" && k !== "BLD";
83
+ if (!ok)
84
+ problem(file, `superseded_by may not name ${s}`, "IDENTIFIERS-7");
85
+ }
86
+ if (kind !== "BLD" && kind !== "SRC") {
87
+ const builds = asList(meta.builds);
88
+ if (builds.length === 0)
89
+ problem(file, "builds must list at least one build");
90
+ checkResolves(ctx, file, builds, "builds");
91
+ for (const b of builds)
92
+ if (entries.has(b) && kindOf(b) !== "BLD")
93
+ problem(file, `builds lists ${b}, which is not a build`);
94
+ }
95
+ // Links that must not point at superseded entries
96
+ if (!isSup) {
97
+ const linkFields = ["builds", "evidence", "conflicting", "related"];
98
+ for (const f of linkFields)
99
+ for (const t of asList(meta[f]))
100
+ if (entries.has(t) && isSuperseded(entries, t))
101
+ problem(file, `${f} cites ${t}, which is superseded`, "STATUS-17");
102
+ for (const loc of asList(meta.locations))
103
+ if (loc && entries.has(loc.build) && isSuperseded(entries, loc.build))
104
+ problem(file, `a location names ${loc.build}, which is superseded`, "STATUS-17");
105
+ }
106
+ if (["FND", "EXP"].includes(kind)) {
107
+ const rep = asList(meta.reproduced_by);
108
+ if (status === "reproduced" && rep.every((p) => p === meta.recorded_by))
109
+ problem(file, "a reproduced entry names someone other than recorded_by in reproduced_by", "STATUS-21");
110
+ if (status !== "reproduced" && rep.length > 0)
111
+ problem(file, "reproduced_by must be empty unless the status is reproduced", "STATUS-21");
112
+ if (typeof meta.recorded_by !== "string" || !meta.recorded_by)
113
+ problem(file, "recorded_by must be a GitHub username");
114
+ }
115
+ if (kind === "FND")
116
+ checkFinding(ctx, e);
117
+ if (kind === "EXP")
118
+ checkExperiment(ctx, id, e, isSup);
119
+ if (KINDS[kind].statuses === "claim")
120
+ checkClaim(ctx, id, e);
121
+ if (kind === "BLD" && ![16, 32].includes(meta.int_width))
122
+ problem(file, "int_width must be 16 or 32");
123
+ if (kind === "SRC" && meta.xxh3 !== null && !/^[0-9a-f]{32}$/.test(String(meta.xxh3)))
124
+ problem(file, "xxh3 must be null or 32 lower-case hex digits");
125
+ if (kind === "FMT")
126
+ checkFormat(ctx, e, names);
127
+ if (kind === "SCR")
128
+ checkScreen(ctx, e);
129
+ }
130
+ return names;
131
+ }
@@ -0,0 +1,6 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { Entry } from "../types.ts";
3
+ /** Checks a finding's method, environment and locations. */
4
+ export declare function checkFinding(ctx: Context, e: Entry): void;
5
+ /** Checks an experiment's builds, fixture, starting state and recording. live is false once it is superseded. */
6
+ export declare function checkExperiment(ctx: Context, id: string, e: Entry, isSup: boolean): void;
@@ -0,0 +1,167 @@
1
+ // The checks of findings and experiments: a finding's locations, and an experiment's fixture,
2
+ // starting state and recording.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { isSuperseded } from "../evidence.js";
6
+ import { asList } from "../ids.js";
7
+ import { checkAddress, checkOffset } from "../locations.js";
8
+ import { locationRule } from "../standard.js";
9
+ /** Checks a finding's method, environment and locations. */
10
+ export function checkFinding(ctx, e) {
11
+ const { problem } = ctx;
12
+ const { buildFiles, codeRanges } = ctx.spec;
13
+ const { file, meta } = e;
14
+ if (!["static", "dynamic"].includes(meta.method))
15
+ problem(file, "method must be static or dynamic");
16
+ if (meta.method === "static" && meta.environment !== null)
17
+ problem(file, "a static finding has environment: null");
18
+ if (meta.method === "dynamic" && (meta.environment === null || meta.environment === ""))
19
+ problem(file, "a dynamic finding gives its environment");
20
+ const locations = asList(meta.locations);
21
+ const builds = asList(meta.builds);
22
+ if (meta.method === "static")
23
+ for (const b of builds)
24
+ if (!locations.some((l) => l && l.build === b))
25
+ problem(file, `a static finding has at least one location in ${b}`);
26
+ for (const loc of locations) {
27
+ if (!loc || typeof loc !== "object") {
28
+ problem(file, "a location must be a map of build, file and address or offset");
29
+ continue;
30
+ }
31
+ if (!builds.includes(loc.build))
32
+ problem(file, `location build ${loc.build} is not in builds`);
33
+ const files = buildFiles.get(loc.build) ?? [];
34
+ const bf = files.find((f) => f.path === loc.file);
35
+ if (!bf) {
36
+ problem(file, `location file ${loc.file} is not in the files of ${loc.build}`);
37
+ continue;
38
+ }
39
+ const format = bf.unpacked?.format ?? bf.format;
40
+ // kind tells code from data within an executable; a data file holds no code to tell apart.
41
+ if (loc.kind !== undefined && !locationRule(format)?.address)
42
+ problem(file, `location kind ${loc.kind} in ${loc.file}: a ${format} file is not an executable, so its locations give no kind`);
43
+ else if (loc.kind !== undefined && loc.kind !== "code" && loc.kind !== "file-data")
44
+ problem(file, `location kind ${loc.kind} in ${loc.file}: kind must be code or file-data when given`);
45
+ const fileData = loc.kind === "file-data";
46
+ if (fileData && "address" in loc)
47
+ problem(file, `a file-data location in ${loc.file} gives a file offset, not an address`);
48
+ // A file-data location in a packed file may name bytes that exist only once it is unpacked,
49
+ // such as the relocation table an unpacker writes, by an offset into the unpacked form.
50
+ const intoUnpacked = loc.unpacked === true;
51
+ if ("unpacked" in loc && !intoUnpacked)
52
+ problem(file, `a location in ${loc.file} gives unpacked: true or leaves it out`);
53
+ else if (intoUnpacked && !fileData)
54
+ problem(file, `a location in ${loc.file} gives unpacked: true only with kind: file-data`);
55
+ else if (intoUnpacked && !bf.packer)
56
+ problem(file, `a location in ${loc.file} gives unpacked: true, but ${loc.file} is not packed`);
57
+ if ("address" in loc && "offset" in loc)
58
+ problem(file, "a location gives address or offset, not both");
59
+ if ("address" in loc) {
60
+ if (!fileData)
61
+ checkAddress(problem, file, loc.address, format);
62
+ }
63
+ else if ("offset" in loc) {
64
+ // Explicit file-data offsets name shipped container metadata or data, never code.
65
+ // Other offsets name bytes of the shipped file: data, CD audio, or MZ overlay code
66
+ // outside the load image. The finding must establish the overlay mapping. Like an
67
+ // address, an offset is judged by the unpacked format, so a packed MZ stub around LE
68
+ // or PE code cannot use offsets for code the loader maps.
69
+ const rule = locationRule(format);
70
+ if (!fileData && rule && !rule.offset)
71
+ problem(file, `location in ${loc.file} gives an offset; a ${format} executable is located by address (only MZ overlay code uses offsets)`);
72
+ const range = intoUnpacked && bf.packer
73
+ ? checkOffset(problem, file, loc.offset, { path: bf.path, size: Number(bf.unpacked?.size) }, "unpacked form of")
74
+ : checkOffset(problem, file, loc.offset, bf);
75
+ // An offset into an executable locates overlay code, so it lies wholly inside one row of
76
+ // the build's Code ranges. Adjacent rows are not joined: a range that crosses from one
77
+ // into the next, such as into another bank, fails.
78
+ if (!fileData && range && rule?.offset && rule.address && codeRanges.has(loc.build)) {
79
+ const inside = codeRanges
80
+ .get(loc.build)
81
+ .some((r) => r.file === loc.file && r.start <= range[0] && range[1] <= r.end);
82
+ if (!inside)
83
+ problem(file, `offset ${loc.offset} in ${loc.file} does not lie wholly inside one of the rows the Code ranges section of ${loc.build} gives for that file`);
84
+ }
85
+ }
86
+ else
87
+ problem(file, "a location gives an address or an offset");
88
+ }
89
+ }
90
+ // A run's draws from the generator, in order. Each is named by the rule it was made under, which
91
+ // the rebuild cites too, and never by the address of the call in the original's code. A live
92
+ // experiment's draws cite living rules, as its other links do.
93
+ const DRAW_KEYS = ["rule", "bound", "result"];
94
+ function checkDraws(ctx, fixture, draws, live) {
95
+ const { problem } = ctx;
96
+ const { entries } = ctx.spec;
97
+ if (draws === undefined)
98
+ return;
99
+ if (!Array.isArray(draws))
100
+ return problem(fixture, "draws is a list");
101
+ draws.forEach((draw, i) => {
102
+ if (draw === null || typeof draw !== "object" || Array.isArray(draw))
103
+ return problem(fixture, `draw ${i} is an object with rule, bound and result`);
104
+ const extra = Object.keys(draw).filter((k) => !DRAW_KEYS.includes(k));
105
+ if (extra.length)
106
+ problem(fixture, `draw ${i} has ${extra.join(", ")}; a draw gives only rule, bound and result`);
107
+ if (entries.get(draw.rule)?.kind !== "RULE")
108
+ problem(fixture, `draw ${i} names ${draw.rule}, which is not a rule entry`);
109
+ else if (live && isSuperseded(entries, draw.rule))
110
+ problem(fixture, `draw ${i} names ${draw.rule}, which is superseded`, "STATUS-17");
111
+ if (!Number.isInteger(draw.bound) || !Number.isInteger(draw.result))
112
+ problem(fixture, `draw ${i} gives bound and result as integers`);
113
+ });
114
+ }
115
+ /** Checks an experiment's builds, fixture, starting state and recording. live is false once it is superseded. */
116
+ export function checkExperiment(ctx, id, e, isSup) {
117
+ const { problem } = ctx;
118
+ const { buildFiles, glossary } = ctx.spec;
119
+ const { specDir } = ctx.config;
120
+ const { file, meta } = e;
121
+ const builds = asList(meta.builds);
122
+ if (builds.length !== 1)
123
+ problem(file, "an experiment lists exactly one build", "ENTRY-TYPES-7");
124
+ const fixture = meta.fixture && join(specDir, "experiments", meta.fixture);
125
+ if (!fixture || !existsSync(fixture))
126
+ problem(file, `fixture ${meta.fixture} does not exist`);
127
+ else {
128
+ try {
129
+ const fx = JSON.parse(readFileSync(fixture, "utf8"));
130
+ if (fx.experiment !== id)
131
+ problem(fixture, `experiment must be ${id}`);
132
+ if (!["new-game", "emulated-call"].includes(meta.starting_state) &&
133
+ !(fx.starting_state && fx.starting_state.xxh3))
134
+ problem(fixture, "gives the hash of the save its runs started from");
135
+ if (typeof meta.starting_state === "string" &&
136
+ meta.starting_state.endsWith(".patch.json") &&
137
+ !fx.starting_state?.base_xxh3)
138
+ problem(fixture, "a patch fixture gives the base save's hash as well");
139
+ for (const run of asList(fx.runs)) {
140
+ for (const ev of asList(run?.events))
141
+ if (!glossary.has(ev?.event))
142
+ problem(fixture, `event ${ev?.event} has no glossary entry`);
143
+ checkDraws(ctx, fixture, run?.draws, !isSup);
144
+ }
145
+ if (typeof meta.recording === "string" && meta.recording !== "" && !fx.recording_xxh3)
146
+ problem(fixture, "an experiment with a recording gives the recording's hash in recording_xxh3");
147
+ }
148
+ catch (err) {
149
+ problem(fixture, `is not valid JSON: ${err.message}`);
150
+ }
151
+ }
152
+ if (typeof meta.starting_state === "string" &&
153
+ meta.starting_state.startsWith("saves/") &&
154
+ !existsSync(join(specDir, "experiments", meta.starting_state)))
155
+ problem(file, `starting_state ${meta.starting_state} does not exist`);
156
+ // A recording is committed in recordings/, kept with the captures, or one of the build's files.
157
+ if (typeof meta.recording === "string" && meta.recording !== "") {
158
+ const rec = meta.recording;
159
+ if (rec.startsWith("recordings/")) {
160
+ if (!existsSync(join(specDir, "experiments", rec)))
161
+ problem(file, `recording ${rec} does not exist`);
162
+ }
163
+ else if (!rec.startsWith("captures/") &&
164
+ !builds.some((b) => (buildFiles.get(b) ?? []).some((f) => f.path === rec)))
165
+ problem(file, `recording ${rec} is neither in recordings/ or captures/ nor a file of ${builds.join(", ")}`);
166
+ }
167
+ }
@@ -0,0 +1,4 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { FormatNames } from "./formats.ts";
3
+ /** Checks the field names of every live rule's procedure. Runs after checkRules, which sets Entry.code. */
4
+ export declare function checkFieldNames(ctx: Context, { layouts }: FormatNames): void;
@@ -0,0 +1,314 @@
1
+ // Field names in procedures: every name after a dot, on a structure whose type is known, is a Name in
2
+ // the layout of that structure's format.
3
+ //
4
+ // A type is known only where it is written in the notation's type form: `FMT-DATA-005`, a pointer to
5
+ // one (`PTR32<FMT-DATA-005>`) or a list of either (`FMT-DATA-005[]`). The checker reads it from the
6
+ // places the standard's Notation lists:
7
+ //
8
+ // - a `let` with a type, `let x = new FMT-...`, or a `let` whose value has a known type: another
9
+ // name or field, `copy` of one, a function whose `define` gives its result type, or a rule's
10
+ // `call` whose Outputs open with "Returns" and a type;
11
+ // - the rule's Parameters section, as `name: type`, and the parameters of a `define`;
12
+ // - for a name that is not a local, its glossary entry, as `name: type`, or a location in the form
13
+ // "field `x` of FMT-..." whose layout row has a type;
14
+ // - a `for each` loop variable takes the element type of the list it visits.
15
+ //
16
+ // A field takes the type in its layout row, so a chain such as `world.occupancy.cell_count` is
17
+ // followed row by row. An index into a list gives its element, and an index on a pointer gives the
18
+ // structure it points at; an index on anything else, or a field of a list, ends what is known. A
19
+ // format's mention in prose gives no type, since prose names formats for many reasons. A name whose
20
+ // type is not written in one of these forms, or that two declarations give different types, stays
21
+ // unchecked. A declaration that writes no type, such as a parameter the Parameters section describes
22
+ // in prose or an untyped parameter of a define, gives none and so differs from no other.
23
+ import { asList } from "../ids.js";
24
+ import { parameterNames, withoutCommentsAndStrings } from "./rules.js";
25
+ const sameType = (a, b) => !!a && !!b && a.format.join(" ") === b.format.join(" ") && a.pointer === b.pointer && a.list === b.list;
26
+ const FMT_ID = String.raw `FMT-[A-Z][A-Z0-9]*-\d{3,}`;
27
+ // A type in the notation that holds a structure: a format ID, a pointer to one, or a list of either.
28
+ const STRUCTURE_TYPE = String.raw `(?:[A-Z][A-Z0-9]*<)?${FMT_ID}>?(?:\[[^\]\n]*\])?`;
29
+ const WHOLE_TYPE = new RegExp(String.raw `^([A-Z][A-Z0-9]*<)?(${FMT_ID})(>)?(\[[^\]\n]*\])?$`);
30
+ // `name: type`, with the backticks around the whole or around each part.
31
+ const TYPED_NAME = new RegExp(String.raw `\x60([a-z_][a-z0-9_]*)\x60?\s*:\s*\x60?(${STRUCTURE_TYPE})(?![\w-])`, "g");
32
+ // Where the glossary says the game keeps a value: a field of a structure.
33
+ const LOCATION = new RegExp(String.raw `\bfield\s+\x60([A-Za-z_][A-Za-z0-9_]*)\x60\s+of\s+(${FMT_ID})|\x60([A-Za-z_][A-Za-z0-9_]*)\x60\s+field\s+of\s+(${FMT_ID})`, "g");
34
+ const RETURNS = new RegExp(String.raw `^Returns\s+(?:an?\s+)?\x60?(${STRUCTURE_TYPE})\x60?(?=[\s,.;]|$)`);
35
+ const LOWER = /[a-z_][a-z0-9_]*/y;
36
+ const FIELD = /[A-Za-z_][A-Za-z0-9_]*/y;
37
+ // Whether the bracket that opens at text[open] closes at the end of text.
38
+ function closesAtEnd(text, open) {
39
+ let depth = 0;
40
+ for (let k = open; k < text.length; k++) {
41
+ if (text[k] === "(")
42
+ depth++;
43
+ else if (text[k] === ")" && --depth === 0)
44
+ return k === text.length - 1;
45
+ }
46
+ return false;
47
+ }
48
+ // Reads a chain that starts at i, or returns null when text[i] does not start a lower-case name.
49
+ function readChain(text, i) {
50
+ LOWER.lastIndex = i;
51
+ const m = LOWER.exec(text);
52
+ if (!m)
53
+ return null;
54
+ const steps = [];
55
+ const upTo = [];
56
+ let j = i + m[0].length;
57
+ for (;;) {
58
+ let k = j;
59
+ while (text[k] === " ")
60
+ k++;
61
+ if (text[k] === "[") {
62
+ let depth = 0;
63
+ for (; k < text.length; k++) {
64
+ if (text[k] === "[")
65
+ depth++;
66
+ else if (text[k] === "]" && --depth === 0)
67
+ break;
68
+ }
69
+ if (k >= text.length)
70
+ break;
71
+ j = k + 1;
72
+ steps.push(null);
73
+ upTo.push(text.slice(i, j));
74
+ continue;
75
+ }
76
+ if (text[j] !== ".")
77
+ break;
78
+ FIELD.lastIndex = j + 1;
79
+ const f = FIELD.exec(text);
80
+ if (!f)
81
+ break;
82
+ j += 1 + f[0].length;
83
+ steps.push(f[0]);
84
+ upTo.push(text.slice(i, j));
85
+ }
86
+ return { base: m[0], steps, upTo, end: j };
87
+ }
88
+ /** Checks the field names of every live rule's procedure. Runs after checkRules, which sets Entry.code. */
89
+ export function checkFieldNames(ctx, { layouts }) {
90
+ const { problem } = ctx;
91
+ const { entries, glossary } = ctx.spec;
92
+ // A format ID and the entries it is split with, which define the same fields.
93
+ const formatOf = (id) => {
94
+ const e = entries.get(id);
95
+ if (!e || e.kind !== "FMT")
96
+ return null;
97
+ return [id, ...asList(e.meta.split_with).filter((x) => entries.get(x)?.kind === "FMT")].sort();
98
+ };
99
+ // The type a type written in the notation gives, or null for a type that holds no structure.
100
+ const typeOf = (text) => {
101
+ const m = WHOLE_TYPE.exec((text ?? "").replaceAll("`", "").trim());
102
+ if (!m || !m[1] !== !m[3])
103
+ return null;
104
+ const format = formatOf(m[2]);
105
+ return format ? { format, pointer: !!m[1], list: !!m[4] } : null;
106
+ };
107
+ // The types text gives names in the form `name: type`; a name given two types has none.
108
+ const typedNames = (text) => {
109
+ const found = new Map();
110
+ for (const m of text.matchAll(TYPED_NAME)) {
111
+ const type = typeOf(m[2]);
112
+ found.set(m[1], found.has(m[1]) && !sameType(found.get(m[1]), type) ? null : type);
113
+ }
114
+ return found;
115
+ };
116
+ // The layout rows of a format, or null when one of its entries has a malformed layout table. A
117
+ // field whose entries give it different types has no type.
118
+ const formatRows = new Map();
119
+ const plain = (type) => type.replaceAll("`", "").trim();
120
+ const rowsOf = (format) => {
121
+ const key = format.join(" ");
122
+ if (formatRows.has(key))
123
+ return formatRows.get(key);
124
+ let rows = new Map();
125
+ for (const id of format) {
126
+ const layout = layouts.get(id);
127
+ if (!layout) {
128
+ rows = null;
129
+ break;
130
+ }
131
+ for (const [name, type] of layout)
132
+ rows.set(name, rows.has(name) && plain(rows.get(name)) !== plain(type) ? "" : type);
133
+ }
134
+ formatRows.set(key, rows);
135
+ return rows;
136
+ };
137
+ const termTypes = new Map();
138
+ const termType = (term) => {
139
+ if (termTypes.has(term))
140
+ return termTypes.get(term);
141
+ const text = glossary.get(term) ?? "";
142
+ const typed = typedNames(text);
143
+ let type = null;
144
+ if (typed.has(term))
145
+ type = typed.get(term);
146
+ else {
147
+ const locations = [...text.matchAll(LOCATION)];
148
+ if (locations.length === 1) {
149
+ const format = formatOf(locations[0][2] ?? locations[0][4]);
150
+ type = format ? typeOf(rowsOf(format)?.get(locations[0][1] ?? locations[0][3])) : null;
151
+ }
152
+ }
153
+ termTypes.set(term, type);
154
+ return type;
155
+ };
156
+ // The type of the value a rule returns, from the opening of its Outputs section.
157
+ const returnType = (id) => {
158
+ const outputs = entries
159
+ .get(id)
160
+ ?.sections.find((s) => s.title === "Outputs")
161
+ ?.text.trim() ?? "";
162
+ const m = RETURNS.exec(outputs);
163
+ return m ? typeOf(m[1]) : null;
164
+ };
165
+ const live = [...entries.values()].filter((e) => e.kind === "RULE" && e.meta.status !== "superseded" && e.code);
166
+ // Functions whose define gives a structure as the result: define name(...) -> FMT-...
167
+ const functionTypes = new Map();
168
+ for (const e of live)
169
+ for (const m of withoutCommentsAndStrings(e.code).matchAll(/\bdefine\s+([a-z_][a-z0-9_]*)\s*\([^)]*\)\s*->\s*([^:\n]+):/g)) {
170
+ const type = typeOf(m[2]);
171
+ // The entries of a split rule define the same function; if they disagree, its type is unknown.
172
+ const disagrees = functionTypes.has(m[1]) && !sameType(functionTypes.get(m[1]), type);
173
+ functionTypes.set(m[1], disagrees ? null : type);
174
+ }
175
+ for (const e of live)
176
+ checkRule(e);
177
+ function checkRule(e) {
178
+ const lines = withoutCommentsAndStrings(e.code).split("\n");
179
+ const params = e.sections.find((s) => s.title === "Parameters")?.text ?? "";
180
+ const declarations = [];
181
+ const declare = (name, type) => declarations.push({ name, type });
182
+ const fixed = (type, source) => () => type === undefined ? undefined : type ? { ...type, source } : null;
183
+ const typedParams = typedNames(params);
184
+ for (const name of parameterNames(params))
185
+ declare(name, fixed(typedParams.has(name) ? typedParams.get(name) : undefined, "the Parameters section"));
186
+ for (const line of lines) {
187
+ const d = /\bdefine\s+[a-z_][a-z0-9_]*\s*\(([^)]*)\)/.exec(line);
188
+ if (d)
189
+ for (const p of d[1].split(",")) {
190
+ const [name, type] = p.split(":");
191
+ if (/^[a-z_][a-z0-9_]*$/.test(name.trim()))
192
+ declare(name.trim(), fixed(type === undefined ? undefined : typeOf(type), "its define"));
193
+ }
194
+ const l = /^\s*let\s+([a-z_][a-z0-9_]*)\s*(?::\s*([^=]+?))?\s*=\s*(.+?)\s*$/.exec(line);
195
+ if (l) {
196
+ const [, name, type, value] = l;
197
+ if (type)
198
+ declare(name, fixed(typeOf(type), "its let"));
199
+ else
200
+ declare(name, (known) => valueType(value, known, "its let"));
201
+ }
202
+ const f = /^\s*for\s+(?:each\s+)?([a-z_][a-z0-9_]*)\s+in\s+(.+?)\s*:\s*$/.exec(line);
203
+ if (f)
204
+ declare(f[1], (known) => {
205
+ const list = f[2].includes("..") ? null : valueType(f[2], known, "the list its loop visits");
206
+ return list?.list ? { ...list, list: false } : null;
207
+ });
208
+ }
209
+ const locals = new Set(declarations.map((d) => d.name));
210
+ // A let can take its type from another local, so the types are worked out again until they
211
+ // stop changing.
212
+ let types = new Map();
213
+ const known = (name) => {
214
+ if (locals.has(name))
215
+ return types.get(name) ?? null;
216
+ const type = termType(name);
217
+ return type ? { ...type, source: `the glossary entry for ${name}` } : null;
218
+ };
219
+ const settledAs = (a, b) => a.size === b.size && [...a].every(([name, t]) => sameType(t, b.get(name) ?? null));
220
+ for (let pass = 0; pass <= declarations.length; pass++) {
221
+ const found = new Map();
222
+ for (const d of declarations) {
223
+ const t = d.type(known);
224
+ if (t === undefined)
225
+ continue;
226
+ found.set(d.name, found.has(d.name) && !sameType(found.get(d.name), t) ? null : t);
227
+ }
228
+ const next = new Map();
229
+ for (const [name, t] of found)
230
+ if (t)
231
+ next.set(name, t);
232
+ const settled = settledAs(next, types);
233
+ types = next;
234
+ if (settled)
235
+ break;
236
+ }
237
+ for (const line of lines)
238
+ for (let i = 0; i < line.length; i++) {
239
+ if (/[\w.]/.test(line[i - 1] ?? ""))
240
+ continue;
241
+ const chain = readChain(line, i);
242
+ if (!chain)
243
+ continue;
244
+ // Names inside the chain's indexes start chains of their own.
245
+ i += chain.base.length - 1;
246
+ if (chain.steps.some((s) => s !== null))
247
+ follow(chain, known, (message) => problem(e.file, message));
248
+ }
249
+ }
250
+ // The type of the value of a let, or of the list a for each loop visits, or null when it has none.
251
+ function valueType(value, known, source) {
252
+ const v = value.trim();
253
+ const made = new RegExp(String.raw `^new\s+(${FMT_ID})$`).exec(v);
254
+ if (made) {
255
+ const format = formatOf(made[1]);
256
+ return format ? { format, pointer: false, list: false, source } : null;
257
+ }
258
+ // A call is the whole value only when the bracket after its name closes at the end, so
259
+ // make(a) + other(b) is not a call of make.
260
+ const called = /^call\s+(RULE-[A-Z][A-Z0-9]*-\d{3,})\s*\(/.exec(v);
261
+ if (called && closesAtEnd(v, called[0].length - 1)) {
262
+ const type = returnType(called[1]);
263
+ return type ? { ...type, source: `the Outputs of ${called[1]}` } : null;
264
+ }
265
+ const copied = /^copy\s*\(/.exec(v);
266
+ if (copied && closesAtEnd(v, copied[0].length - 1))
267
+ return valueType(v.slice(copied[0].length, -1), known, source);
268
+ const fn = /^([a-z_][a-z0-9_]*)\s*\(/.exec(v);
269
+ if (fn && closesAtEnd(v, fn[0].length - 1)) {
270
+ const type = functionTypes.get(fn[1]);
271
+ return type ? { ...type, source: `the define of ${fn[1]}` } : null;
272
+ }
273
+ const chain = readChain(v, 0);
274
+ if (!chain || chain.end !== v.length)
275
+ return null;
276
+ const t = follow(chain, known);
277
+ return t ? { ...t, source } : null;
278
+ }
279
+ // Follows a chain access by access and returns the type it ends with. With report, a field missing
280
+ // from its structure's layout is reported.
281
+ function follow(chain, known, report) {
282
+ const base = known(chain.base);
283
+ if (!base)
284
+ return null;
285
+ let type = base;
286
+ for (const [n, field] of chain.steps.entries()) {
287
+ if (field === null) {
288
+ // list[i] is an element, and p[i] is the structure p + i points at.
289
+ if (type.list)
290
+ type = { ...type, list: false };
291
+ else if (type.pointer)
292
+ type = { ...type, pointer: false };
293
+ else
294
+ return null;
295
+ continue;
296
+ }
297
+ if (type.list)
298
+ return null;
299
+ const rows = rowsOf(type.format);
300
+ if (!rows)
301
+ return null;
302
+ const cell = rows.get(field);
303
+ if (cell === undefined) {
304
+ report?.(`${chain.upTo[n]} names ${field}, which is not in the layout of ${type.format.join(" or ")} (${chain.base} has its type from ${base.source})`);
305
+ return null;
306
+ }
307
+ const next = typeOf(cell);
308
+ if (!next)
309
+ return null;
310
+ type = next;
311
+ }
312
+ return { ...type, source: base.source };
313
+ }
314
+ }
@@ -0,0 +1,19 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { Entry } from "../types.ts";
3
+ /** The names the formats' tables define. */
4
+ export interface FormatNames {
5
+ /** Enumeration name -> the formats that define it, once per row. */
6
+ enumNames: Map<string, string[]>;
7
+ /** Format ID -> the names of its layout and enumeration rows. */
8
+ fieldNames: Map<string, Set<string>>;
9
+ /**
10
+ * Format ID -> the Name of each layout row -> its Type cell, for a format whose layout tables all
11
+ * have the right columns and whose rows all have one cell per column. A format without a Layout
12
+ * table has an empty map.
13
+ */
14
+ layouts: Map<string, Map<string, string>>;
15
+ }
16
+ /** The IDs cited in the last column of the tables of an entry's sections (of every section when sectionTitles is left out). */
17
+ export declare function tableIds(e: Entry, sectionTitles?: string[]): Set<string>;
18
+ /** Checks a format entry, adding the names its tables define to formatNames. */
19
+ export declare function checkFormat(ctx: Context, e: Entry, formatNames: FormatNames): void;