@scientific-method/standard-checker 0.4.0 → 0.5.1

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 CHANGED
@@ -12,8 +12,9 @@ pnpm exec standard-checker --check # check, and fail on a stale index or PARI
12
12
  ```
13
13
 
14
14
  It exits with 0 when the repository passes, 1 when it reports problems (one line per problem,
15
- starting with the file's path), and 2 when the options are invalid. `--record-validation` cannot be
16
- combined with `--check`, nor `--require-ksc` with `--no-ksy`.
15
+ starting with the file's path), and 2 when the options are invalid or `--record-validation` cannot
16
+ write the record. `--record-validation` cannot be combined with `--check`, nor `--require-ksc` with
17
+ `--no-ksy`.
17
18
 
18
19
  A pass ends with `spec check passed:` and the counts of entries, parity rows and deviations. When a
19
20
  step of the check did not run, it ends with `spec check passed with skipped steps:`, the counts, and
@@ -33,7 +34,7 @@ problems under other sections carry no label yet.
33
34
  |---|---|---|
34
35
  | `--root <dir>` | The repository to check. | the current directory |
35
36
  | `--check` | Fail when an index or `PARITY.md` is stale, instead of rewriting it. | rewrite |
36
- | `--base <ref>` | Also fail when a spec ID, area or deviation that exists at `<ref>` is gone. | where HEAD forked from `origin/$GITHUB_BASE_REF` or `origin/main`, when that resolves |
37
+ | `--base <ref>` | Also fail when a spec ID, area or deviation that exists at `<ref>` is gone, or when a superseded format entry has no layout table although it had one at `<ref>`. | where HEAD forked from `origin/$GITHUB_BASE_REF` or `origin/main`, when that resolves |
37
38
  | `--no-ksy` | Skip compiling the Kaitai definitions in `spec/formats/`. The result line names the skipped compilation. | compile |
38
39
  | `--require-ksc` | Fail when `spec/formats/` holds Kaitai definitions and no compiler is found, instead of passing with the compilation skipped. | pass with the compilation skipped |
39
40
  | `--glossary <path>` | Also accept the terms of a draft glossary file, or of a directory of them. | none |
@@ -42,13 +43,21 @@ problems under other sections carry no label yet.
42
43
  | `--images <ranges>` | Comma-separated half-open address ranges of the original's flat 32-bit images, such as `0x00400000..0x004C9000`. A `0x` value inside one that a code comment gives must be recorded in an entry the comment cites. | none, so only `fn_` and `g_` names are checked |
43
44
  | `--max-range <bytes>` | The largest address range an entry can record an address by. A larger one, such as a whole section, records only its two ends. | `0x10000` |
44
45
  | `--data-dirs <dirs>` | Comma-separated top-level directories of the original's data. A path into one must name a file of some build, with its exact case. | the top-level directories of the files the build entries list |
45
- | `--record-validation <builds>` | Write `VALIDATION.md` for the marked test files of validated parity rows, naming the comma-separated build IDs the run used. Run it only after every test in those files passed with none skipped. | not written |
46
+ | `--record-validation <builds>` | Write `VALIDATION.md` for the marked test files of validated parity rows, naming the comma-separated build IDs the run used and HEAD as the commit the run tested. Run it only after every test in those files passed with none skipped, against HEAD as committed: it refuses, with exit code 2, when the working tree differs from HEAD in anything other than `VALIDATION.md`, counting untracked files that git does not ignore. | not written |
46
47
  | `--help` | Print the options. | |
47
48
 
48
49
  The `KSC` environment variable names the Kaitai Struct compiler. Without it, the checker looks for
49
50
  `kaitai-struct-compiler` or `ksc` on `PATH`. When it finds neither, it skips the compilation and
50
51
  names it in the result line, or with `--require-ksc` reports the missing compiler as a problem.
51
52
 
53
+ On Windows the search tries each name with the `PATHEXT` extensions, as `cmd.exe` does, so it finds
54
+ the `.bat` launcher of the official release and skips the extensionless Unix script beside it. The
55
+ checker then runs the launcher by its full path. A `KSC` that holds a bare name, with no directory,
56
+ is looked up on `PATH` the same way.
57
+
58
+ On every platform, a launcher found on `PATH` whose `--version` fails is not used, and the checker
59
+ prints a warning naming its path, with its output.
60
+
52
61
  ## In GitHub Actions
53
62
 
54
63
  The toolkit's `actions/check-documentation` composite action runs this checker with `--check`,
@@ -2,6 +2,7 @@ import type { Context } from "../context.ts";
2
2
  import type { Deviation } from "./deviations.ts";
3
3
  /**
4
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.
5
+ * the base branch) and is gone now, and a superseded format entry whose Layout has no table although
6
+ * it had one at the base. Does nothing when there is no base to compare with.
6
7
  */
7
8
  export declare function checkBase(ctx: Context, deviations: Map<string, Deviation>): void;
@@ -1,9 +1,12 @@
1
- // IDs, areas and deviations that exist on the base branch must not disappear.
1
+ // IDs, areas and deviations that exist on the base branch must not disappear, and a format entry
2
+ // superseded since the base keeps the layout table it had there.
2
3
  import { execFileSync } from "node:child_process";
4
+ import { relative } from "node:path";
3
5
  import { splitSections, tables } from "../markdown.js";
4
6
  /**
5
7
  * 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.
8
+ * the base branch) and is gone now, and a superseded format entry whose Layout has no table although
9
+ * it had one at the base. Does nothing when there is no base to compare with.
7
10
  */
8
11
  export function checkBase(ctx, deviations) {
9
12
  const { problem } = ctx;
@@ -62,6 +65,20 @@ export function checkBase(ctx, deviations) {
62
65
  if (!areas.includes(a))
63
66
  problem(null, `area ${a} exists at ${base} and has been removed or renamed`, "IDENTIFIERS-5");
64
67
  }
68
+ // The format checks let a superseded entry have no layout table, so one retired from unknown
69
+ // keeps its None known. A superseded entry stays as it was when it was replaced (IDENTIFIERS-7),
70
+ // so one that had a table at the base may not drop it on the way.
71
+ const layoutTables = (body) => {
72
+ const layout = splitSections(body).find((s) => s.title === "Layout");
73
+ return layout ? tables(layout.text).length : 0;
74
+ };
75
+ for (const e of entries.values()) {
76
+ if (e.kind !== "FMT" || e.meta.status !== "superseded" || layoutTables(e.body) > 0)
77
+ continue;
78
+ const old = show(relative(repoDir, e.file).replaceAll("\\", "/"));
79
+ if (old && layoutTables(old) > 0)
80
+ problem(e.file, `Layout has no table, but it had one at ${base}`, "IDENTIFIERS-7");
81
+ }
65
82
  // A base from before the deviation log became a directory keeps its deviations in DEVIATIONS.md.
66
83
  const oldDev = show("DEVIATIONS.md");
67
84
  if (oldDev)
@@ -129,13 +129,20 @@ export function checkExperiment(ctx, id, e, isSup) {
129
129
  const fx = JSON.parse(readFileSync(fixture, "utf8"));
130
130
  if (fx.experiment !== id)
131
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)
132
+ // Only a new game and an emulated call start without a save. starting_state null still has
133
+ // one: a save that cannot be committed, kept with the captures and found by its hash.
134
+ const save = fx.starting_state;
135
+ if (!["new-game", "emulated-call"].includes(meta.starting_state) && !save?.xxh3) {
136
+ const where = "gives the hash of the save its runs started from in starting_state.xxh3";
137
+ problem(fixture, meta.starting_state === null
138
+ ? `${where}; starting_state null names a save kept with the captures, and an experiment that starts without a save has starting_state new-game or emulated-call`
139
+ : where);
140
+ }
141
+ if (typeof meta.starting_state === "string" && meta.starting_state.endsWith(".patch.json") && !save?.base_xxh3)
138
142
  problem(fixture, "a patch fixture gives the base save's hash as well");
143
+ for (const key of ["xxh3", "base_xxh3"])
144
+ if (save?.[key] && !/^[0-9a-f]{32}$/.test(String(save[key])))
145
+ problem(fixture, `starting_state.${key} must be 32 lower-case hex digits`);
139
146
  for (const run of asList(fx.runs)) {
140
147
  for (const ev of asList(run?.events))
141
148
  if (!glossary.has(ev?.event))
@@ -25,6 +25,9 @@ export function checkFormat(ctx, e, formatNames) {
25
25
  const { file, meta } = e;
26
26
  const id = meta.id;
27
27
  const first = asList(meta.builds)[0];
28
+ // An unknown entry claims nothing, and a superseded entry stays as it was when it was replaced
29
+ // (IDENTIFIERS-7), so neither needs a definition or a layout table, nor a status set by its rows.
30
+ const claimsContent = meta.status !== "unknown" && meta.status !== "superseded";
28
31
  if (meta.text === true) {
29
32
  if (meta.definition !== null || meta.size !== null || meta.byte_order !== null)
30
33
  problem(file, "a text format has definition, size and byte_order null");
@@ -32,7 +35,7 @@ export function checkFormat(ctx, e, formatNames) {
32
35
  else {
33
36
  if (!["little", "big"].includes(meta.byte_order))
34
37
  problem(file, "byte_order must be little or big for a binary format");
35
- if (meta.status !== "unknown" && meta.status !== "superseded") {
38
+ if (claimsContent) {
36
39
  const expected = `${id.toLowerCase().replaceAll("-", "_")}.ksy`;
37
40
  if (meta.definition !== expected)
38
41
  problem(file, `definition must be ${expected}`);
@@ -94,7 +97,7 @@ export function checkFormat(ctx, e, formatNames) {
94
97
  if (layout) {
95
98
  const ts = tables(layout.text);
96
99
  const wanted = meta.text === true ? TEXT_LAYOUT : BINARY_LAYOUT;
97
- if (meta.status !== "unknown" && ts.length === 0)
100
+ if (claimsContent && ts.length === 0)
98
101
  problem(file, "Layout has no table");
99
102
  for (const t of ts) {
100
103
  if (t.header.join("|") !== wanted.join("|")) {
@@ -155,7 +158,7 @@ export function checkFormat(ctx, e, formatNames) {
155
158
  enumNames.get(n).push(id);
156
159
  }
157
160
  }
158
- if (meta.status !== "superseded" && meta.status !== "unknown") {
161
+ if (claimsContent) {
159
162
  const expected = disputed ? "disputed" : lowest;
160
163
  if (expected && meta.status !== expected)
161
164
  problem(file, `status must be ${expected}, the lowest status among its rows`);
@@ -1,8 +1,8 @@
1
1
  // Kaitai compilation: every definition in spec/formats/ belongs to a format entry and compiles.
2
2
  import { execFileSync } from "node:child_process";
3
- import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
3
+ import { accessSync, constants, existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { basename, join } from "node:path";
5
+ import { basename, delimiter, extname, join } from "node:path";
6
6
  /**
7
7
  * Checks that each .ksy file in spec/formats/ belongs to a format entry, and compiles them all with
8
8
  * the Kaitai Struct compiler. With --no-ksy, or with no compiler found, the compilation is recorded
@@ -51,9 +51,7 @@ export function compileKaitai(ctx) {
51
51
  catch (err) {
52
52
  // A compiler that cannot be started, such as a KSC naming a missing file, prints nothing,
53
53
  // so its error message is the only account of the failure.
54
- const failure = err;
55
- const output = `${String(failure.stdout ?? "")}${String(failure.stderr ?? "")}`;
56
- problem(null, `Kaitai definitions do not compile:\n${output || (failure.message ?? String(err))}`);
54
+ problem(null, `Kaitai definitions do not compile:\n${toolOutput(err)}`);
57
55
  }
58
56
  }
59
57
  }
@@ -87,16 +85,65 @@ function kaitaiBatches(fixed, files) {
87
85
  batches.push(batch);
88
86
  return batches;
89
87
  }
88
+ // KSC is used as given, except that a bare command name is looked up on PATH like the default
89
+ // names. A name found on PATH whose --version fails is named on stderr, so a broken launcher does
90
+ // not read as a missing one.
90
91
  function findKaitai() {
91
- if (process.env.KSC)
92
- return { cmd: process.env.KSC, args: [] };
93
- for (const cmd of ["kaitai-struct-compiler", "ksc"]) {
92
+ const ksc = process.env.KSC;
93
+ if (ksc)
94
+ return { cmd: isBareName(ksc) ? (findOnPath(ksc) ?? ksc) : ksc, args: [] };
95
+ for (const name of ["kaitai-struct-compiler", "ksc"]) {
96
+ const cmd = findOnPath(name);
97
+ if (!cmd)
98
+ continue;
94
99
  try {
95
100
  runTool(cmd, ["--version"]);
96
101
  return { cmd, args: [] };
97
102
  }
98
- catch { }
103
+ catch (err) {
104
+ console.warn(`warning: ${cmd} --version failed, so it is not used:\n${toolOutput(err).trim()}`);
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ const isBareName = (cmd) => !/[\\/]/.test(cmd);
110
+ // What a failed runTool call printed or, when it printed nothing because the program could not be
111
+ // started, the error's message.
112
+ function toolOutput(err) {
113
+ const failure = err;
114
+ const output = `${String(failure.stdout ?? "")}${String(failure.stderr ?? "")}`;
115
+ return output.trim() ? output : String(failure.message ?? err);
116
+ }
117
+ // The path of the first file on PATH that runs as `name`, or null when there is none. The path
118
+ // matters on Windows: cmd.exe runs a .bat that it found on PATH under a quoted bare name with %~dp0
119
+ // set to the working directory, and the compiler's launcher finds its jars through %~dp0. There a
120
+ // name without one of the PATHEXT extensions is tried with each, in PATH order and then PATHEXT
121
+ // order as cmd.exe searches, so the extensionless Unix script that ships beside the .bat launcher
122
+ // is skipped. Elsewhere it takes the first executable file of that name, as execFileSync would.
123
+ function findOnPath(name) {
124
+ const windows = process.platform === "win32";
125
+ let exts = [""];
126
+ if (windows) {
127
+ const pathExts = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
128
+ if (!pathExts.some((e) => extname(name).toLowerCase() === e.toLowerCase()))
129
+ exts = pathExts;
99
130
  }
131
+ const dirs = (process.env.PATH ?? "")
132
+ .split(delimiter)
133
+ .map((d) => (windows ? d.replace(/"/g, "") : d))
134
+ .filter(Boolean);
135
+ for (const dir of dirs)
136
+ for (const ext of exts) {
137
+ const file = join(dir, name + ext);
138
+ try {
139
+ if (!statSync(file, { throwIfNoEntry: false })?.isFile())
140
+ continue;
141
+ if (!windows)
142
+ accessSync(file, constants.X_OK);
143
+ return file;
144
+ }
145
+ catch { }
146
+ }
100
147
  return null;
101
148
  }
102
149
  // On Windows the compiler is a .bat file, which only cmd.exe can run. Node's shell: true joins the
@@ -30,6 +30,11 @@ export function checkValidation(ctx, { validatedTests }) {
30
30
  console.error(`--record-validation: ${b} is not a build entry`);
31
31
  process.exit(2);
32
32
  }
33
+ const files = [...validatedTests.keys()].sort();
34
+ if (!files.length) {
35
+ console.error('--record-validation: no validated row lists a test file with a "needs: GAME_DIR" comment, so there is nothing to record');
36
+ process.exit(2);
37
+ }
33
38
  let commit;
34
39
  try {
35
40
  commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8" }).trim();
@@ -38,9 +43,68 @@ export function checkValidation(ctx, { validatedTests }) {
38
43
  console.error("--record-validation: git rev-parse HEAD failed");
39
44
  process.exit(2);
40
45
  }
41
- const files = [...validatedTests.keys()].sort();
42
- if (!files.length) {
43
- console.error('--record-validation: no validated row lists a test file with a "needs: GAME_DIR" comment, so there is nothing to record');
46
+ // Commit is the commit the run tested, so the run must have tested HEAD as committed. Any change
47
+ // to a tracked file, or an untracked file that git does not ignore, anywhere in the repository
48
+ // means it tested something else. Only an earlier VALIDATION.md may differ, since this run
49
+ // replaces it. An untracked directory is listed once, and submodules are compared whatever the
50
+ // repository's submodule.*.ignore or diff.ignoreSubmodules settings say.
51
+ const changed = [];
52
+ try {
53
+ const status = execFileSync("git", [
54
+ "status",
55
+ "--porcelain=v1",
56
+ "-z",
57
+ "--untracked-files=normal",
58
+ "--ignore-submodules=none",
59
+ "--",
60
+ ":/",
61
+ ":(exclude)VALIDATION.md",
62
+ ], { cwd: repoDir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }).split("\0");
63
+ for (let i = 0; i < status.length; i++) {
64
+ if (!status[i])
65
+ continue;
66
+ changed.push(status[i].slice(3));
67
+ // A rename or copy is followed by its source path.
68
+ if (/[RC]/.test(status[i].slice(0, 2)))
69
+ i++;
70
+ }
71
+ // git status does not look at a file marked assume-unchanged or skip-worktree, so a file with
72
+ // either mark that is present is hashed and compared with HEAD here. A sparse checkout leaves
73
+ // its skip-worktree files absent, and an absent file was not part of what the run tested.
74
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8" }).trim();
75
+ const marked = execFileSync("git", ["ls-files", "-v", "-z", "--full-name", "--", ":/", ":(exclude)VALIDATION.md"], { cwd: repoDir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 })
76
+ .split("\0")
77
+ .filter((e) => e && (e[0] === "S" || /[a-z]/.test(e[0])))
78
+ .map((e) => e.slice(2))
79
+ .filter((p) => existsSync(join(top, p)));
80
+ if (marked.length) {
81
+ const worktree = execFileSync("git", ["hash-object", "--stdin-paths"], {
82
+ cwd: top,
83
+ encoding: "utf8",
84
+ input: marked.join("\n") + "\n",
85
+ }).split("\n");
86
+ const head = new Map();
87
+ for (const line of execFileSync("git", ["--literal-pathspecs", "ls-tree", "-r", "-z", "HEAD", "--", ...marked], {
88
+ cwd: top,
89
+ encoding: "utf8",
90
+ }).split("\0")) {
91
+ const tab = line.indexOf("\t");
92
+ if (tab > 0)
93
+ head.set(line.slice(tab + 1), line.slice(0, tab).split(" ")[2]);
94
+ }
95
+ marked.forEach((p, i) => {
96
+ if (head.get(p) !== worktree[i])
97
+ changed.push(p);
98
+ });
99
+ }
100
+ }
101
+ catch {
102
+ console.error("--record-validation: git status failed");
103
+ process.exit(2);
104
+ }
105
+ if (changed.length) {
106
+ const shown = changed.slice(0, 5).join(", ") + (changed.length > 5 ? `, and ${changed.length - 5} more` : "");
107
+ console.error(`--record-validation: the working tree differs from HEAD (${shown}), so HEAD is not the commit the run tested. Commit the change, run the marked tests against that commit, then record`);
44
108
  process.exit(2);
45
109
  }
46
110
  writeFileSync(validationPath, [
@@ -30,8 +30,10 @@
30
30
  // --record-validation <builds>
31
31
  // write VALIDATION.md for the test files of the validated rows that carry a
32
32
  // "needs: GAME_DIR" comment, naming the comma-separated build IDs the run
33
- // used. Run it only after every test in those files passed, with none
34
- // skipped, against the original's files
33
+ // used and HEAD as the commit the run tested. Run it only after every test
34
+ // in those files passed, with none skipped, against the original's files
35
+ // and HEAD as committed; it refuses when the working tree differs from HEAD
36
+ // in anything other than VALIDATION.md
35
37
  //
36
38
  // Each problem is one line that starts with the path it concerns, or spec for the spec as a whole.
37
39
  // A problem that breaks a numbered rule of the standard ends with the rule's label, such as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scientific-method/standard-checker",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Checks a restoration's spec/, parity/ and deviations/ against version 1 of the dinorefurb documentation standard.",
5
5
  "type": "module",
6
6
  "license": "MIT",