@scientific-method/standard-checker 0.2.0 → 0.4.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
@@ -13,7 +13,13 @@ pnpm exec standard-checker --check # check, and fail on a stale index or PARI
13
13
 
14
14
  It exits with 0 when the repository passes, 1 when it reports problems (one line per problem,
15
15
  starting with the file's path), and 2 when the options are invalid. `--record-validation` cannot be
16
- combined with `--check`.
16
+ combined with `--check`, nor `--require-ksc` with `--no-ksy`.
17
+
18
+ A pass ends with `spec check passed:` and the counts of entries, parity rows and deviations. When a
19
+ step of the check did not run, it ends with `spec check passed with skipped steps:`, the counts, and
20
+ `Skipped:` followed by each step and why, such as
21
+ `Skipped: Kaitai compilation of 2 definitions (--no-ksy).` A failing run lists the skipped steps
22
+ after its problems.
17
23
 
18
24
  A problem that breaks a numbered rule of the standard ends with the rule's label in brackets, such
19
25
  as `[STATUS-14]`. The standard opens that rule with the heading `###### STATUS-14`, anchored at
@@ -28,7 +34,8 @@ problems under other sections carry no label yet.
28
34
  | `--root <dir>` | The repository to check. | the current directory |
29
35
  | `--check` | Fail when an index or `PARITY.md` is stale, instead of rewriting it. | rewrite |
30
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 |
31
- | `--no-ksy` | Skip compiling the Kaitai definitions in `spec/formats/`. | compile |
37
+ | `--no-ksy` | Skip compiling the Kaitai definitions in `spec/formats/`. The result line names the skipped compilation. | compile |
38
+ | `--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 |
32
39
  | `--glossary <path>` | Also accept the terms of a draft glossary file, or of a directory of them. | none |
33
40
  | `--code <dirs>` | Comma-separated directories whose files may cite spec and deviation IDs and hold `PLACEHOLDER` comments. | `src,tests,tools` |
34
41
  | `--references <dirs>` | Comma-separated directories whose files may cite IDs but whose `PLACEHOLDER` comments do not count against parity. | none |
@@ -39,12 +46,14 @@ problems under other sections carry no label yet.
39
46
  | `--help` | Print the options. | |
40
47
 
41
48
  The `KSC` environment variable names the Kaitai Struct compiler. Without it, the checker looks for
42
- `kaitai-struct-compiler` or `ksc` on `PATH`, and warns when it finds neither.
49
+ `kaitai-struct-compiler` or `ksc` on `PATH`. When it finds neither, it skips the compilation and
50
+ names it in the result line, or with `--require-ksc` reports the missing compiler as a problem.
43
51
 
44
52
  ## In GitHub Actions
45
53
 
46
54
  The toolkit's `actions/check-documentation` composite action runs this checker with `--check`,
47
- installs a pinned Kaitai compiler when the repository has `.ksy` files, and exposes every option
55
+ installs a pinned Kaitai compiler when the repository has `.ksy` files (and then passes
56
+ `--require-ksc`), and exposes every option
48
57
  above as an input. Pin the action to the toolkit commit whose `packages/standard-checker` matches
49
58
  the version installed here, so CI and local runs apply the same checks.
50
59
 
@@ -40,6 +40,7 @@ export function checkEntries(ctx) {
40
40
  const names = {
41
41
  enumNames: new Map(), // name -> format IDs
42
42
  fieldNames: new Map(), // format ID -> Set of names
43
+ layouts: new Map(), // format ID -> layout Name -> Type
43
44
  };
44
45
  for (const [id, e] of entries) {
45
46
  const { file, meta, kind } = e;
@@ -0,0 +1,4 @@
1
+ import type { Context } from "../context.ts";
2
+ import type { FormatNames } from "./formats.ts";
3
+ /** Checks the field names of every live rule's procedure. Runs after checkRules, which sets Entry.code. */
4
+ export declare function checkFieldNames(ctx: Context, { layouts }: FormatNames): void;
@@ -0,0 +1,314 @@
1
+ // Field names in procedures: every name after a dot, on a structure whose type is known, is a Name in
2
+ // the layout of that structure's format.
3
+ //
4
+ // A type is known only where it is written in the notation's type form: `FMT-DATA-005`, a pointer to
5
+ // one (`PTR32<FMT-DATA-005>`) or a list of either (`FMT-DATA-005[]`). The checker reads it from the
6
+ // places the standard's Notation lists:
7
+ //
8
+ // - a `let` with a type, `let x = new FMT-...`, or a `let` whose value has a known type: another
9
+ // name or field, `copy` of one, a function whose `define` gives its result type, or a rule's
10
+ // `call` whose Outputs open with "Returns" and a type;
11
+ // - the rule's Parameters section, as `name: type`, and the parameters of a `define`;
12
+ // - for a name that is not a local, its glossary entry, as `name: type`, or a location in the form
13
+ // "field `x` of FMT-..." whose layout row has a type;
14
+ // - a `for each` loop variable takes the element type of the list it visits.
15
+ //
16
+ // A field takes the type in its layout row, so a chain such as `world.occupancy.cell_count` is
17
+ // followed row by row. An index into a list gives its element, and an index on a pointer gives the
18
+ // structure it points at; an index on anything else, or a field of a list, ends what is known. A
19
+ // format's mention in prose gives no type, since prose names formats for many reasons. A name whose
20
+ // type is not written in one of these forms, or that two declarations give different types, stays
21
+ // unchecked. A declaration that writes no type, such as a parameter the Parameters section describes
22
+ // in prose or an untyped parameter of a define, gives none and so differs from no other.
23
+ import { asList } from "../ids.js";
24
+ import { parameterNames, withoutCommentsAndStrings } from "./rules.js";
25
+ const sameType = (a, b) => !!a && !!b && a.format.join(" ") === b.format.join(" ") && a.pointer === b.pointer && a.list === b.list;
26
+ const FMT_ID = String.raw `FMT-[A-Z][A-Z0-9]*-\d{3,}`;
27
+ // A type in the notation that holds a structure: a format ID, a pointer to one, or a list of either.
28
+ const STRUCTURE_TYPE = String.raw `(?:[A-Z][A-Z0-9]*<)?${FMT_ID}>?(?:\[[^\]\n]*\])?`;
29
+ const WHOLE_TYPE = new RegExp(String.raw `^([A-Z][A-Z0-9]*<)?(${FMT_ID})(>)?(\[[^\]\n]*\])?$`);
30
+ // `name: type`, with the backticks around the whole or around each part.
31
+ const TYPED_NAME = new RegExp(String.raw `\x60([a-z_][a-z0-9_]*)\x60?\s*:\s*\x60?(${STRUCTURE_TYPE})(?![\w-])`, "g");
32
+ // Where the glossary says the game keeps a value: a field of a structure.
33
+ const LOCATION = new RegExp(String.raw `\bfield\s+\x60([A-Za-z_][A-Za-z0-9_]*)\x60\s+of\s+(${FMT_ID})|\x60([A-Za-z_][A-Za-z0-9_]*)\x60\s+field\s+of\s+(${FMT_ID})`, "g");
34
+ const RETURNS = new RegExp(String.raw `^Returns\s+(?:an?\s+)?\x60?(${STRUCTURE_TYPE})\x60?(?=[\s,.;]|$)`);
35
+ const LOWER = /[a-z_][a-z0-9_]*/y;
36
+ const FIELD = /[A-Za-z_][A-Za-z0-9_]*/y;
37
+ // Whether the bracket that opens at text[open] closes at the end of text.
38
+ function closesAtEnd(text, open) {
39
+ let depth = 0;
40
+ for (let k = open; k < text.length; k++) {
41
+ if (text[k] === "(")
42
+ depth++;
43
+ else if (text[k] === ")" && --depth === 0)
44
+ return k === text.length - 1;
45
+ }
46
+ return false;
47
+ }
48
+ // Reads a chain that starts at i, or returns null when text[i] does not start a lower-case name.
49
+ function readChain(text, i) {
50
+ LOWER.lastIndex = i;
51
+ const m = LOWER.exec(text);
52
+ if (!m)
53
+ return null;
54
+ const steps = [];
55
+ const upTo = [];
56
+ let j = i + m[0].length;
57
+ for (;;) {
58
+ let k = j;
59
+ while (text[k] === " ")
60
+ k++;
61
+ if (text[k] === "[") {
62
+ let depth = 0;
63
+ for (; k < text.length; k++) {
64
+ if (text[k] === "[")
65
+ depth++;
66
+ else if (text[k] === "]" && --depth === 0)
67
+ break;
68
+ }
69
+ if (k >= text.length)
70
+ break;
71
+ j = k + 1;
72
+ steps.push(null);
73
+ upTo.push(text.slice(i, j));
74
+ continue;
75
+ }
76
+ if (text[j] !== ".")
77
+ break;
78
+ FIELD.lastIndex = j + 1;
79
+ const f = FIELD.exec(text);
80
+ if (!f)
81
+ break;
82
+ j += 1 + f[0].length;
83
+ steps.push(f[0]);
84
+ upTo.push(text.slice(i, j));
85
+ }
86
+ return { base: m[0], steps, upTo, end: j };
87
+ }
88
+ /** Checks the field names of every live rule's procedure. Runs after checkRules, which sets Entry.code. */
89
+ export function checkFieldNames(ctx, { layouts }) {
90
+ const { problem } = ctx;
91
+ const { entries, glossary } = ctx.spec;
92
+ // A format ID and the entries it is split with, which define the same fields.
93
+ const formatOf = (id) => {
94
+ const e = entries.get(id);
95
+ if (!e || e.kind !== "FMT")
96
+ return null;
97
+ return [id, ...asList(e.meta.split_with).filter((x) => entries.get(x)?.kind === "FMT")].sort();
98
+ };
99
+ // The type a type written in the notation gives, or null for a type that holds no structure.
100
+ const typeOf = (text) => {
101
+ const m = WHOLE_TYPE.exec((text ?? "").replaceAll("`", "").trim());
102
+ if (!m || !m[1] !== !m[3])
103
+ return null;
104
+ const format = formatOf(m[2]);
105
+ return format ? { format, pointer: !!m[1], list: !!m[4] } : null;
106
+ };
107
+ // The types text gives names in the form `name: type`; a name given two types has none.
108
+ const typedNames = (text) => {
109
+ const found = new Map();
110
+ for (const m of text.matchAll(TYPED_NAME)) {
111
+ const type = typeOf(m[2]);
112
+ found.set(m[1], found.has(m[1]) && !sameType(found.get(m[1]), type) ? null : type);
113
+ }
114
+ return found;
115
+ };
116
+ // The layout rows of a format, or null when one of its entries has a malformed layout table. A
117
+ // field whose entries give it different types has no type.
118
+ const formatRows = new Map();
119
+ const plain = (type) => type.replaceAll("`", "").trim();
120
+ const rowsOf = (format) => {
121
+ const key = format.join(" ");
122
+ if (formatRows.has(key))
123
+ return formatRows.get(key);
124
+ let rows = new Map();
125
+ for (const id of format) {
126
+ const layout = layouts.get(id);
127
+ if (!layout) {
128
+ rows = null;
129
+ break;
130
+ }
131
+ for (const [name, type] of layout)
132
+ rows.set(name, rows.has(name) && plain(rows.get(name)) !== plain(type) ? "" : type);
133
+ }
134
+ formatRows.set(key, rows);
135
+ return rows;
136
+ };
137
+ const termTypes = new Map();
138
+ const termType = (term) => {
139
+ if (termTypes.has(term))
140
+ return termTypes.get(term);
141
+ const text = glossary.get(term) ?? "";
142
+ const typed = typedNames(text);
143
+ let type = null;
144
+ if (typed.has(term))
145
+ type = typed.get(term);
146
+ else {
147
+ const locations = [...text.matchAll(LOCATION)];
148
+ if (locations.length === 1) {
149
+ const format = formatOf(locations[0][2] ?? locations[0][4]);
150
+ type = format ? typeOf(rowsOf(format)?.get(locations[0][1] ?? locations[0][3])) : null;
151
+ }
152
+ }
153
+ termTypes.set(term, type);
154
+ return type;
155
+ };
156
+ // The type of the value a rule returns, from the opening of its Outputs section.
157
+ const returnType = (id) => {
158
+ const outputs = entries
159
+ .get(id)
160
+ ?.sections.find((s) => s.title === "Outputs")
161
+ ?.text.trim() ?? "";
162
+ const m = RETURNS.exec(outputs);
163
+ return m ? typeOf(m[1]) : null;
164
+ };
165
+ const live = [...entries.values()].filter((e) => e.kind === "RULE" && e.meta.status !== "superseded" && e.code);
166
+ // Functions whose define gives a structure as the result: define name(...) -> FMT-...
167
+ const functionTypes = new Map();
168
+ for (const e of live)
169
+ for (const m of withoutCommentsAndStrings(e.code).matchAll(/\bdefine\s+([a-z_][a-z0-9_]*)\s*\([^)]*\)\s*->\s*([^:\n]+):/g)) {
170
+ const type = typeOf(m[2]);
171
+ // The entries of a split rule define the same function; if they disagree, its type is unknown.
172
+ const disagrees = functionTypes.has(m[1]) && !sameType(functionTypes.get(m[1]), type);
173
+ functionTypes.set(m[1], disagrees ? null : type);
174
+ }
175
+ for (const e of live)
176
+ checkRule(e);
177
+ function checkRule(e) {
178
+ const lines = withoutCommentsAndStrings(e.code).split("\n");
179
+ const params = e.sections.find((s) => s.title === "Parameters")?.text ?? "";
180
+ const declarations = [];
181
+ const declare = (name, type) => declarations.push({ name, type });
182
+ const fixed = (type, source) => () => type === undefined ? undefined : type ? { ...type, source } : null;
183
+ const typedParams = typedNames(params);
184
+ for (const name of parameterNames(params))
185
+ declare(name, fixed(typedParams.has(name) ? typedParams.get(name) : undefined, "the Parameters section"));
186
+ for (const line of lines) {
187
+ const d = /\bdefine\s+[a-z_][a-z0-9_]*\s*\(([^)]*)\)/.exec(line);
188
+ if (d)
189
+ for (const p of d[1].split(",")) {
190
+ const [name, type] = p.split(":");
191
+ if (/^[a-z_][a-z0-9_]*$/.test(name.trim()))
192
+ declare(name.trim(), fixed(type === undefined ? undefined : typeOf(type), "its define"));
193
+ }
194
+ const l = /^\s*let\s+([a-z_][a-z0-9_]*)\s*(?::\s*([^=]+?))?\s*=\s*(.+?)\s*$/.exec(line);
195
+ if (l) {
196
+ const [, name, type, value] = l;
197
+ if (type)
198
+ declare(name, fixed(typeOf(type), "its let"));
199
+ else
200
+ declare(name, (known) => valueType(value, known, "its let"));
201
+ }
202
+ const f = /^\s*for\s+(?:each\s+)?([a-z_][a-z0-9_]*)\s+in\s+(.+?)\s*:\s*$/.exec(line);
203
+ if (f)
204
+ declare(f[1], (known) => {
205
+ const list = f[2].includes("..") ? null : valueType(f[2], known, "the list its loop visits");
206
+ return list?.list ? { ...list, list: false } : null;
207
+ });
208
+ }
209
+ const locals = new Set(declarations.map((d) => d.name));
210
+ // A let can take its type from another local, so the types are worked out again until they
211
+ // stop changing.
212
+ let types = new Map();
213
+ const known = (name) => {
214
+ if (locals.has(name))
215
+ return types.get(name) ?? null;
216
+ const type = termType(name);
217
+ return type ? { ...type, source: `the glossary entry for ${name}` } : null;
218
+ };
219
+ const settledAs = (a, b) => a.size === b.size && [...a].every(([name, t]) => sameType(t, b.get(name) ?? null));
220
+ for (let pass = 0; pass <= declarations.length; pass++) {
221
+ const found = new Map();
222
+ for (const d of declarations) {
223
+ const t = d.type(known);
224
+ if (t === undefined)
225
+ continue;
226
+ found.set(d.name, found.has(d.name) && !sameType(found.get(d.name), t) ? null : t);
227
+ }
228
+ const next = new Map();
229
+ for (const [name, t] of found)
230
+ if (t)
231
+ next.set(name, t);
232
+ const settled = settledAs(next, types);
233
+ types = next;
234
+ if (settled)
235
+ break;
236
+ }
237
+ for (const line of lines)
238
+ for (let i = 0; i < line.length; i++) {
239
+ if (/[\w.]/.test(line[i - 1] ?? ""))
240
+ continue;
241
+ const chain = readChain(line, i);
242
+ if (!chain)
243
+ continue;
244
+ // Names inside the chain's indexes start chains of their own.
245
+ i += chain.base.length - 1;
246
+ if (chain.steps.some((s) => s !== null))
247
+ follow(chain, known, (message) => problem(e.file, message));
248
+ }
249
+ }
250
+ // The type of the value of a let, or of the list a for each loop visits, or null when it has none.
251
+ function valueType(value, known, source) {
252
+ const v = value.trim();
253
+ const made = new RegExp(String.raw `^new\s+(${FMT_ID})$`).exec(v);
254
+ if (made) {
255
+ const format = formatOf(made[1]);
256
+ return format ? { format, pointer: false, list: false, source } : null;
257
+ }
258
+ // A call is the whole value only when the bracket after its name closes at the end, so
259
+ // make(a) + other(b) is not a call of make.
260
+ const called = /^call\s+(RULE-[A-Z][A-Z0-9]*-\d{3,})\s*\(/.exec(v);
261
+ if (called && closesAtEnd(v, called[0].length - 1)) {
262
+ const type = returnType(called[1]);
263
+ return type ? { ...type, source: `the Outputs of ${called[1]}` } : null;
264
+ }
265
+ const copied = /^copy\s*\(/.exec(v);
266
+ if (copied && closesAtEnd(v, copied[0].length - 1))
267
+ return valueType(v.slice(copied[0].length, -1), known, source);
268
+ const fn = /^([a-z_][a-z0-9_]*)\s*\(/.exec(v);
269
+ if (fn && closesAtEnd(v, fn[0].length - 1)) {
270
+ const type = functionTypes.get(fn[1]);
271
+ return type ? { ...type, source: `the define of ${fn[1]}` } : null;
272
+ }
273
+ const chain = readChain(v, 0);
274
+ if (!chain || chain.end !== v.length)
275
+ return null;
276
+ const t = follow(chain, known);
277
+ return t ? { ...t, source } : null;
278
+ }
279
+ // Follows a chain access by access and returns the type it ends with. With report, a field missing
280
+ // from its structure's layout is reported.
281
+ function follow(chain, known, report) {
282
+ const base = known(chain.base);
283
+ if (!base)
284
+ return null;
285
+ let type = base;
286
+ for (const [n, field] of chain.steps.entries()) {
287
+ if (field === null) {
288
+ // list[i] is an element, and p[i] is the structure p + i points at.
289
+ if (type.list)
290
+ type = { ...type, list: false };
291
+ else if (type.pointer)
292
+ type = { ...type, pointer: false };
293
+ else
294
+ return null;
295
+ continue;
296
+ }
297
+ if (type.list)
298
+ return null;
299
+ const rows = rowsOf(type.format);
300
+ if (!rows)
301
+ return null;
302
+ const cell = rows.get(field);
303
+ if (cell === undefined) {
304
+ report?.(`${chain.upTo[n]} names ${field}, which is not in the layout of ${type.format.join(" or ")} (${chain.base} has its type from ${base.source})`);
305
+ return null;
306
+ }
307
+ const next = typeOf(cell);
308
+ if (!next)
309
+ return null;
310
+ type = next;
311
+ }
312
+ return { ...type, source: base.source };
313
+ }
314
+ }
@@ -6,6 +6,12 @@ export interface FormatNames {
6
6
  enumNames: Map<string, string[]>;
7
7
  /** Format ID -> the names of its layout and enumeration rows. */
8
8
  fieldNames: Map<string, Set<string>>;
9
+ /**
10
+ * Format ID -> the Name of each layout row -> its Type cell, for a format whose layout tables all
11
+ * have the right columns and whose rows all have one cell per column. A format without a Layout
12
+ * table has an empty map.
13
+ */
14
+ layouts: Map<string, Map<string, string>>;
9
15
  }
10
16
  /** The IDs cited in the last column of the tables of an entry's sections (of every section when sectionTitles is left out). */
11
17
  export declare function tableIds(e: Entry, sectionTitles?: string[]): Set<string>;
@@ -21,7 +21,7 @@ export function tableIds(e, sectionTitles) {
21
21
  export function checkFormat(ctx, e, formatNames) {
22
22
  const { problem } = ctx;
23
23
  const { entries, buildFiles } = ctx.spec;
24
- const { enumNames, fieldNames } = formatNames;
24
+ const { enumNames, fieldNames, layouts } = formatNames;
25
25
  const { file, meta } = e;
26
26
  const id = meta.id;
27
27
  const first = asList(meta.builds)[0];
@@ -88,18 +88,45 @@ export function checkFormat(ctx, e, formatNames) {
88
88
  names.add(row[nameCol].replaceAll("`", ""));
89
89
  }
90
90
  };
91
+ // A row whose status is wrong still names a field, so the field checks of rules read every row.
92
+ const layoutTypes = new Map();
93
+ let wellFormed = true;
91
94
  if (layout) {
92
95
  const ts = tables(layout.text);
93
96
  const wanted = meta.text === true ? TEXT_LAYOUT : BINARY_LAYOUT;
94
97
  if (meta.status !== "unknown" && ts.length === 0)
95
98
  problem(file, "Layout has no table");
96
99
  for (const t of ts) {
97
- if (t.header.join("|") !== wanted.join("|"))
100
+ if (t.header.join("|") !== wanted.join("|")) {
98
101
  problem(file, `a layout table has the columns ${wanted.join(" | ")}`);
99
- else
100
- visit(t, "layout");
102
+ wellFormed = false;
103
+ continue;
104
+ }
105
+ visit(t, "layout");
106
+ const nameCol = t.header.indexOf("Name");
107
+ const typeCol = t.header.indexOf("Type");
108
+ for (const row of t.rows) {
109
+ // A row with the wrong number of cells may hold a field whose Name cell cannot be found.
110
+ if (row.length !== t.header.length) {
111
+ wellFormed = false;
112
+ continue;
113
+ }
114
+ if (!row[nameCol])
115
+ continue;
116
+ // A cell that names more than one field, or a path into a field (`items[i].count`), still
117
+ // names the field it starts with, though not its type.
118
+ for (const name of (row[nameCol].match(/`[^`]+`/g) ?? [row[nameCol]]).map((n) => n.replaceAll("`", "").trim())) {
119
+ const lead = /^[A-Za-z_][A-Za-z0-9_]*/.exec(name)?.[0];
120
+ if (lead === name)
121
+ layoutTypes.set(name, row[typeCol]);
122
+ else if (lead && !layoutTypes.has(lead))
123
+ layoutTypes.set(lead, "");
124
+ }
125
+ }
101
126
  }
102
127
  }
128
+ if (wellFormed)
129
+ layouts.set(id, layoutTypes);
103
130
  // An enumeration table kept in a value file counts as one of the entry's tables.
104
131
  const enumTables = enums ? [...tables(enums.text), ...valueFileTables(ctx, e, enums.text)] : [];
105
132
  e.valueTables = enumTables.filter((t) => t.file);
@@ -1,6 +1,7 @@
1
1
  import type { Context } from "../context.ts";
2
2
  /**
3
3
  * Checks that each .ksy file in spec/formats/ belongs to a format entry, and compiles them all with
4
- * the Kaitai Struct compiler, warning when there is none. Does nothing with --no-ksy.
4
+ * the Kaitai Struct compiler. With --no-ksy, or with no compiler found, the compilation is recorded
5
+ * as a skipped step; with --require-ksc, a missing compiler is a problem instead.
5
6
  */
6
7
  export declare function compileKaitai(ctx: Context): void;
@@ -5,47 +5,60 @@ import { tmpdir } from "node:os";
5
5
  import { basename, 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
- * the Kaitai Struct compiler, warning when there is none. Does nothing with --no-ksy.
8
+ * the Kaitai Struct compiler. With --no-ksy, or with no compiler found, the compilation is recorded
9
+ * as a skipped step; with --require-ksc, a missing compiler is a problem instead.
9
10
  */
10
11
  export function compileKaitai(ctx) {
11
- const { problem } = ctx;
12
+ const { problem, skip } = ctx;
12
13
  const { entries } = ctx.spec;
13
- const { specDir, skipKsy } = ctx.config;
14
- if (!skipKsy) {
15
- const ksys = [];
16
- const fd = join(specDir, "formats");
17
- if (existsSync(fd))
18
- for (const f of readdirSync(fd))
19
- if (f.endsWith(".ksy"))
20
- ksys.push(join(fd, f));
21
- for (const k of ksys) {
22
- const id = basename(k, ".ksy")
23
- .toUpperCase()
24
- .replace(/^FMT_([A-Z0-9]+)_(\d+)$/, "FMT-$1-$2");
25
- if (!entries.has(id))
26
- problem(k, `belongs to no format entry (${id})`);
27
- }
28
- const compiler = findKaitai();
29
- if (compiler && ksys.length) {
30
- const out = mkdtempSync(join(tmpdir(), "ksy-check-"));
31
- const fixed = [...compiler.args, "--target", "python", "--outdir", out, "--import-path", fd];
14
+ const { specDir, skipKsy, requireKsc } = ctx.config;
15
+ const ksys = [];
16
+ const fd = join(specDir, "formats");
17
+ if (existsSync(fd))
18
+ for (const f of readdirSync(fd))
19
+ if (f.endsWith(".ksy"))
20
+ ksys.push(join(fd, f));
21
+ for (const k of ksys) {
22
+ const id = basename(k, ".ksy")
23
+ .toUpperCase()
24
+ .replace(/^FMT_([A-Z0-9]+)_(\d+)$/, "FMT-$1-$2");
25
+ if (!entries.has(id))
26
+ problem(k, `belongs to no format entry (${id})`);
27
+ }
28
+ if (!ksys.length)
29
+ return;
30
+ const definitions = `${ksys.length} definition${ksys.length === 1 ? "" : "s"}`;
31
+ if (skipKsy) {
32
+ skip(`Kaitai compilation of ${definitions} (--no-ksy)`);
33
+ return;
34
+ }
35
+ const compiler = findKaitai();
36
+ if (!compiler) {
37
+ const missing = "no Kaitai Struct compiler found, set KSC or install kaitai-struct-compiler";
38
+ if (requireKsc)
39
+ problem(null, `${missing}. --require-ksc requires compiling the ${definitions} in spec/formats/`);
40
+ else
41
+ skip(`Kaitai compilation of ${definitions} (${missing})`);
42
+ return;
43
+ }
44
+ const out = mkdtempSync(join(tmpdir(), "ksy-check-"));
45
+ const fixed = [...compiler.args, "--target", "python", "--outdir", out, "--import-path", fd];
46
+ try {
47
+ for (const batch of kaitaiBatches([compiler.cmd, ...fixed], ksys)) {
32
48
  try {
33
- for (const batch of kaitaiBatches([compiler.cmd, ...fixed], ksys)) {
34
- try {
35
- runTool(compiler.cmd, [...fixed, ...batch]);
36
- }
37
- catch (err) {
38
- const failure = err;
39
- problem(null, `Kaitai definitions do not compile:\n${String(failure.stdout ?? "")}${String(failure.stderr ?? "")}`);
40
- }
41
- }
49
+ runTool(compiler.cmd, [...fixed, ...batch]);
42
50
  }
43
- finally {
44
- rmSync(out, { recursive: true, force: true });
51
+ catch (err) {
52
+ // A compiler that cannot be started, such as a KSC naming a missing file, prints nothing,
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))}`);
45
57
  }
46
58
  }
47
- else if (ksys.length)
48
- console.warn("warning: no Kaitai Struct compiler found (set KSC or install kaitai-struct-compiler); definitions were not compiled.");
59
+ }
60
+ finally {
61
+ rmSync(out, { recursive: true, force: true });
49
62
  }
50
63
  }
51
64
  // cmd.exe takes a command line of at most 8,191 characters, and the compiler's .bat launcher adds
@@ -1,4 +1,11 @@
1
1
  import type { Context } from "../context.ts";
2
2
  import type { FormatNames } from "./formats.ts";
3
+ /**
4
+ * A procedure with each string literal emptied and its comments dropped. Strings go first, so a `#`
5
+ * inside one does not start a comment, and a string ends on the line it starts on.
6
+ */
7
+ export declare const withoutCommentsAndStrings: (code: string) => string;
8
+ /** The names a rule's Parameters section declares: each lower-case name that opens a code span, as `n` or `n: type`. */
9
+ export declare const parameterNames: (params: string) => string[];
3
10
  /** Checks every rule's procedure. Sets Entry.code on every rule entry, superseded ones included. */
4
11
  export declare function checkRules(ctx: Context, { enumNames }: FormatNames): void;
@@ -6,6 +6,13 @@ import { mayBeInterrupted, onlyEmulatedRuns } from "../evidence.js";
6
6
  import { asList, idsIn, kindOf } from "../ids.js";
7
7
  import { readCsv } from "../markdown.js";
8
8
  import { KINDS, LIST_LIMIT } from "../standard.js";
9
+ /**
10
+ * A procedure with each string literal emptied and its comments dropped. Strings go first, so a `#`
11
+ * inside one does not start a comment, and a string ends on the line it starts on.
12
+ */
13
+ export const withoutCommentsAndStrings = (code) => code.replace(/"[^"\n]*"/g, '""').replace(/#.*$/gm, "");
14
+ /** The names a rule's Parameters section declares: each lower-case name that opens a code span, as `n` or `n: type`. */
15
+ export const parameterNames = (params) => [...params.matchAll(/`([a-z_][a-z0-9_]*)(?=`|\s*:)/g)].map((m) => m[1]);
9
16
  const BUILTINS = new Set([
10
17
  "min",
11
18
  "max",
@@ -102,10 +109,7 @@ export function checkRules(ctx, { enumNames }) {
102
109
  // Types in a define's signature (`define roll(n: UINT16) -> char[]:`) are not names the
103
110
  // procedure reads, so they are dropped before the name checks.
104
111
  const TYPE = String.raw `(?:[A-Za-z][A-Za-z0-9]*|FMT-[A-Z0-9]+-\d+)(?:\[[^\]]*\])?`;
105
- const code = e
106
- .code.replace(/#.*$/gm, "")
107
- .replace(/"[^"]*"/g, '""')
108
- .replace(new RegExp(String.raw `(\bdefine\s+[a-z_][a-z0-9_]*\s*\()([^)]*)\)(\s*->\s*${TYPE})?`, "g"), (_, head, params) => `${head}${params
112
+ const code = withoutCommentsAndStrings(e.code).replace(new RegExp(String.raw `(\bdefine\s+[a-z_][a-z0-9_]*\s*\()([^)]*)\)(\s*->\s*${TYPE})?`, "g"), (_, head, params) => `${head}${params
109
113
  .split(",")
110
114
  .map((p) => p.split(":")[0].trim())
111
115
  .join(", ")})`);
@@ -196,8 +200,8 @@ export function checkRules(ctx, { enumNames }) {
196
200
  for (const p of m[1].split(","))
197
201
  locals.add(p.split(":")[0].trim());
198
202
  const params = e.sections.find((s) => s.title === "Parameters")?.text ?? "";
199
- for (const m of params.matchAll(/`([a-z_][a-z0-9_]*)`/g))
200
- locals.add(m[1]);
203
+ for (const name of parameterNames(params))
204
+ locals.add(name);
201
205
  for (const m of code.matchAll(/(?<![.\w])([a-z_][a-z0-9_]*)\s*\(/g)) {
202
206
  const name = m[1];
203
207
  if (BUILTINS.has(name) || KEYWORDS.has(name) || locals.has(name))
package/dist/context.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Config } from "./options.ts";
2
- import type { Problem } from "./problems.ts";
2
+ import type { Problem, Skip } from "./problems.ts";
3
3
  import type { CodeFile, CodeRange, Entry, Meta } from "./types.ts";
4
4
  /** The spec as the load phase read it. Later phases add to entries (Entry.code, Entry.valueTables). */
5
5
  export interface Spec {
@@ -27,6 +27,8 @@ export interface LoadContext {
27
27
  }
28
28
  /** What every phase after the load needs. */
29
29
  export interface Context extends LoadContext {
30
+ /** Records a step of the check that did not run. */
31
+ skip: Skip;
30
32
  /** The spec as loaded. */
31
33
  spec: Spec;
32
34
  /** The files of the --code and --references directories, read on first use. */
package/dist/options.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface Config {
8
8
  checkOnly: boolean;
9
9
  /** --no-ksy: skip compiling the Kaitai definitions. */
10
10
  skipKsy: boolean;
11
+ /** --require-ksc: fail when there are Kaitai definitions and no compiler is found. */
12
+ requireKsc: boolean;
11
13
  /** --base, or null when it was not given. */
12
14
  baseArg: string | null;
13
15
  /** Every --glossary path, in order. */
package/dist/options.js CHANGED
@@ -10,7 +10,7 @@ export const dirList = (value, fallback) => value === undefined
10
10
  .split(",")
11
11
  .map((x) => x.trim())
12
12
  .filter(Boolean);
13
- const FLAGS = ["--check", "--no-ksy"];
13
+ const FLAGS = ["--check", "--no-ksy", "--require-ksc"];
14
14
  const VALUED = [
15
15
  "--root",
16
16
  "--base",
@@ -52,6 +52,11 @@ export function parseOptions(argv) {
52
52
  process.exit(2);
53
53
  }
54
54
  const skipKsy = options["no-ksy"] === true;
55
+ const requireKsc = options["require-ksc"] === true;
56
+ if (skipKsy && requireKsc) {
57
+ console.error("--require-ksc requires the Kaitai compilation that --no-ksy skips, so they cannot be combined");
58
+ process.exit(2);
59
+ }
55
60
  const baseArg = options.base ?? null;
56
61
  const codeRoots = dirList(options.code, ["src", "tests", "tools"]);
57
62
  const referenceRoots = dirList(options.references, []);
@@ -75,6 +80,7 @@ export function parseOptions(argv) {
75
80
  specDir,
76
81
  checkOnly,
77
82
  skipKsy,
83
+ requireKsc,
78
84
  baseArg,
79
85
  glossaryDrafts: options.glossary,
80
86
  codeRoots,
@@ -12,15 +12,34 @@ export type Rule = `IDENTIFIERS-${UpTo<7>}` | `STATUS-${UpTo<41>}` | `ENTRY-TYPE
12
12
  * a numbered rule names it.
13
13
  */
14
14
  export type Problem = (file: string | null, message: string, rule?: Rule) => void;
15
+ /**
16
+ * Records a step of the check that did not run, with the reason, such as "Kaitai compilation of 2
17
+ * definitions (--no-ksy)". The result line names every skipped step, so a pass never reads as a
18
+ * full check when part of it did not run. The line joins the steps with "; ", so a step's text
19
+ * must not contain "; ".
20
+ */
21
+ export type Skip = (step: string) => void;
22
+ /** What a run found, for the result line. */
23
+ export interface Counts {
24
+ /** Spec entries. */
25
+ entries: number;
26
+ /** PARITY.md rows. */
27
+ parityRows: number;
28
+ /** Deviations. */
29
+ deviations: number;
30
+ }
15
31
  /** The problem collector of one run. */
16
32
  export interface Problems {
17
33
  /** Records a problem. */
18
34
  problem: Problem;
35
+ /** Records a skipped step. */
36
+ skip: Skip;
19
37
  /**
20
- * Prints every distinct problem in the order found, with the summary, and exits with 1. Returns
21
- * when there are none.
38
+ * With problems, prints every distinct one in the order found, the summary and the skipped
39
+ * steps, and exits with 1. With none, prints the result line: "spec check passed: " and counts,
40
+ * or "spec check passed with skipped steps: " and counts followed by the skipped steps.
22
41
  */
23
- report(entryCount: number): void;
42
+ report(counts: Counts): void;
24
43
  }
25
44
  /** Creates the collector for a run over repoDir, against which problem paths are printed. */
26
45
  export declare function createProblems(repoDir: string): Problems;
package/dist/problems.js CHANGED
@@ -3,6 +3,7 @@ import { relative } from "node:path";
3
3
  /** Creates the collector for a run over repoDir, against which problem paths are printed. */
4
4
  export function createProblems(repoDir) {
5
5
  const problems = [];
6
+ const skipped = [];
6
7
  let citedRule = false;
7
8
  // A problem that breaks a numbered rule ends with the rule's label, so whoever fixes it can read
8
9
  // that one rule instead of the whole section.
@@ -11,17 +12,27 @@ export function createProblems(repoDir) {
11
12
  citedRule = true;
12
13
  problems.push(`${file ? relative(repoDir, file).replaceAll("\\", "/") : "spec"}: ${message}${rule ? ` [${rule}]` : ""}`);
13
14
  };
14
- const report = (entryCount) => {
15
+ const skip = (step) => {
16
+ skipped.push(step);
17
+ };
18
+ const skippedLine = () => `Skipped: ${skipped.join("; ")}.`;
19
+ const report = ({ entries, parityRows, deviations }) => {
20
+ const counts = `${entries} entries, ${parityRows} parity rows, ${deviations} deviations.`;
15
21
  // The same problem can be found twice, such as a term that cites one finding in two places.
16
22
  const unique = [...new Set(problems)];
17
23
  if (unique.length) {
18
24
  for (const p of unique)
19
25
  console.error(p);
20
- console.error(`\n${unique.length} problem(s) in ${entryCount} spec entries.`);
26
+ console.error(`\n${unique.length} problem(s) in ${entries} spec entries.`);
21
27
  if (citedRule)
22
28
  console.error("A label in brackets, such as [STATUS-14], names the rule of the documentation standard that the problem breaks. The standard opens it with the heading ###### STATUS-14, anchored at https://dinorefurb.com/documentation-standard/#status-14 and at #status-14 in a vendored copy.");
29
+ if (skipped.length)
30
+ console.error(skippedLine());
23
31
  process.exit(1);
24
32
  }
33
+ console.log(skipped.length
34
+ ? `spec check passed with skipped steps: ${counts} ${skippedLine()}`
35
+ : `spec check passed: ${counts}`);
25
36
  };
26
- return { problem, report };
37
+ return { problem, skip, report };
27
38
  }
@@ -10,7 +10,9 @@
10
10
  // --check fail when an index or PARITY.md is stale instead of rewriting it
11
11
  // --base <ref> also fail when an ID or area that exists at <ref> is gone (default: where
12
12
  // HEAD forked from origin/$GITHUB_BASE_REF or origin/main, when it resolves)
13
- // --no-ksy skip compiling the Kaitai definitions
13
+ // --no-ksy skip compiling the Kaitai definitions; the result line names the skip
14
+ // --require-ksc fail when spec/formats/ holds Kaitai definitions and no compiler is found,
15
+ // instead of passing with the compilation skipped
14
16
  // --glossary <path> also accept the terms of a draft glossary file, or of a directory of them
15
17
  // --code <dirs> comma-separated directories whose files may cite spec and deviation IDs
16
18
  // and hold PLACEHOLDER comments (default: src,tests,tools)
@@ -36,16 +38,22 @@
36
38
  // [STATUS-4] for the rule whose heading is anchored at #status-4.
37
39
  //
38
40
  // The KSC environment variable names the Kaitai Struct compiler. Without it, the check looks for
39
- // kaitai-struct-compiler or ksc on PATH, and warns when it finds neither.
41
+ // kaitai-struct-compiler or ksc on PATH. When it finds neither, the run skips the compilation and
42
+ // says so in its result line, or fails with --require-ksc.
43
+ //
44
+ // It exits with 0 when the spec passes, 1 when it reports problems, and 2 when the options are
45
+ // invalid. A pass prints "spec check passed: " and the counts, or, when a step did not run,
46
+ // "spec check passed with skipped steps: " and the counts followed by "Skipped: " and each step
47
+ // with its reason.
40
48
  //
41
49
  // No dependencies. The YAML reader understands the subset the standard's front matter uses:
42
50
  // scalars, flow lists, and block lists of flat maps.
43
51
  //
44
52
  // This file reads the options and runs the phases in order: load the spec, check the entries, the
45
- // rules and what crosses entries, compile the Kaitai definitions, check the deviations, parity,
46
- // VALIDATION.md, the code's references and comments and the base ref, then write or check the
47
- // generated files. Every phase reports into one collector, which prints the problems at the end in
48
- // the order they were found.
53
+ // rules, the field names in their procedures and what crosses entries, compile the Kaitai
54
+ // definitions, check the deviations, parity, VALIDATION.md, the code's references and comments and
55
+ // the base ref, then write or check the generated files. Every phase reports into one collector,
56
+ // which prints the problems at the end in the order they were found.
49
57
  import { readFileSync } from "node:fs";
50
58
  import { dirname } from "node:path";
51
59
  import { fileURLToPath } from "node:url";
@@ -54,6 +62,7 @@ import { checkCommentAddresses } from "./checks/comment-addresses.js";
54
62
  import { checkAcrossEntries } from "./checks/cross-entry.js";
55
63
  import { checkDeviations } from "./checks/deviations.js";
56
64
  import { checkEntries } from "./checks/entries.js";
65
+ import { checkFieldNames } from "./checks/fields.js";
57
66
  import { compileKaitai } from "./checks/kaitai.js";
58
67
  import { checkParity } from "./checks/parity.js";
59
68
  import { checkReferences } from "./checks/references.js";
@@ -77,13 +86,14 @@ if (argv.includes("--help") || argv.includes("-h")) {
77
86
  process.exit(0);
78
87
  }
79
88
  const config = parseOptions(argv);
80
- const { problem, report } = createProblems(config.repoDir);
89
+ const { problem, skip, report } = createProblems(config.repoDir);
81
90
  const spec = loadSpec({ config, problem });
82
91
  // The checker's modules all sit in this file's directory, which holds nothing else, so the code
83
92
  // checks leave the whole directory out.
84
- const ctx = { config, problem, spec, codeFiles: createCodeFiles(config, dirname(selfPath)) };
93
+ const ctx = { config, problem, skip, spec, codeFiles: createCodeFiles(config, dirname(selfPath)) };
85
94
  const formatNames = checkEntries(ctx);
86
95
  checkRules(ctx, formatNames);
96
+ checkFieldNames(ctx, formatNames);
87
97
  checkAcrossEntries(ctx, formatNames);
88
98
  compileKaitai(ctx);
89
99
  const deviations = checkDeviations(ctx);
@@ -96,5 +106,4 @@ const generated = generateIndexes(ctx);
96
106
  generateParity(ctx, parity, generated);
97
107
  writeGenerated(ctx, generated);
98
108
  checkLineLimits(ctx);
99
- report(spec.entries.size);
100
- console.log(`spec check passed: ${spec.entries.size} entries, ${parity.rows.size} parity rows, ${deviations.size} deviations.`);
109
+ report({ entries: spec.entries.size, parityRows: parity.rows.size, deviations: deviations.size });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scientific-method/standard-checker",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",