@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
package/README.md CHANGED
@@ -15,6 +15,12 @@ It exits with 0 when the repository passes, 1 when it reports problems (one line
15
15
  starting with the file's path), and 2 when the options are invalid. `--record-validation` cannot be
16
16
  combined with `--check`.
17
17
 
18
+ A problem that breaks a numbered rule of the standard ends with the rule's label in brackets, such
19
+ as `[STATUS-14]`. The standard opens that rule with the heading `###### STATUS-14`, anchored at
20
+ `#status-14` on the site and in the copies restorations vendor, so the rule can be read on its
21
+ own. Rules are numbered in Identifiers, Status and the shared part of Entry types so far, and
22
+ problems under other sections carry no label yet.
23
+
18
24
  ## Options
19
25
 
20
26
  | Option | Meaning | Default |
@@ -0,0 +1,7 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { Deviation } from "./deviations.ts";
3
+ /**
4
+ * Reports a spec ID, area or deviation that exists at the base (--base, or where HEAD forked from
5
+ * the base branch) and is gone now. Does nothing when there is no base to compare with.
6
+ */
7
+ export declare function checkBase(ctx: Context, deviations: Map<string, Deviation>): void;
@@ -0,0 +1,72 @@
1
+ // IDs, areas and deviations that exist on the base branch must not disappear.
2
+ import { execFileSync } from "node:child_process";
3
+ import { splitSections, tables } from "../markdown.js";
4
+ /**
5
+ * Reports a spec ID, area or deviation that exists at the base (--base, or where HEAD forked from
6
+ * the base branch) and is gone now. Does nothing when there is no base to compare with.
7
+ */
8
+ export function checkBase(ctx, deviations) {
9
+ const { problem } = ctx;
10
+ const { entries, areas } = ctx.spec;
11
+ const { repoDir, baseArg } = ctx.config;
12
+ // Without --base, compare with the point this branch left the base branch (the pull request's
13
+ // target in CI), not that branch's tip: an entry added on the base branch after this branch
14
+ // forked is not one this branch deleted.
15
+ const git = (...args) => execFileSync("git", ["-C", repoDir, ...args], { stdio: ["ignore", "pipe", "ignore"] }).toString();
16
+ let base = baseArg;
17
+ if (!base) {
18
+ const target = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "origin/main";
19
+ try {
20
+ base = git("merge-base", "HEAD", target).trim();
21
+ }
22
+ catch {
23
+ base = null;
24
+ }
25
+ }
26
+ let listing = null;
27
+ if (base)
28
+ try {
29
+ listing = git("ls-tree", "-r", "--name-only", base, "--", "spec", "deviations");
30
+ }
31
+ catch {
32
+ if (baseArg)
33
+ problem(null, `cannot list spec/ at ${base}`);
34
+ }
35
+ if (listing) {
36
+ for (const p of listing.split("\n")) {
37
+ const m = /^spec\/(?:builds|sources|formats|rules|findings|experiments|bugs|screens)\/([A-Z]+-[A-Z0-9.-]+)\.md$/.exec(p);
38
+ if (m && !entries.has(m[1]))
39
+ problem(null, `${m[1]} exists at ${base} and has been deleted or renamed`, "IDENTIFIERS-6");
40
+ const d = /^deviations\/(DEV-[A-Z0-9]+-\d+)\.md$/.exec(p);
41
+ if (d && !deviations.has(d[1]))
42
+ problem(null, `${d[1]} exists at ${base} and has been deleted or renamed`);
43
+ }
44
+ // ./ makes the path relative to --root, which need not be the top of the repository. A file
45
+ // that does not exist at the base has nothing to compare, and each is read on its own so a
46
+ // missing README does not skip the deviation comparison.
47
+ const show = (path) => {
48
+ try {
49
+ return git("show", `${base}:./${path}`).replace(/\r\n/g, "\n");
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ };
55
+ const oldReadme = show("spec/README.md");
56
+ // Reads the area table the way the current README is read, so areas with or without
57
+ // backticks are both found.
58
+ const oldAreaSection = oldReadme && splitSections(oldReadme).find((s) => s.title === "Areas");
59
+ const oldAreaTable = oldAreaSection && tables(oldAreaSection.text)[0];
60
+ for (const row of (oldAreaTable || undefined)?.rows ?? []) {
61
+ const a = row[0].replaceAll("`", "");
62
+ if (!areas.includes(a))
63
+ problem(null, `area ${a} exists at ${base} and has been removed or renamed`, "IDENTIFIERS-5");
64
+ }
65
+ // A base from before the deviation log became a directory keeps its deviations in DEVIATIONS.md.
66
+ const oldDev = show("DEVIATIONS.md");
67
+ if (oldDev)
68
+ for (const m of oldDev.matchAll(/^## (DEV-[A-Z0-9]+-\d+)$/gm))
69
+ if (!deviations.has(m[1]))
70
+ problem(null, `${m[1]} exists at ${base} and has been removed`);
71
+ }
72
+ }
@@ -0,0 +1,4 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { Entry } from "../types.ts";
3
+ /** Checks the links and status of a claim: a format, rule, bug or screen. */
4
+ export declare function checkClaim(ctx: Context, id: string, e: Entry): void;
@@ -0,0 +1,83 @@
1
+ // The checks of a claim's links (evidence, conflicting, related, split_with, complete_reading), of
2
+ // its status against the evidence it cites, and of a bug's own fields.
3
+ import { checkResolves, checkStatusCitations, evidenceFacts } from "../evidence.js";
4
+ import { asList, kindOf } from "../ids.js";
5
+ import { CLAIM_LINKS, FIELD_RULES, RELATED_KINDS } from "../standard.js";
6
+ /** Checks the links and status of a claim: a format, rule, bug or screen. */
7
+ export function checkClaim(ctx, id, e) {
8
+ const { problem } = ctx;
9
+ const { entries } = ctx.spec;
10
+ const { file, meta, kind } = e;
11
+ const status = meta.status;
12
+ for (const f of CLAIM_LINKS)
13
+ if (!Array.isArray(meta[f]))
14
+ problem(file, `${f} must be a list`, FIELD_RULES[f]);
15
+ const evidence = asList(meta.evidence);
16
+ const conflicting = asList(meta.conflicting);
17
+ const related = asList(meta.related);
18
+ const split = asList(meta.split_with);
19
+ checkResolves(ctx, file, evidence, "evidence");
20
+ checkResolves(ctx, file, conflicting, "conflicting");
21
+ checkResolves(ctx, file, related, "related");
22
+ checkResolves(ctx, file, split, "split_with");
23
+ if ("complete_reading" in meta) {
24
+ if (!Array.isArray(meta.complete_reading))
25
+ problem(file, "complete_reading must be a list");
26
+ const reading = asList(meta.complete_reading);
27
+ checkResolves(ctx, file, reading, "complete_reading");
28
+ const first = asList(meta.builds)[0];
29
+ for (const x of reading) {
30
+ const f = entries.get(x);
31
+ if (!f)
32
+ continue;
33
+ if (f.kind !== "FND" || f.meta.method !== "static")
34
+ problem(file, `complete_reading may hold only static findings, not ${x}`, "STATUS-4");
35
+ else if (!evidence.includes(x))
36
+ problem(file, `complete_reading names ${x}; list it in evidence as well`, "STATUS-4");
37
+ else if (!asList(f.meta.builds).includes(first))
38
+ problem(file, `complete_reading names ${x}, which does not list the first build ${first}`, "STATUS-4");
39
+ }
40
+ }
41
+ for (const x of evidence)
42
+ if (!["FND", "EXP", "SRC"].includes(kindOf(x)))
43
+ problem(file, `evidence may hold only findings, experiments and sources, not ${x}`, "ENTRY-TYPES-5");
44
+ for (const x of conflicting)
45
+ if (!["FND", "EXP"].includes(kindOf(x)))
46
+ problem(file, `conflicting may hold only findings and experiments, not ${x}`, "ENTRY-TYPES-5");
47
+ if (conflicting.length > 0 && status !== "disputed")
48
+ problem(file, "conflicting must be empty unless the status is disputed", "ENTRY-TYPES-5");
49
+ for (const x of related)
50
+ if (!RELATED_KINDS[kind].includes(kindOf(x)))
51
+ problem(file, `related may not link to ${x}`, "ENTRY-TYPES-6");
52
+ for (const s of split) {
53
+ const other = entries.get(s);
54
+ if (!other)
55
+ continue;
56
+ if (!asList(other.meta.split_with).includes(id))
57
+ problem(file, `${s} does not name ${id} back in split_with`, "ENTRY-TYPES-8");
58
+ for (const b of asList(meta.builds))
59
+ if (asList(other.meta.builds).includes(b))
60
+ problem(file, `${s} is split from this entry but also lists ${b}`, "ENTRY-TYPES-8");
61
+ }
62
+ if (status !== "superseded") {
63
+ const facts = evidenceFacts(entries, e);
64
+ checkStatusCitations(problem, file, status, facts, conflicting);
65
+ if (["supported", "established"].includes(status)) {
66
+ for (const b of asList(meta.builds)) {
67
+ const covered = evidence.some((x) => ["FND", "EXP"].includes(kindOf(x)) && asList(entries.get(x)?.meta.builds).includes(b));
68
+ if (!covered)
69
+ problem(file, `lists ${b}, but no finding or experiment it cites lists that build`, "ENTRY-TYPES-7");
70
+ }
71
+ }
72
+ }
73
+ if (kind === "BUG") {
74
+ if (!["crash", "hang", "save-corruption", "rules", "presentation", "performance"].includes(meta.impact))
75
+ problem(file, "impact must be crash, hang, save-corruption, rules, presentation or performance");
76
+ if (!["unintended", "unclear"].includes(meta.intent))
77
+ problem(file, "intent must be unintended or unclear");
78
+ if (!["relied-on", "not-relied-on", "unknown"].includes(meta.player_reliance))
79
+ problem(file, "player_reliance must be relied-on, not-relied-on or unknown");
80
+ if (!related.some((x) => ["RULE", "FMT", "SCR"].includes(kindOf(x))))
81
+ problem(file, "a bug names at least one rule, format or screen in related", "ENTRY-TYPES-6");
82
+ }
83
+ }
@@ -0,0 +1,3 @@
1
+ import type { Context } from "../context.ts";
2
+ /** Checks the addresses that comments in the --code and --references directories give. */
3
+ export declare function checkCommentAddresses(ctx: Context): void;
@@ -0,0 +1,111 @@
1
+ // Addresses in code comments: an address of the original that a comment in the code gives,
2
+ // written 0x… or as a neutral name (fn_…, g_…), is recorded in an entry the comment cites, or in an
3
+ // entry that one of those cites as evidence. Evidence lives in the spec, so a comment that relies on
4
+ // an address cites the finding that shows it; citing an ID only proves that the ID exists. A
5
+ // superseded entry records nothing.
6
+ //
7
+ // Comments are found by reading .cs, .ts, .js and .mjs files as code, so `//` inside a string is
8
+ // not a comment and `/* … */` is. A comment block is a run of consecutive lines that hold only
9
+ // comment; a comment that trails code also takes the block above it and the comment lines below
10
+ // it that start in the same column, and a comment-only line among those finds the same block. An
11
+ // entry records an address written in its locations or its text, singly or inside a half-open
12
+ // range, in either case. A range of more than --max-range bytes describes a section or a whole
13
+ // table, not a place, and records only its two ends, nothing inside it: it would otherwise vouch
14
+ // for every address in the program on behalf of each entry that cites it.
15
+ //
16
+ // A neutral name is always an address. A plain 0x value is one only inside an image that --images
17
+ // gives, so colours, masks and offsets in the same notation are left alone; without --images only
18
+ // neutral names are checked. Only flat 32-bit addresses are read: a segmented address (MZ, NE) is
19
+ // not checked.
20
+ import { codeComments } from "../code-comments.js";
21
+ import { isSuperseded } from "../evidence.js";
22
+ import { asList, idsIn } from "../ids.js";
23
+ /** Checks the addresses that comments in the --code and --references directories give. */
24
+ export function checkCommentAddresses(ctx) {
25
+ const { problem } = ctx;
26
+ const { entries } = ctx.spec;
27
+ const { images, maxRange } = ctx.config;
28
+ const ADDRESS_RE = /(?<![0-9A-Za-z_])(0x|fn_|g_)([0-9A-Fa-f]{8})(?![0-9A-Za-z_])/g;
29
+ const RANGE_RE = /(?<![0-9A-Za-z_])(?:0x|fn_|g_)([0-9A-Fa-f]{8})(?:\.\.0x([0-9A-Fa-f]{8}))?(?![0-9A-Za-z_])/g;
30
+ const inImage = (value) => images.some(([low, high]) => value >= low && value < high);
31
+ const recorded = new Map(); // entry ID -> half-open [low, high) ranges it records
32
+ const rangesOf = (id) => {
33
+ if (recorded.has(id))
34
+ return recorded.get(id);
35
+ const e = entries.get(id);
36
+ const ranges = [];
37
+ if (e && !isSuperseded(entries, id)) {
38
+ const text = [
39
+ e.body,
40
+ ...asList(e.meta.locations).map((loc) => loc && typeof loc === "object" && "address" in loc ? String(loc.address) : ""),
41
+ ].join("\n");
42
+ for (const [, low, high] of text.matchAll(RANGE_RE)) {
43
+ const range = high === undefined ? [parseInt(low, 16), parseInt(low, 16) + 1] : [parseInt(low, 16), parseInt(high, 16)];
44
+ if (range[1] <= range[0])
45
+ continue;
46
+ // A larger range records only its two ends, which the entry writes out.
47
+ if (range[1] - range[0] <= maxRange)
48
+ ranges.push(range);
49
+ else
50
+ ranges.push([range[0], range[0] + 1], [range[1] - 1, range[1]]);
51
+ }
52
+ }
53
+ recorded.set(id, ranges);
54
+ return ranges;
55
+ };
56
+ const reach = (ids) => [
57
+ ...new Set([...ids, ...ids.flatMap((id) => asList(entries.get(id)?.meta.evidence).map(String))]),
58
+ ];
59
+ for (const { file, text } of ctx.codeFiles()) {
60
+ if (!/\.(cs|ts|js|mjs)$/.test(file))
61
+ continue;
62
+ const lines = codeComments(text, !file.endsWith(".cs"));
63
+ const commentOnly = (k) => k >= 0 && k < lines.length && !lines[k].code && lines[k].comments.length > 0;
64
+ // For each comment-only line that continues the comment trailing code on a line above (the
65
+ // rest of a /* … */ begun there, or a comment line starting in its column), that line.
66
+ const trails = [];
67
+ for (let k = 0; k < lines.length; k++) {
68
+ const t = k === 0 ? -1 : lines[k - 1].code ? (lines[k - 1].comments.length ? k - 1 : -1) : trails[k - 1];
69
+ const head = lines[k].comments[0];
70
+ trails[k] =
71
+ commentOnly(k) && t >= 0 && (head.continued || head.column === lines[t].comments.at(-1).column) ? t : -1;
72
+ }
73
+ const blocks = new Map(); // "first,last" -> the entries the block cites, and those within reach
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const own = lines[i].comments.map((c) => c.text).join("\n");
76
+ const addresses = new Map();
77
+ for (const m of own.matchAll(ADDRESS_RE)) {
78
+ // The end of a half-open range is one byte past the last address it covers.
79
+ const rangeEnd = m.index >= 2 && own.slice(m.index - 2, m.index) === "..";
80
+ const value = parseInt(m[2], 16) - (rangeEnd ? 1 : 0);
81
+ if (m[1] === "0x" && !inImage(value))
82
+ continue;
83
+ addresses.set(`${m[0]}@${value}`, [m[0], value]);
84
+ }
85
+ if (!addresses.size)
86
+ continue;
87
+ // A line with code, or one continuing the comment that trails it, belongs to that comment.
88
+ const t = lines[i].code ? i : trails[i];
89
+ let first = t >= 0 ? t : i, last = first;
90
+ while (first > 0 && (commentOnly(first - 1) || lines[first].comments[0]?.continued))
91
+ first--;
92
+ while (t >= 0 ? trails[last + 1] === t : commentOnly(last + 1))
93
+ last++;
94
+ const key = `${first},${last}`;
95
+ if (!blocks.has(key)) {
96
+ const block = lines
97
+ .slice(first, last + 1)
98
+ .flatMap((l) => l.comments.map((c) => c.text))
99
+ .join("\n");
100
+ const cited = idsIn(block).filter((x) => entries.has(x));
101
+ blocks.set(key, { cited, scope: reach(cited) });
102
+ }
103
+ const { cited, scope } = blocks.get(key);
104
+ for (const [address, value] of addresses.values()) {
105
+ if (scope.some((x) => rangesOf(x).some(([low, high]) => value >= low && value < high)))
106
+ continue;
107
+ problem(file, `line ${i + 1} gives ${address}, but ${cited.length ? `neither ${cited.join(", ")} nor the evidence ${cited.length === 1 ? "it cites" : "they cite"} records it` : "the comment cites no entry that records it"}; cite the finding that records it, or record it in a new one`);
108
+ }
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,4 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { FormatNames } from "./formats.ts";
3
+ /** Runs the checks across entries, in order. */
4
+ export declare function checkAcrossEntries(ctx: Context, { enumNames }: FormatNames): void;
@@ -0,0 +1,177 @@
1
+ // Checks that look across entries: what the glossary and entry bodies cite, paths into the
2
+ // original's data, enumeration names, saves and recordings, value files, superseded_by chains and
3
+ // split entries.
4
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { isSuperseded } from "../evidence.js";
7
+ import { asList, idsIn } from "../ids.js";
8
+ import { readText } from "../markdown.js";
9
+ import { KINDS } from "../standard.js";
10
+ /** Runs the checks across entries, in order. */
11
+ export function checkAcrossEntries(ctx, { enumNames }) {
12
+ const { problem, config } = ctx;
13
+ const { specDir } = config;
14
+ const { entries, glossary, glossaryFiles, glossaryDir, buildFiles } = ctx.spec;
15
+ const glossaryFile = (term) => glossaryFiles.get(term) ?? glossaryDir;
16
+ // Glossary claims
17
+ for (const [term, text] of glossary) {
18
+ for (const x of idsIn(text))
19
+ if (!entries.has(x))
20
+ problem(glossaryFile(term), `${term} cites ${x}, which does not exist`);
21
+ else if (isSuperseded(entries, x))
22
+ problem(glossaryFile(term), `${term} cites ${x}, which is superseded`);
23
+ }
24
+ // Body references
25
+ for (const [, e] of entries)
26
+ for (const x of idsIn(e.body))
27
+ if (!entries.has(x))
28
+ problem(e.file, `the body names ${x}, which does not exist`);
29
+ // A path into a build's data directories names a file of some build with its exact case. A
30
+ // directory, or a pattern whose last part holds a placeholder such as nn or xxx, is left alone.
31
+ // The data directories are the top-level directories of the build files unless --data-dirs
32
+ // names them. A build path that holds a space is matched whole, longest first, together with any
33
+ // path that follows it, so Dir/With Space/file.ext is read as one path.
34
+ {
35
+ const exact = new Set();
36
+ const folded = new Map();
37
+ const topDirs = new Set();
38
+ for (const files of buildFiles.values())
39
+ for (const f of files) {
40
+ const p = f.path;
41
+ if (typeof p !== "string")
42
+ continue;
43
+ exact.add(p);
44
+ folded.set(p.toLowerCase(), p);
45
+ const parts = p.split("/");
46
+ if (parts.length > 1)
47
+ topDirs.add(parts[0]);
48
+ for (let i = 1; i < parts.length; i++)
49
+ exact.add(parts.slice(0, i).join("/"));
50
+ }
51
+ const dataDirs = config.dataDirs ?? [...topDirs].sort();
52
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ const tail = String.raw `[A-Za-z0-9_./-]*[A-Za-z0-9]`;
54
+ const spaced = [...exact]
55
+ .filter((p) => p.includes(" ") && dataDirs.includes(p.split("/")[0]))
56
+ .sort((a, b) => b.length - a.length);
57
+ const whole = spaced.length ? `(?:${spaced.map(escapeRe).join("|")})(?:${tail})?|` : "";
58
+ const dataPath = new RegExp(String.raw `(?<!\w)(?:${whole}(?:${dataDirs.map(escapeRe).join("|")})\/${tail})`, "g");
59
+ const dirsFolded = new Set([...exact].map((p) => p.toLowerCase()));
60
+ const checkPaths = (file, text) => {
61
+ for (const m of text.matchAll(dataPath)) {
62
+ const p = m[0];
63
+ if (exact.has(p))
64
+ continue;
65
+ const last = p.split("/").pop();
66
+ if (folded.has(p.toLowerCase()))
67
+ problem(file, `path ${p} is written ${folded.get(p.toLowerCase())} in the build entry`);
68
+ else if (dirsFolded.has(p.toLowerCase()))
69
+ problem(file, `directory ${p} differs in case from the build entry`);
70
+ else if (/\d/.test(last) && !/nn|NN|xx|XX/.test(last))
71
+ problem(file, `path ${p} is not a file of any build`);
72
+ }
73
+ };
74
+ if (dataDirs.length > 0) {
75
+ for (const [, e] of entries)
76
+ if (e.kind !== "BLD")
77
+ checkPaths(e.file, readFileSync(e.file, "utf8"));
78
+ for (const [term, text] of glossary)
79
+ if (glossaryFiles.has(term))
80
+ checkPaths(glossaryFiles.get(term), text);
81
+ }
82
+ }
83
+ // Enumeration names are unique apart from split formats
84
+ for (const [name, fmts] of enumNames) {
85
+ const uniq = [...new Set(fmts)];
86
+ if (uniq.length > 1 &&
87
+ !uniq.every((f) => uniq.every((g) => f === g || asList(entries.get(f).meta.split_with).includes(g))))
88
+ problem(null, `enumeration name ${name} is defined by ${uniq.join(", ")}`);
89
+ }
90
+ // Saves and recordings must be listed in spec/LICENSE
91
+ {
92
+ const licence = existsSync(join(specDir, "LICENSE")) ? readFileSync(join(specDir, "LICENSE"), "utf8") : "";
93
+ if (!licence)
94
+ problem(null, "spec/LICENSE is missing");
95
+ for (const sub of ["saves", "recordings"]) {
96
+ const d = join(specDir, "experiments", sub);
97
+ if (!existsSync(d))
98
+ continue;
99
+ for (const f of readdirSync(d)) {
100
+ if (f === ".gitkeep" || f.endsWith(".patch.json"))
101
+ continue;
102
+ if (!licence.includes(`experiments/${sub}/${f}`))
103
+ problem(join(d, f), "is not listed in spec/LICENSE as covered by neither licence");
104
+ }
105
+ }
106
+ }
107
+ // Every save and recording is named by some experiment.
108
+ {
109
+ const named = new Set();
110
+ for (const e of entries.values())
111
+ if (e.kind === "EXP")
112
+ for (const v of [e.meta.starting_state, e.meta.recording])
113
+ if (typeof v === "string")
114
+ named.add(v);
115
+ for (const sub of ["saves", "recordings"]) {
116
+ const d = join(specDir, "experiments", sub);
117
+ if (existsSync(d))
118
+ for (const f of readdirSync(d))
119
+ if (f !== ".gitkeep" && !named.has(`${sub}/${f}`))
120
+ problem(join(d, f), "is named by no experiment");
121
+ }
122
+ }
123
+ // Every value file belongs to the entry its name gives, in that entry's directory, and is named by it.
124
+ for (const { dir } of Object.values(KINDS)) {
125
+ const d = join(specDir, dir);
126
+ if (!existsSync(d))
127
+ continue;
128
+ for (const name of readdirSync(d)) {
129
+ if (!name.endsWith(".csv"))
130
+ continue;
131
+ const m = /^([A-Z]+-[A-Z0-9]+-\d{3,})\.[A-Za-z0-9_]+\.csv$/.exec(name);
132
+ const owner = m && entries.get(m[1]);
133
+ if (!m || !owner || owner.file !== join(d, `${m[1]}.md`)) {
134
+ problem(join(d, name), "belongs to no entry; a value file is named <ID>.<table>.csv and sits beside its entry");
135
+ continue;
136
+ }
137
+ if (!readText(owner.file).includes(name))
138
+ problem(join(d, name), `is not named by ${m[1]}`);
139
+ }
140
+ }
141
+ // No chain of superseded_by links leads back to where it started.
142
+ for (const [id, e] of entries) {
143
+ const seen = new Set();
144
+ const stack = [...asList(e.meta.superseded_by)];
145
+ while (stack.length) {
146
+ const x = stack.pop();
147
+ if (x === id) {
148
+ problem(e.file, "its superseded_by links lead back to it", "IDENTIFIERS-7");
149
+ break;
150
+ }
151
+ if (seen.has(x) || !entries.has(x))
152
+ continue;
153
+ seen.add(x);
154
+ stack.push(...asList(entries.get(x).meta.superseded_by));
155
+ }
156
+ }
157
+ // An entry that relates to a split rule or format relates to every entry of the split, and every
158
+ // build it lists is listed by one of them.
159
+ for (const [id, e] of entries) {
160
+ if (KINDS[e.kind].statuses !== "claim" || e.meta.status === "superseded")
161
+ continue;
162
+ const related = asList(e.meta.related);
163
+ for (const x of related) {
164
+ const other = entries.get(x);
165
+ // A superseded part cannot be related to, so it is left out of the group.
166
+ const group = other ? [x, ...asList(other.meta.split_with).filter((g) => !isSuperseded(entries, g))] : [];
167
+ if (group.length < 2 || group.includes(id))
168
+ continue;
169
+ for (const g of group)
170
+ if (!related.includes(g))
171
+ problem(e.file, `relates to ${x}, which is split with ${g}; add ${g} to related`, "ENTRY-TYPES-6");
172
+ for (const b of asList(e.meta.builds))
173
+ if (!group.some((g) => asList(entries.get(g)?.meta.builds).includes(b)))
174
+ problem(e.file, `lists ${b}, which no entry of the split ${group.join(", ")} lists`, "ENTRY-TYPES-8");
175
+ }
176
+ }
177
+ }
@@ -0,0 +1,12 @@
1
+ import type { Context } from "../context.ts";
2
+ /** One deviation of the log. */
3
+ export interface Deviation {
4
+ /** The spec IDs its Departs from item names. */
5
+ departs: string[];
6
+ /** Whether its Dropped item says it was dropped. */
7
+ dropped: boolean;
8
+ /** Its file. */
9
+ file: string;
10
+ }
11
+ /** Reads and checks deviations/. Returns deviation ID -> the deviation. */
12
+ export declare function checkDeviations(ctx: Context): Map<string, Deviation>;
@@ -0,0 +1,101 @@
1
+ // The deviation log: one file per deviation in deviations/, named after its ID and opening with it
2
+ // as a # heading, followed by its items.
3
+ import { existsSync, statSync } from "node:fs";
4
+ import { basename, join } from "node:path";
5
+ import { checkResolves, isSuperseded } from "../evidence.js";
6
+ import { termFiles } from "../files.js";
7
+ import { areaOf, idsIn, kindOf } from "../ids.js";
8
+ import { readText } from "../markdown.js";
9
+ /** Reads and checks deviations/. Returns deviation ID -> the deviation. */
10
+ export function checkDeviations(ctx) {
11
+ const { problem } = ctx;
12
+ const { entries, areas } = ctx.spec;
13
+ const { repoDir } = ctx.config;
14
+ const deviations = new Map();
15
+ const devDir = join(repoDir, "deviations");
16
+ {
17
+ if (existsSync(join(repoDir, "DEVIATIONS.md")))
18
+ problem(join(repoDir, "DEVIATIONS.md"), "the deviation log is the directory deviations/; move each ## deviation to deviations/<ID>.md with the ID as its # heading");
19
+ if (!existsSync(devDir))
20
+ problem(null, "deviations/ is missing");
21
+ else
22
+ for (const file of termFiles(devDir)) {
23
+ if (statSync(file).isDirectory() || !file.endsWith(".md")) {
24
+ problem(file, "is not a deviation file; deviations/ holds one <ID>.md per deviation");
25
+ continue;
26
+ }
27
+ const m = /^# (.+)\n?([\s\S]*)$/.exec(readText(file));
28
+ if (!m) {
29
+ problem(file, "opens with the deviation's ID as a # heading");
30
+ continue;
31
+ }
32
+ const title = m[1].trim();
33
+ if (!/^DEV-[A-Z][A-Z0-9]*-\d{3,}$/.test(title)) {
34
+ problem(file, `heading ${title} is not a deviation ID`);
35
+ continue;
36
+ }
37
+ if (basename(file) !== `${title}.md`)
38
+ problem(file, `file name must be ${title}.md`);
39
+ if (deviations.has(title))
40
+ problem(file, `${title} is used twice`);
41
+ if (!areas.includes(areaOf(title)))
42
+ problem(file, `${title}: area is not in the area list`);
43
+ const items = [...m[2].matchAll(/^- ([A-Za-z ]+): (.*)$/gm)].map((x) => [x[1], x[2]]);
44
+ const item = Object.fromEntries(items);
45
+ const order = [
46
+ "Departs from",
47
+ "Reason",
48
+ "Setting",
49
+ "Default",
50
+ ...("Justification" in item ? ["Justification"] : []),
51
+ "Dropped",
52
+ ];
53
+ if (items
54
+ .slice(0, order.length)
55
+ .map((x) => x[0])
56
+ .join("|") !== order.join("|"))
57
+ problem(file, `${title}: items must be ${order.join(", ")} in that order`);
58
+ const departs = idsIn(item["Departs from"]);
59
+ const dropped = Boolean(item.Dropped) && item.Dropped !== "no";
60
+ if (dropped && !/^\d{4}-\d{2}-\d{2}\b/.test(item.Dropped))
61
+ problem(file, `${title}: Dropped gives the date, YYYY-MM-DD, and the reason`);
62
+ checkResolves(ctx, file, departs, `${title} Departs from`);
63
+ if (!dropped) {
64
+ if (!departs.some((x) => ["RULE", "FMT", "SCR"].includes(kindOf(x))))
65
+ problem(file, `${title}: Departs from names at least one rule, format or screen`);
66
+ for (const x of departs)
67
+ if (isSuperseded(entries, x))
68
+ problem(file, `${title} departs from ${x}, which is superseded`);
69
+ checkDeviationDefault(ctx, file, title, item, departs);
70
+ }
71
+ deviations.set(title, { departs, dropped, file });
72
+ }
73
+ }
74
+ return deviations;
75
+ }
76
+ // Default is off, on or mandatory. Only the fix of an unintended, not-relied-on bug is on by right;
77
+ // mandatory, and on for anything else, carry a Justification that the rebuild is strictly better or a
78
+ // small judgement call that makes the game better to play.
79
+ function checkDeviationDefault(ctx, path, title, item, departs) {
80
+ const { problem } = ctx;
81
+ const { entries } = ctx.spec;
82
+ const defaults = ["off", "on", "mandatory"];
83
+ const dflt = item.Default;
84
+ if (!defaults.includes(dflt))
85
+ return problem(path, `${title}: Default is one of ${defaults.join(", ")}`);
86
+ if ((item.Setting === "None") !== (dflt === "mandatory"))
87
+ return problem(path, `${title}: Default is mandatory exactly when Setting is None`);
88
+ const bugs = departs
89
+ .filter((x) => kindOf(x) === "BUG")
90
+ .map((x) => entries.get(x))
91
+ .filter((b) => Boolean(b));
92
+ const bugFix = bugs.length > 0 && bugs.every((b) => b.meta.intent === "unintended" && b.meta.player_reliance === "not-relied-on");
93
+ if (bugFix && dflt === "off")
94
+ problem(path, `${title}: Default is on or mandatory for the fix of an unintended, not-relied-on bug`);
95
+ const needsJustification = dflt === "mandatory" || (dflt === "on" && !bugFix);
96
+ const hasJustification = "Justification" in item;
97
+ if (needsJustification && !hasJustification)
98
+ problem(path, `${title}: is ${dflt} but has no Justification saying why the rebuild's behaviour is strictly better, or what the judgement call improves`);
99
+ if (!needsJustification && hasJustification)
100
+ problem(path, `${title}: has a Justification, which only a mandatory deviation or one that is on without fixing an unintended, not-relied-on bug has`);
101
+ }
@@ -0,0 +1,7 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { FormatNames } from "./formats.ts";
3
+ /**
4
+ * Checks every entry, kind by kind, and returns the names the formats' tables define, which the
5
+ * rule checks read.
6
+ */
7
+ export declare function checkEntries(ctx: Context): FormatNames;