@scientific-method/standard-checker 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/checks/base.d.ts +7 -0
- package/dist/checks/base.js +72 -0
- package/dist/checks/claims.d.ts +4 -0
- package/dist/checks/claims.js +83 -0
- package/dist/checks/comment-addresses.d.ts +3 -0
- package/dist/checks/comment-addresses.js +111 -0
- package/dist/checks/cross-entry.d.ts +4 -0
- package/dist/checks/cross-entry.js +177 -0
- package/dist/checks/deviations.d.ts +12 -0
- package/dist/checks/deviations.js +101 -0
- package/dist/checks/entries.d.ts +7 -0
- package/dist/checks/entries.js +130 -0
- package/dist/checks/evidence-entries.d.ts +6 -0
- package/dist/checks/evidence-entries.js +167 -0
- package/dist/checks/formats.d.ts +13 -0
- package/dist/checks/formats.js +194 -0
- package/dist/checks/kaitai.d.ts +6 -0
- package/dist/checks/kaitai.js +103 -0
- package/dist/checks/parity.d.ts +30 -0
- package/dist/checks/parity.js +189 -0
- package/dist/checks/references.d.ts +4 -0
- package/dist/checks/references.js +37 -0
- package/dist/checks/rules.d.ts +4 -0
- package/dist/checks/rules.js +259 -0
- package/dist/checks/screens.d.ts +4 -0
- package/dist/checks/screens.js +44 -0
- package/dist/checks/validation.d.ts +7 -0
- package/dist/checks/validation.js +116 -0
- package/dist/code-comments.d.ts +3 -0
- package/dist/code-comments.js +150 -0
- package/dist/code-files.d.ts +11 -0
- package/dist/code-files.js +41 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +2 -0
- package/dist/evidence.d.ts +37 -0
- package/dist/evidence.js +91 -0
- package/dist/files.d.ts +11 -0
- package/dist/files.js +35 -0
- package/dist/generate/indexes.d.ts +3 -0
- package/dist/generate/indexes.js +135 -0
- package/dist/generate/layout.d.ts +18 -0
- package/dist/generate/layout.js +49 -0
- package/dist/generate/parity-md.d.ts +7 -0
- package/dist/generate/parity-md.js +44 -0
- package/dist/generate/write.d.ts +8 -0
- package/dist/generate/write.js +75 -0
- package/dist/ids.d.ts +13 -0
- package/dist/ids.js +22 -0
- package/dist/load/builds.d.ts +9 -0
- package/dist/load/builds.js +124 -0
- package/dist/load/code-ranges.d.ts +4 -0
- package/dist/load/code-ranges.js +79 -0
- package/dist/load/entries.d.ts +7 -0
- package/dist/load/entries.js +57 -0
- package/dist/load/glossary.d.ts +12 -0
- package/dist/load/glossary.js +70 -0
- package/dist/load/readme.d.ts +3 -0
- package/dist/load/readme.js +36 -0
- package/dist/load/spec.d.ts +6 -0
- package/dist/load/spec.js +25 -0
- package/dist/locations.d.ts +19 -0
- package/dist/locations.js +57 -0
- package/dist/markdown.d.ts +27 -0
- package/dist/markdown.js +162 -0
- package/dist/options.d.ts +34 -0
- package/dist/options.js +87 -0
- package/dist/problems.d.ts +27 -0
- package/dist/problems.js +27 -0
- package/dist/standard-checker.js +51 -3033
- package/dist/standard.d.ts +65 -0
- package/dist/standard.js +173 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +2 -0
- package/dist/yaml.d.ts +6 -0
- package/dist/yaml.js +173 -0
- package/package.json +1 -1
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,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,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,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;
|