@scientific-method/standard-checker 0.4.1 → 0.6.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 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)
@@ -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, [
@@ -4,6 +4,20 @@ import { dirname, join } from "node:path";
4
4
  import { readText } from "../markdown.js";
5
5
  import { locationRule, unlistedFormat } from "../standard.js";
6
6
  import { parseYaml } from "../yaml.js";
7
+ /**
8
+ * Returns a manifest or other-files path as text, or reports it and returns undefined when it is
9
+ * missing or is a map or list. The YAML reader turns a bare name such as 1990 or 0 into a number,
10
+ * so a number or boolean is a path written without quotes and is read as its text.
11
+ */
12
+ function pathText(value, file, missing, problem) {
13
+ if (value === null || value === undefined || value === "")
14
+ problem(file, missing);
15
+ else if (typeof value === "object")
16
+ problem(file, "a path is text, not a map or list");
17
+ else
18
+ return String(value);
19
+ return undefined;
20
+ }
7
21
  /**
8
22
  * Reads and checks the manifest of every build entry, and reports a manifest or list of other files
9
23
  * that belongs to no build. Returns build ID -> the items of its files list that are maps.
@@ -40,9 +54,16 @@ export function loadBuildFiles({ config, problem }, entries) {
40
54
  problem(path, "every item of files is a map of path, format, size and xxh3");
41
55
  const files = manifest.files.filter((f) => f && typeof f === "object");
42
56
  buildFiles.set(id, files);
57
+ // A manifest gives one format, size and hash per file, so a path it lists twice is reported
58
+ // even when the two items agree. Paths compare as text, as in the list of other files.
59
+ const seen = new Set();
43
60
  for (const f of files) {
44
- if (!f.path)
45
- problem(path, "every file has a path");
61
+ const p = pathText(f.path, path, "every file has a path", problem);
62
+ if (p !== undefined) {
63
+ if (seen.has(p))
64
+ problem(path, `${p} is listed twice`);
65
+ seen.add(p);
66
+ }
46
67
  if (f.format === undefined || f.format === null || f.format === "")
47
68
  problem(path, `${f.path}: every file has a format`);
48
69
  else if (!locationRule(f.format))
@@ -105,11 +126,9 @@ export function checkOtherFiles({ problem }, entries, buildFiles) {
105
126
  problem(path, "every item of other_files is a map of path and reason");
106
127
  continue;
107
128
  }
108
- if (item.path === null || item.path === undefined || item.path === "") {
109
- problem(path, "every other file has a path");
129
+ const other = pathText(item.path, path, "every other file has a path", problem);
130
+ if (other === undefined)
110
131
  continue;
111
- }
112
- const other = String(item.path);
113
132
  if (item.reason === null || String(item.reason).trim() === "")
114
133
  problem(path, `${other}: every other file gives the reason the manifest leaves it out`);
115
134
  if (other.includes("\\"))
@@ -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.1",
3
+ "version": "0.6.0",
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",