@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.
Files changed (77) 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 +130 -0
  14. package/dist/checks/evidence-entries.d.ts +6 -0
  15. package/dist/checks/evidence-entries.js +167 -0
  16. package/dist/checks/formats.d.ts +13 -0
  17. package/dist/checks/formats.js +194 -0
  18. package/dist/checks/kaitai.d.ts +6 -0
  19. package/dist/checks/kaitai.js +103 -0
  20. package/dist/checks/parity.d.ts +30 -0
  21. package/dist/checks/parity.js +189 -0
  22. package/dist/checks/references.d.ts +4 -0
  23. package/dist/checks/references.js +37 -0
  24. package/dist/checks/rules.d.ts +4 -0
  25. package/dist/checks/rules.js +259 -0
  26. package/dist/checks/screens.d.ts +4 -0
  27. package/dist/checks/screens.js +44 -0
  28. package/dist/checks/validation.d.ts +7 -0
  29. package/dist/checks/validation.js +116 -0
  30. package/dist/code-comments.d.ts +3 -0
  31. package/dist/code-comments.js +150 -0
  32. package/dist/code-files.d.ts +11 -0
  33. package/dist/code-files.js +41 -0
  34. package/dist/context.d.ts +34 -0
  35. package/dist/context.js +2 -0
  36. package/dist/evidence.d.ts +37 -0
  37. package/dist/evidence.js +91 -0
  38. package/dist/files.d.ts +11 -0
  39. package/dist/files.js +35 -0
  40. package/dist/generate/indexes.d.ts +3 -0
  41. package/dist/generate/indexes.js +135 -0
  42. package/dist/generate/layout.d.ts +18 -0
  43. package/dist/generate/layout.js +49 -0
  44. package/dist/generate/parity-md.d.ts +7 -0
  45. package/dist/generate/parity-md.js +44 -0
  46. package/dist/generate/write.d.ts +8 -0
  47. package/dist/generate/write.js +75 -0
  48. package/dist/ids.d.ts +13 -0
  49. package/dist/ids.js +22 -0
  50. package/dist/load/builds.d.ts +9 -0
  51. package/dist/load/builds.js +124 -0
  52. package/dist/load/code-ranges.d.ts +4 -0
  53. package/dist/load/code-ranges.js +79 -0
  54. package/dist/load/entries.d.ts +7 -0
  55. package/dist/load/entries.js +57 -0
  56. package/dist/load/glossary.d.ts +12 -0
  57. package/dist/load/glossary.js +70 -0
  58. package/dist/load/readme.d.ts +3 -0
  59. package/dist/load/readme.js +36 -0
  60. package/dist/load/spec.d.ts +6 -0
  61. package/dist/load/spec.js +25 -0
  62. package/dist/locations.d.ts +19 -0
  63. package/dist/locations.js +57 -0
  64. package/dist/markdown.d.ts +27 -0
  65. package/dist/markdown.js +162 -0
  66. package/dist/options.d.ts +34 -0
  67. package/dist/options.js +87 -0
  68. package/dist/problems.d.ts +27 -0
  69. package/dist/problems.js +27 -0
  70. package/dist/standard-checker.js +51 -3033
  71. package/dist/standard.d.ts +65 -0
  72. package/dist/standard.js +173 -0
  73. package/dist/types.d.ts +91 -0
  74. package/dist/types.js +2 -0
  75. package/dist/yaml.d.ts +6 -0
  76. package/dist/yaml.js +173 -0
  77. package/package.json +1 -1
@@ -0,0 +1,130 @@
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
+ };
44
+ for (const [id, e] of entries) {
45
+ const { file, meta, kind } = e;
46
+ checkIdForm(ctx, file, id);
47
+ for (const f of FIELDS[kind].required)
48
+ if (!(f in meta))
49
+ problem(file, `front matter lacks ${f}`, FIELD_RULES[f]);
50
+ if (!Array.isArray(meta.superseded_by))
51
+ problem(file, "superseded_by must be a list", FIELD_RULES.superseded_by);
52
+ const expectedSections = SECTIONS[kind];
53
+ const got = e.sections.map((s) => s.title);
54
+ if (got.join("|") !== expectedSections.join("|"))
55
+ problem(file, `sections must be ${expectedSections.join(", ")} in that order; found ${got.join(", ") || "none"}`, "ENTRY-TYPES-1");
56
+ for (const s of e.sections)
57
+ if (s.text.trim() === "")
58
+ problem(file, `section ${s.title} is empty; write None known. or None.`, "ENTRY-TYPES-2");
59
+ const superseded = asList(meta.superseded_by);
60
+ const status = meta.status;
61
+ if (KINDS[kind].statuses === "claim" && !CLAIM_STATUSES.includes(status))
62
+ problem(file, `status ${status} is not one of ${CLAIM_STATUSES.join(", ")}`, "STATUS-1");
63
+ if (KINDS[kind].statuses === "evidence" && !EVIDENCE_STATUSES.includes(status))
64
+ problem(file, `status ${status} is not one of ${EVIDENCE_STATUSES.join(", ")}`, "STATUS-21");
65
+ const isSup = status === "superseded" || ((kind === "BLD" || kind === "SRC") && superseded.length > 0);
66
+ if (status === "superseded" && superseded.length === 0)
67
+ problem(file, "a superseded entry names what replaced or disproved it in superseded_by", "IDENTIFIERS-7");
68
+ if (status && status !== "superseded" && superseded.length > 0)
69
+ problem(file, "superseded_by must be empty unless the status is superseded", "ENTRY-TYPES-4");
70
+ checkResolves(ctx, file, superseded, "superseded_by");
71
+ for (const s of superseded) {
72
+ const k = kindOf(s);
73
+ const ok = ["FND", "EXP"].includes(kind)
74
+ ? ["FND", "EXP"].includes(k)
75
+ : kind === "BLD"
76
+ ? k === "BLD"
77
+ : kind === "SRC"
78
+ ? k === "SRC"
79
+ : kind === "BUG"
80
+ ? true
81
+ : k !== "SRC" && k !== "BLD";
82
+ if (!ok)
83
+ problem(file, `superseded_by may not name ${s}`, "IDENTIFIERS-7");
84
+ }
85
+ if (kind !== "BLD" && kind !== "SRC") {
86
+ const builds = asList(meta.builds);
87
+ if (builds.length === 0)
88
+ problem(file, "builds must list at least one build");
89
+ checkResolves(ctx, file, builds, "builds");
90
+ for (const b of builds)
91
+ if (entries.has(b) && kindOf(b) !== "BLD")
92
+ problem(file, `builds lists ${b}, which is not a build`);
93
+ }
94
+ // Links that must not point at superseded entries
95
+ if (!isSup) {
96
+ const linkFields = ["builds", "evidence", "conflicting", "related"];
97
+ for (const f of linkFields)
98
+ for (const t of asList(meta[f]))
99
+ if (entries.has(t) && isSuperseded(entries, t))
100
+ problem(file, `${f} cites ${t}, which is superseded`, "STATUS-17");
101
+ for (const loc of asList(meta.locations))
102
+ if (loc && entries.has(loc.build) && isSuperseded(entries, loc.build))
103
+ problem(file, `a location names ${loc.build}, which is superseded`, "STATUS-17");
104
+ }
105
+ if (["FND", "EXP"].includes(kind)) {
106
+ const rep = asList(meta.reproduced_by);
107
+ if (status === "reproduced" && rep.every((p) => p === meta.recorded_by))
108
+ problem(file, "a reproduced entry names someone other than recorded_by in reproduced_by", "STATUS-21");
109
+ if (status !== "reproduced" && rep.length > 0)
110
+ problem(file, "reproduced_by must be empty unless the status is reproduced", "STATUS-21");
111
+ if (typeof meta.recorded_by !== "string" || !meta.recorded_by)
112
+ problem(file, "recorded_by must be a GitHub username");
113
+ }
114
+ if (kind === "FND")
115
+ checkFinding(ctx, e);
116
+ if (kind === "EXP")
117
+ checkExperiment(ctx, id, e, isSup);
118
+ if (KINDS[kind].statuses === "claim")
119
+ checkClaim(ctx, id, e);
120
+ if (kind === "BLD" && ![16, 32].includes(meta.int_width))
121
+ problem(file, "int_width must be 16 or 32");
122
+ if (kind === "SRC" && meta.xxh3 !== null && !/^[0-9a-f]{32}$/.test(String(meta.xxh3)))
123
+ problem(file, "xxh3 must be null or 32 lower-case hex digits");
124
+ if (kind === "FMT")
125
+ checkFormat(ctx, e, names);
126
+ if (kind === "SCR")
127
+ checkScreen(ctx, e);
128
+ }
129
+ return names;
130
+ }
@@ -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,13 @@
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
+ /** The IDs cited in the last column of the tables of an entry's sections (of every section when sectionTitles is left out). */
11
+ export declare function tableIds(e: Entry, sectionTitles?: string[]): Set<string>;
12
+ /** Checks a format entry, adding the names its tables define to formatNames. */
13
+ export declare function checkFormat(ctx: Context, e: Entry, formatNames: FormatNames): void;
@@ -0,0 +1,194 @@
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 } = 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
+ if (layout) {
92
+ const ts = tables(layout.text);
93
+ const wanted = meta.text === true ? TEXT_LAYOUT : BINARY_LAYOUT;
94
+ if (meta.status !== "unknown" && ts.length === 0)
95
+ problem(file, "Layout has no table");
96
+ for (const t of ts) {
97
+ if (t.header.join("|") !== wanted.join("|"))
98
+ problem(file, `a layout table has the columns ${wanted.join(" | ")}`);
99
+ else
100
+ visit(t, "layout");
101
+ }
102
+ }
103
+ // An enumeration table kept in a value file counts as one of the entry's tables.
104
+ const enumTables = enums ? [...tables(enums.text), ...valueFileTables(ctx, e, enums.text)] : [];
105
+ e.valueTables = enumTables.filter((t) => t.file);
106
+ for (const t of enumTables) {
107
+ if (t.header.join("|") !== ENUM_TABLE.join("|")) {
108
+ problem(t.file ?? file, `an enumeration table has the columns ${ENUM_TABLE.join(" | ")}`);
109
+ continue;
110
+ }
111
+ if (!t.heading)
112
+ problem(file, "an enumeration table sits under a ### heading naming its fields");
113
+ else
114
+ for (const f of (t.heading.match(/`([^`]+)`/g) ?? t.heading.split(/\s*,\s*|\s+and\s+/))
115
+ .map((x) => x.replaceAll("`", "").trim())
116
+ .filter(Boolean))
117
+ if (!names.has(f))
118
+ problem(file, `enumeration heading names ${f}, which is not a field of the layout`);
119
+ visit(t, "enumeration");
120
+ for (const row of t.rows) {
121
+ if (row.length !== t.header.length)
122
+ continue; // visit reported it
123
+ const n = row[1].replaceAll("`", "");
124
+ if (!/^[A-Z][A-Z0-9_]*$/.test(n))
125
+ problem(file, `enumeration name ${n} must be upper-case letters, digits and underscores`);
126
+ if (!enumNames.has(n))
127
+ enumNames.set(n, []);
128
+ enumNames.get(n).push(id);
129
+ }
130
+ }
131
+ if (meta.status !== "superseded" && meta.status !== "unknown") {
132
+ const expected = disputed ? "disputed" : lowest;
133
+ if (expected && meta.status !== expected)
134
+ problem(file, `status must be ${expected}, the lowest status among its rows`);
135
+ }
136
+ const cited = tableIds(e, ["Layout", "Enumerations and flags"]);
137
+ for (const t of e.valueTables)
138
+ for (const row of t.rows)
139
+ for (const x of idsIn(row[t.header.length - 1]))
140
+ cited.add(x);
141
+ const listed = new Set([...asList(meta.evidence), ...asList(meta.conflicting)]);
142
+ for (const x of cited)
143
+ if (!listed.has(x) && ["FND", "EXP", "SRC"].includes(kindOf(x)))
144
+ problem(file, `${x} is cited in a table but not in evidence or conflicting`);
145
+ for (const t of tables(layout?.text ?? ""))
146
+ for (const row of t.rows)
147
+ for (const x of idsIn(row[t.header.indexOf("Meaning")]))
148
+ if (kindOf(x) === "RULE" && !asList(meta.related).includes(x))
149
+ problem(file, `layout names ${x}; add it to related`, "ENTRY-TYPES-6");
150
+ // Kaitai definition
151
+ if (meta.definition && existsSync(join(dirname(file), meta.definition))) {
152
+ const ksy = readFileSync(join(dirname(file), meta.definition), "utf8");
153
+ const expectedId = id.toLowerCase().replaceAll("-", "_");
154
+ if (!new RegExp(`^\\s*id:\\s*${expectedId}\\s*$`, "m").test(ksy))
155
+ problem(join(dirname(file), meta.definition), `meta/id must be ${expectedId}`);
156
+ if (!/^\s*license:\s*\S+/m.test(ksy))
157
+ problem(join(dirname(file), meta.definition), "meta/license must name the licence");
158
+ }
159
+ }
160
+ // The enumeration tables an entry keeps in value files: each ### heading stays in the entry, followed
161
+ // by a sentence naming its file, formats/<ID>.<table>.csv.
162
+ function valueFileTables(ctx, e, text) {
163
+ const { problem } = ctx;
164
+ const out = [];
165
+ let heading = null;
166
+ let fence = false;
167
+ for (const line of text.split("\n")) {
168
+ if (/^(```|~~~)/.test(line))
169
+ fence = !fence;
170
+ if (fence)
171
+ continue;
172
+ const h = /^### (.+)$/.exec(line);
173
+ if (h) {
174
+ heading = h[1].trim();
175
+ continue;
176
+ }
177
+ for (const m of line.matchAll(/\b([A-Z]+-[A-Z0-9]+-\d{3,}\.[A-Za-z0-9_]+\.csv)\b/g)) {
178
+ // Another entry's value file holds that entry's rows, so it is not read as one of these.
179
+ if (!m[1].startsWith(`${e.meta.id}.`)) {
180
+ problem(e.file, `value file ${m[1]} belongs to another entry; an entry's value files are named ${e.meta.id}.<table>.csv`);
181
+ continue;
182
+ }
183
+ const path = join(dirname(e.file), m[1]);
184
+ if (!existsSync(path)) {
185
+ problem(e.file, `value file ${m[1]} does not exist`);
186
+ continue;
187
+ }
188
+ const csv = readCsv(path, problem);
189
+ if (csv)
190
+ out.push({ heading, header: csv.header, rows: csv.rows, file: path });
191
+ }
192
+ }
193
+ return out;
194
+ }
@@ -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;