@scientific-method/standard-checker 2.0.0 → 2.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 CHANGED
@@ -20,7 +20,9 @@ A pass ends with `spec check passed:` and the counts of entries, parity rows and
20
20
  step of the check did not run, it ends with `spec check passed with skipped steps:`, the counts, and
21
21
  `Skipped:` followed by each step and why, such as
22
22
  `Skipped: Kaitai compilation of 2 definitions (--no-ksy).` A failing run lists the skipped steps
23
- after its problems.
23
+ after its problems. A `call` or `emit` whose arguments cannot be counted, because the Parameters
24
+ section it is counted against is missing or is not `None.` or a list of parameters, or because its
25
+ argument list is never closed, is a skipped step as well.
24
26
 
25
27
  Without `--base`, the checker compares the spec with where HEAD forked from `origin/$GITHUB_BASE_REF`,
26
28
  or from `origin/main` when that variable is unset. Outside a git repository, in a shallow clone, or
@@ -0,0 +1,17 @@
1
+ import type { Context } from "../context.ts";
2
+ /**
3
+ * The number of parameters a rule's Parameters section lists: 0 for `None.`, otherwise the number
4
+ * of items of a Markdown list whose items each open with a code span holding a name, or a name, a
5
+ * colon and a type, followed directly by a colon (`` - `gang: FMT-DATA-005`: the gang ``). An item
6
+ * may continue on indented lines. Null when the section holds anything else, so its parameters
7
+ * cannot be counted: `None known.`, prose after the list, or an item that names two parameters
8
+ * (`` - `x`, `y`: the cell ``) or puts anything between its code span and the colon.
9
+ */
10
+ export declare function parameterCount(section: string): number | null;
11
+ /**
12
+ * The number of arguments in the parenthesised list that opens at code[open], counting the commas
13
+ * outside nested brackets. Null when the list is not closed.
14
+ */
15
+ export declare function argumentCount(code: string, open: number): number | null;
16
+ /** Checks the argument count of every call, function call and emit in a live rule's procedure. */
17
+ export declare function checkArgumentCounts(ctx: Context): void;
@@ -0,0 +1,195 @@
1
+ // Argument counts: every `call` passes one argument for each item of the called rule's Parameters
2
+ // list, every call to a function a rule defines one for each parameter of its `define`, and every
3
+ // `emit` one for each item of the Parameters list of each handler its glossary entry names. Two
4
+ // `emit`s of one event in rules that share a build pass the same number of arguments, which is the
5
+ // only check an event with no handlers gets.
6
+ //
7
+ // A call to a split rule, and an emit to a split handler, is counted against each entry of the
8
+ // split that lists one of the calling or emitting rule's builds, and a call to a function that a
9
+ // split rule defines against the `define` of each such entry. A Parameters section in any other
10
+ // form than `None.` or the list gives no count, so the calls and emits that depend on it are named
11
+ // as a skipped step and do not fail the check. A call or emit whose argument list is never closed
12
+ // is named as a skipped step too. Only live rules are checked, and only live rules' `define`s are
13
+ // counted against.
14
+ import { asList, idsIn, kindOf } from "../ids.js";
15
+ import { BUILTINS, defines, procedureLocals, withoutCommentsAndStrings } from "./rules.js";
16
+ /**
17
+ * The number of parameters a rule's Parameters section lists: 0 for `None.`, otherwise the number
18
+ * of items of a Markdown list whose items each open with a code span holding a name, or a name, a
19
+ * colon and a type, followed directly by a colon (`` - `gang: FMT-DATA-005`: the gang ``). An item
20
+ * may continue on indented lines. Null when the section holds anything else, so its parameters
21
+ * cannot be counted: `None known.`, prose after the list, or an item that names two parameters
22
+ * (`` - `x`, `y`: the cell ``) or puts anything between its code span and the colon.
23
+ */
24
+ export function parameterCount(section) {
25
+ const text = section.trim();
26
+ if (text === "None.")
27
+ return 0;
28
+ let count = 0;
29
+ for (const line of text.split("\n")) {
30
+ if (line.trim() === "")
31
+ continue;
32
+ if (/^[-*+]\s/.test(line)) {
33
+ if (!/^[-*+]\s+`[a-z_][a-z0-9_]*(?:\s*:\s*[^`\s][^`]*)?`:/.test(line))
34
+ return null;
35
+ count++;
36
+ }
37
+ else if (!(count > 0 && /^\s/.test(line)))
38
+ return null;
39
+ }
40
+ return count > 0 ? count : null;
41
+ }
42
+ /**
43
+ * The number of arguments in the parenthesised list that opens at code[open], counting the commas
44
+ * outside nested brackets. Null when the list is not closed.
45
+ */
46
+ export function argumentCount(code, open) {
47
+ let depth = 0;
48
+ let commas = 0;
49
+ let empty = true;
50
+ for (let i = open + 1; i < code.length; i++) {
51
+ const c = code[i];
52
+ if (c === "(" || c === "[" || c === "{")
53
+ depth++;
54
+ else if (c === ")" || c === "]" || c === "}") {
55
+ if (depth === 0)
56
+ return empty ? 0 : commas + 1;
57
+ depth--;
58
+ }
59
+ else if (c === "," && depth === 0)
60
+ commas++;
61
+ if (!/\s/.test(c))
62
+ empty = false;
63
+ }
64
+ return null;
65
+ }
66
+ // The argument count of a call or emit whose name ends at end: 0 when no list follows the name.
67
+ const countAfter = (code, end) => {
68
+ const opening = /[ \t]*\(/y;
69
+ opening.lastIndex = end;
70
+ return opening.test(code) ? argumentCount(code, opening.lastIndex - 1) : 0;
71
+ };
72
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
73
+ /** Checks the argument count of every call, function call and emit in a live rule's procedure. */
74
+ export function checkArgumentCounts(ctx) {
75
+ const { problem, skip } = ctx;
76
+ const { entries, glossary } = ctx.spec;
77
+ const live = [...entries].filter(([, e]) => e.kind === "RULE" && e.meta.status !== "superseded");
78
+ const builds = (e) => asList(e.meta.builds);
79
+ const sharesBuild = (a, b) => builds(a).some((x) => builds(b).includes(x));
80
+ const parametersOf = (id) => entries.get(id).sections.find((s) => s.title === "Parameters")?.text;
81
+ const counts = new Map();
82
+ const countOf = (id) => {
83
+ if (!counts.has(id))
84
+ counts.set(id, parameterCount(parametersOf(id) ?? ""));
85
+ return counts.get(id);
86
+ };
87
+ // The entries whose Parameters section a call to rule from caller is counted against: each entry
88
+ // of rule's split that lists one of the caller's builds, or rule itself when none does.
89
+ const targetsOf = (rule, caller) => {
90
+ const group = [rule, ...asList(entries.get(rule).meta.split_with)].filter((x) => entries.get(x)?.kind === "RULE");
91
+ const sharing = [...new Set(group)].filter((x) => sharesBuild(entries.get(x), caller));
92
+ return sharing.length ? sharing : [rule];
93
+ };
94
+ // Rule ID -> what could not be counted against its Parameters section -> how many times.
95
+ const uncounted = new Map();
96
+ const cannotCount = (target, what) => {
97
+ if (!uncounted.has(target))
98
+ uncounted.set(target, new Map());
99
+ const m = uncounted.get(target);
100
+ m.set(what, (m.get(what) ?? 0) + 1);
101
+ };
102
+ // What could not be counted because its argument list is never closed.
103
+ const unclosed = [];
104
+ // Function name -> the rules that define it, with the parameter count of each define.
105
+ const defined = new Map();
106
+ for (const [id, e] of live)
107
+ for (const { name, params } of defines(withoutCommentsAndStrings(e.code ?? ""))) {
108
+ if (!defined.has(name))
109
+ defined.set(name, []);
110
+ defined.get(name).push({ id, count: params.length });
111
+ }
112
+ // Event name -> the argument counts its emits pass, with the rule each comes from.
113
+ const emitted = new Map();
114
+ for (const [id, e] of live) {
115
+ const { file } = e;
116
+ const code = withoutCommentsAndStrings(e.code ?? "");
117
+ for (const m of code.matchAll(/\bcall\s+(RULE-[A-Z0-9]+-\d+)/g)) {
118
+ const called = entries.get(m[1]);
119
+ if (!called || called.kind !== "RULE")
120
+ continue;
121
+ const n = countAfter(code, m.index + m[0].length);
122
+ if (n === null) {
123
+ unclosed.push(`call of ${m[1]} in ${id}`);
124
+ continue;
125
+ }
126
+ for (const target of targetsOf(m[1], e)) {
127
+ const want = countOf(target);
128
+ if (want === null)
129
+ cannotCount(target, `call in ${id}`);
130
+ else if (want !== n)
131
+ problem(file, target === m[1]
132
+ ? `calls ${m[1]} with ${plural(n, "argument")}, but its Parameters section lists ${plural(want, "parameter")}`
133
+ : `calls ${m[1]} with ${plural(n, "argument")}, but the Parameters section of ${target}, the entry of the split that lists a build of this rule, lists ${plural(want, "parameter")}`);
134
+ }
135
+ }
136
+ // A name the procedure declares itself, or a built-in, is not a function another rule defines.
137
+ const locals = procedureLocals(code, parametersOf(id) ?? "");
138
+ for (const m of code.matchAll(/(?<![.\w])(?<!\b(?:define|emit)\s+)([a-z_][a-z0-9_]*)\s*\(/g)) {
139
+ const owners = defined.get(m[1]);
140
+ if (!owners || locals.has(m[1]) || BUILTINS.has(m[1]))
141
+ continue;
142
+ const n = argumentCount(code, m.index + m[0].length - 1);
143
+ if (n === null) {
144
+ unclosed.push(`call of ${m[1]}() in ${id}`);
145
+ continue;
146
+ }
147
+ const near = owners.filter((o) => o.id === id || sharesBuild(entries.get(o.id), e));
148
+ if (near.length) {
149
+ for (const { id: owner, count } of near)
150
+ if (count !== n)
151
+ problem(file, `calls ${m[1]}() with ${plural(n, "argument")}, but its define in ${owner} takes ${plural(count, "parameter")}`);
152
+ }
153
+ else if (owners.every((o) => o.count !== n))
154
+ // No define lists one of this rule's builds, so the call is wrong only if it fits none of them.
155
+ problem(file, owners.length === 1
156
+ ? `calls ${m[1]}() with ${plural(n, "argument")}, but its define in ${owners[0].id} takes ${plural(owners[0].count, "parameter")}`
157
+ : `calls ${m[1]}() with ${plural(n, "argument")}, but none of its defines takes that many (${owners.map((o) => `${plural(o.count, "parameter")} in ${o.id}`).join(", ")})`);
158
+ }
159
+ for (const m of code.matchAll(/\bemit\s+([A-Za-z_][A-Za-z0-9_]*)/g)) {
160
+ const event = m[1];
161
+ const n = countAfter(code, m.index + m[0].length);
162
+ if (n === null) {
163
+ unclosed.push(`emit of ${event} in ${id}`);
164
+ continue;
165
+ }
166
+ if (!emitted.has(event))
167
+ emitted.set(event, []);
168
+ const others = emitted.get(event);
169
+ const other = others.find((o) => o.count !== n && (o.id === id || sharesBuild(o.entry, e)));
170
+ if (other)
171
+ problem(file, other.id === id
172
+ ? `emits ${event} with ${plural(n, "argument")}, but also emits it with ${plural(other.count, "argument")}`
173
+ : `emits ${event} with ${plural(n, "argument")}, but ${other.id} emits it with ${plural(other.count, "argument")}`);
174
+ if (!others.some((o) => o.id === id && o.count === n))
175
+ others.push({ id, entry: e, count: n });
176
+ const handlers = idsIn(glossary.get(event)).filter((x) => kindOf(x) === "RULE" && entries.get(x)?.kind === "RULE");
177
+ for (const handler of new Set(handlers.flatMap((h) => targetsOf(h, e)))) {
178
+ const want = countOf(handler);
179
+ if (want === null)
180
+ cannotCount(handler, `emit of ${event} in ${id}`);
181
+ else if (want !== n)
182
+ problem(file, `emits ${event} with ${plural(n, "argument")}, but the Parameters section of its handler ${handler} lists ${plural(want, "parameter")}`);
183
+ }
184
+ }
185
+ }
186
+ for (const [target, whats] of uncounted) {
187
+ const list = [...whats].map(([what, times]) => (times > 1 ? `${what} (${times} times)` : what)).join(", ");
188
+ const why = parametersOf(target) === undefined
189
+ ? "which has no Parameters section"
190
+ : "whose Parameters section is not None. or a list of parameters";
191
+ skip(`argument counts against ${target}, ${why} (${list})`);
192
+ }
193
+ for (const what of new Set(unclosed))
194
+ skip(`the argument count of the ${what}, whose argument list is not closed`);
195
+ }
@@ -3,8 +3,14 @@ import type { Context } from "../context.ts";
3
3
  export interface Deviation {
4
4
  /** The spec IDs its Departs from item names. */
5
5
  departs: string[];
6
+ /** The spec IDs its Replaces item names: the entries it replaces entirely. */
7
+ replaces: string[];
6
8
  /** Whether its Dropped item says it was dropped. */
7
9
  dropped: boolean;
10
+ /** Whether its Default is mandatory. */
11
+ mandatory: boolean;
12
+ /** The test files its Tests item lists, which check the rebuild does what the deviation says. */
13
+ tests: string[];
8
14
  /** Its file. */
9
15
  file: string;
10
16
  }
@@ -3,7 +3,7 @@
3
3
  import { existsSync, statSync } from "node:fs";
4
4
  import { basename, join } from "node:path";
5
5
  import { checkResolves, isSuperseded } from "../evidence.js";
6
- import { termFiles } from "../files.js";
6
+ import { checkTestFiles, termFiles } from "../files.js";
7
7
  import { areaOf, idsIn, kindOf } from "../ids.js";
8
8
  import { readText } from "../markdown.js";
9
9
  /** Reads and checks deviations/. Returns deviation ID -> the deviation. */
@@ -44,10 +44,12 @@ export function checkDeviations(ctx) {
44
44
  const item = Object.fromEntries(items);
45
45
  const order = [
46
46
  "Departs from",
47
+ ...("Replaces" in item ? ["Replaces"] : []),
47
48
  "Reason",
48
49
  "Setting",
49
50
  "Default",
50
51
  ...("Justification" in item ? ["Justification"] : []),
52
+ ...("Tests" in item ? ["Tests"] : []),
51
53
  "Dropped",
52
54
  ];
53
55
  if (items
@@ -59,7 +61,12 @@ export function checkDeviations(ctx) {
59
61
  const dropped = Boolean(item.Dropped) && item.Dropped !== "no";
60
62
  if (dropped && !/^\d{4}-\d{2}-\d{2}\b/.test(item.Dropped))
61
63
  problem(file, `${title}: Dropped gives the date, YYYY-MM-DD, and the reason`);
64
+ const replaces = idsIn(item.Replaces);
62
65
  checkResolves(ctx, file, departs, `${title} Departs from`);
66
+ checkResolves(ctx, file, replaces, `${title} Replaces`);
67
+ // The Tests item lists the test files that check the rebuild does what the deviation says. A
68
+ // dropped deviation's tests may have gone with it.
69
+ let tests = [];
63
70
  if (!dropped) {
64
71
  if (!departs.some((x) => ["RULE", "FMT", "SCR"].includes(kindOf(x))))
65
72
  problem(file, `${title}: Departs from names at least one rule, format or screen`);
@@ -67,8 +74,28 @@ export function checkDeviations(ctx) {
67
74
  if (isSuperseded(entries, x))
68
75
  problem(file, `${title} departs from ${x}, which is superseded`);
69
76
  checkDeviationDefault(ctx, file, title, item, departs);
77
+ // Replaces names the entries of Departs from that a mandatory deviation replaces entirely, which
78
+ // is what lets their rows become deviated.
79
+ if ("Replaces" in item) {
80
+ if (item.Default !== "mandatory")
81
+ problem(file, `${title}: only a mandatory deviation has a Replaces item`);
82
+ if (replaces.length === 0)
83
+ problem(file, `${title}: Replaces names at least one entry`);
84
+ for (const x of replaces) {
85
+ if (!departs.includes(x))
86
+ problem(file, `${title}: Replaces names ${x}, which Departs from does not`);
87
+ if (!["RULE", "FMT", "SCR"].includes(kindOf(x)))
88
+ problem(file, `${title}: Replaces names ${x}, which is not a rule, format or screen`);
89
+ }
90
+ }
91
+ // Nothing records a local run of a deviation's tests, so they run in CI and never need GAME_DIR.
92
+ if ("Tests" in item) {
93
+ tests = checkTestFiles(ctx, file, title, item.Tests, false);
94
+ if (tests.length === 0)
95
+ problem(file, `${title}: Tests lists at least one test file; leave the item out when there is none`);
96
+ }
70
97
  }
71
- deviations.set(title, { departs, dropped, file });
98
+ deviations.set(title, { departs, replaces, dropped, mandatory: item.Default === "mandatory", tests, file });
72
99
  }
73
100
  }
74
101
  return deviations;
@@ -21,7 +21,7 @@
21
21
  // unchecked. A declaration that writes no type, such as a parameter the Parameters section describes
22
22
  // in prose or an untyped parameter of a define, gives none and so differs from no other.
23
23
  import { asList } from "../ids.js";
24
- import { parameterNames, withoutCommentsAndStrings } from "./rules.js";
24
+ import { defines, parameterNames, withoutCommentsAndStrings } from "./rules.js";
25
25
  const sameType = (a, b) => !!a && !!b && a.format.join(" ") === b.format.join(" ") && a.pointer === b.pointer && a.list === b.list;
26
26
  const FMT_ID = String.raw `FMT-[A-Z][A-Z0-9]*-\d{3,}`;
27
27
  // A type in the notation that holds a structure: a format ID, a pointer to one, or a list of either.
@@ -184,9 +184,8 @@ export function checkFieldNames(ctx, { layouts }) {
184
184
  for (const name of parameterNames(params))
185
185
  declare(name, fixed(typedParams.has(name) ? typedParams.get(name) : undefined, "the Parameters section"));
186
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(",")) {
187
+ for (const d of defines(line))
188
+ for (const p of d.params) {
190
189
  const [name, type] = p.split(":");
191
190
  if (/^[a-z_][a-z0-9_]*$/.test(name.trim()))
192
191
  declare(name.trim(), fixed(type === undefined ? undefined : typeOf(type), "its define"));
@@ -4,7 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { basename, join } from "node:path";
5
5
  import { collectPlaceholders } from "../code-files.js";
6
6
  import { mayBeInterrupted, onlyEmulatedRuns } from "../evidence.js";
7
- import { markdownTree, walk } from "../files.js";
7
+ import { checkTestFiles, markdownTree, NEEDS_GAME, walk } from "../files.js";
8
8
  import { areaOf, compareIds, kindOf } from "../ids.js";
9
9
  import { readText, tables } from "../markdown.js";
10
10
  import { LINE_LIMIT } from "../standard.js";
@@ -24,10 +24,9 @@ export function checkParity(ctx, deviations) {
24
24
  const parityRows = new Map(); // spec ID -> { cells, file }
25
25
  const parityCounts = { status: {}, code: {} };
26
26
  const validatedTests = new Map(); // marked test file of a validated row -> [{ specId, file }]
27
- // A test file that reads the original's files through GAME_DIR says so with this comment. It runs
27
+ // A test file that reads the original's files through GAME_DIR says so with NEEDS_GAME. It runs
28
28
  // only on a maintainer's machine, so its validated rows need it in VALIDATION.md; every other test
29
29
  // runs in CI.
30
- const NEEDS_GAME = /needs:\s*GAME_DIR/;
31
30
  const needsGame = (p) => existsSync(p) && NEEDS_GAME.test(readFileSync(p, "utf8"));
32
31
  // A PARITY.md that still holds the rows is left alone until they have moved, so the check does not
33
32
  // overwrite them with the totals.
@@ -101,24 +100,7 @@ export function checkParity(ctx, deviations) {
101
100
  problem(file, `${specId}: an unknown entry cannot be complete`);
102
101
  if (code === "complete" && placeholders.has(specId))
103
102
  problem(file, `${specId}: a PLACEHOLDER comment cites it, so it cannot be complete`);
104
- const testFiles = tests === "None"
105
- ? []
106
- : tests
107
- .split(",")
108
- .map((x) => x.trim())
109
- .filter(Boolean);
110
- for (const tf of testFiles) {
111
- const p = join(repoDir, tf);
112
- if (!existsSync(p))
113
- problem(file, `${specId}: test file ${tf} does not exist`);
114
- else {
115
- const text = readFileSync(p, "utf8");
116
- if (!text.includes(specId))
117
- problem(file, `${specId}: test file ${tf} does not mention ${specId}`);
118
- if (text.includes("GAME_DIR") && !NEEDS_GAME.test(text))
119
- problem(file, `${specId}: test file ${tf} mentions GAME_DIR without a "needs: GAME_DIR" comment, so CI would skip it unseen`);
120
- }
121
- }
103
+ const testFiles = checkTestFiles(ctx, file, specId, tests);
122
104
  const listedDevs = devs === "None"
123
105
  ? []
124
106
  : devs
@@ -131,11 +113,25 @@ export function checkParity(ctx, deviations) {
131
113
  .sort(compareIds);
132
114
  if (listedDevs.slice().sort(compareIds).join(",") !== expectedDevs.join(","))
133
115
  problem(file, `${specId}: Deviations must be ${expectedDevs.join(", ") || "None"}`);
116
+ // A row whose entry a mandatory deviation replaces entirely cannot be compared with the original,
117
+ // so it has no tests of its own: they belong in the deviation's Tests item. It is deviated once
118
+ // every mandatory deviation it lists has tests.
119
+ const mandatory = listedDevs
120
+ .map((x) => deviations.get(x))
121
+ .filter((d) => Boolean(d?.mandatory && !d.dropped));
122
+ const replacing = listedDevs.find((x) => {
123
+ const d = deviations.get(x);
124
+ return d?.mandatory && !d.dropped && d.replaces.includes(specId);
125
+ });
126
+ const replaced = replacing !== undefined;
127
+ if (replaced && testFiles.length > 0)
128
+ problem(file, `${specId}: ${replacing} replaces it, so Tests must be None; list the tests in the deviation's Tests item`);
129
+ const deviated = replaced && mandatory.every((d) => d.tests.length > 0);
134
130
  let expectedStatus;
135
131
  if (code !== "complete" || e.meta.status === "disputed")
136
132
  expectedStatus = e.meta.status;
137
- else if (testFiles.length === 0)
138
- expectedStatus = "implemented";
133
+ else if (testFiles.length === 0 || replaced)
134
+ expectedStatus = deviated ? "deviated" : "implemented";
139
135
  else if (["supported", "established"].includes(e.meta.status))
140
136
  expectedStatus = "validated";
141
137
  else {
@@ -7,5 +7,21 @@ import type { FormatNames } from "./formats.ts";
7
7
  export declare const withoutCommentsAndStrings: (code: string) => string;
8
8
  /** The names a rule's Parameters section declares: each lower-case name that opens a code span, as `n` or `n: type`. */
9
9
  export declare const parameterNames: (params: string) => string[];
10
+ /**
11
+ * Each `define` in a procedure: the function's name and its parameters as written, with their types
12
+ * (`n: UINT16`). A define with no parameters has an empty list.
13
+ */
14
+ export declare const defines: (code: string) => {
15
+ name: string;
16
+ params: string[];
17
+ }[];
18
+ /**
19
+ * The names a procedure declares for itself: its `let`s, its loop variables, the parameters of its
20
+ * `define`s and the names its rule's Parameters section lists. A call to one of these names calls
21
+ * the local value, not a function another rule defines.
22
+ */
23
+ export declare function procedureLocals(code: string, params: string): Set<string>;
24
+ /** The functions every procedure may call without a rule defining them. */
25
+ export declare const BUILTINS: Set<string>;
10
26
  /** Checks every rule's procedure. Sets Entry.code on every rule entry, superseded ones included. */
11
27
  export declare function checkRules(ctx: Context, { enumNames }: FormatNames): void;
@@ -13,7 +13,37 @@ import { KINDS, LIST_LIMIT } from "../standard.js";
13
13
  export const withoutCommentsAndStrings = (code) => code.replace(/"[^"\n]*"/g, '""').replace(/#.*$/gm, "");
14
14
  /** The names a rule's Parameters section declares: each lower-case name that opens a code span, as `n` or `n: type`. */
15
15
  export const parameterNames = (params) => [...params.matchAll(/`([a-z_][a-z0-9_]*)(?=`|\s*:)/g)].map((m) => m[1]);
16
- const BUILTINS = new Set([
16
+ /**
17
+ * Each `define` in a procedure: the function's name and its parameters as written, with their types
18
+ * (`n: UINT16`). A define with no parameters has an empty list.
19
+ */
20
+ export const defines = (code) => [...code.matchAll(/\bdefine\s+([a-z_][a-z0-9_]*)\s*\(([^)]*)\)/g)].map((m) => ({
21
+ name: m[1],
22
+ params: m[2]
23
+ .split(",")
24
+ .map((p) => p.trim())
25
+ .filter((p) => p !== ""),
26
+ }));
27
+ /**
28
+ * The names a procedure declares for itself: its `let`s, its loop variables, the parameters of its
29
+ * `define`s and the names its rule's Parameters section lists. A call to one of these names calls
30
+ * the local value, not a function another rule defines.
31
+ */
32
+ export function procedureLocals(code, params) {
33
+ const locals = new Set();
34
+ for (const m of code.matchAll(/\blet\s+([a-z_][a-z0-9_]*)/g))
35
+ locals.add(m[1]);
36
+ for (const m of code.matchAll(/\bfor\s+(?:each\s+)?([a-z_][a-z0-9_]*)\s+in\b/g))
37
+ locals.add(m[1]);
38
+ for (const d of defines(code))
39
+ for (const p of d.params)
40
+ locals.add(p.split(":")[0].trim());
41
+ for (const name of parameterNames(params))
42
+ locals.add(name);
43
+ return locals;
44
+ }
45
+ /** The functions every procedure may call without a rule defining them. */
46
+ export const BUILTINS = new Set([
17
47
  "min",
18
48
  "max",
19
49
  "abs",
@@ -191,17 +221,7 @@ export function checkRules(ctx, { enumNames }) {
191
221
  if (/\b0x[0-9A-Fa-f]{6,}\b/.test(noNeutral) && /\b0x00[4-9A-F][0-9A-F]{5}\b/.test(noNeutral))
192
222
  problem(file, "the procedure contains what looks like an address outside a neutral name");
193
223
  // Functions called without `call`
194
- const locals = new Set();
195
- for (const m of code.matchAll(/\blet\s+([a-z_][a-z0-9_]*)/g))
196
- locals.add(m[1]);
197
- for (const m of code.matchAll(/\bfor\s+(?:each\s+)?([a-z_][a-z0-9_]*)\s+in\b/g))
198
- locals.add(m[1]);
199
- for (const m of code.matchAll(/\bdefine\s+[a-z_][a-z0-9_]*\s*\(([^)]*)\)/g))
200
- for (const p of m[1].split(","))
201
- locals.add(p.split(":")[0].trim());
202
- const params = e.sections.find((s) => s.title === "Parameters")?.text ?? "";
203
- for (const name of parameterNames(params))
204
- locals.add(name);
224
+ const locals = procedureLocals(code, e.sections.find((s) => s.title === "Parameters")?.text ?? "");
205
225
  for (const m of code.matchAll(/(?<![.\w])([a-z_][a-z0-9_]*)\s*\(/g)) {
206
226
  const name = m[1];
207
227
  if (BUILTINS.has(name) || KEYWORDS.has(name) || locals.has(name))
package/dist/files.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Context } from "./context.ts";
1
2
  /** A path with backslashes turned into forward slashes. */
2
3
  export declare const toSlash: (p: string) => string;
3
4
  /**
@@ -9,3 +10,12 @@ export declare function walk(dir: string, fn: (path: string) => void): void;
9
10
  export declare function markdownTree(dir: string): Map<string, string>;
10
11
  /** The paths of the files and directories in dir, apart from .gitkeep. */
11
12
  export declare const termFiles: (dir: string) => string[];
13
+ /** The comment of a test file that reads the original's files through GAME_DIR, which CI skips. */
14
+ export declare const NEEDS_GAME: RegExp;
15
+ /**
16
+ * Checks the test files that cell lists for id, comma-separated by their path from the repository
17
+ * root, and returns them. `None` lists none. Each listed path is a file that mentions id, and one that
18
+ * mentions GAME_DIR carries a "needs: GAME_DIR" comment, since CI skips it. Without needsGame, a file
19
+ * that mentions GAME_DIR at all is a problem. Each problem goes to file.
20
+ */
21
+ export declare function checkTestFiles(ctx: Context, file: string, id: string, cell: string, needsGame?: boolean): string[];
package/dist/files.js CHANGED
@@ -1,5 +1,5 @@
1
- // Walking the repository's directories.
2
- import { existsSync, readdirSync, statSync } from "node:fs";
1
+ // Walking the repository's directories, and reading the test files a row or deviation lists.
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { join, relative } from "node:path";
4
4
  /** A path with backslashes turned into forward slashes. */
5
5
  export const toSlash = (p) => p.replaceAll("\\", "/");
@@ -33,3 +33,39 @@ export function markdownTree(dir) {
33
33
  export const termFiles = (dir) => readdirSync(dir)
34
34
  .filter((name) => name !== ".gitkeep")
35
35
  .map((name) => join(dir, name));
36
+ /** The comment of a test file that reads the original's files through GAME_DIR, which CI skips. */
37
+ export const NEEDS_GAME = /needs:\s*GAME_DIR/;
38
+ /**
39
+ * Checks the test files that cell lists for id, comma-separated by their path from the repository
40
+ * root, and returns them. `None` lists none. Each listed path is a file that mentions id, and one that
41
+ * mentions GAME_DIR carries a "needs: GAME_DIR" comment, since CI skips it. Without needsGame, a file
42
+ * that mentions GAME_DIR at all is a problem. Each problem goes to file.
43
+ */
44
+ export function checkTestFiles(ctx, file, id, cell, needsGame = true) {
45
+ const { problem } = ctx;
46
+ const listed = cell === "None"
47
+ ? []
48
+ : cell
49
+ .split(",")
50
+ .map((x) => x.replaceAll("`", "").trim())
51
+ .filter(Boolean);
52
+ // Whole IDs only, as ID_RE reads them, so RULE-SCORE-0010 does not mention RULE-SCORE-001.
53
+ const mention = new RegExp(`\\b${id}\\b`);
54
+ for (const tf of listed) {
55
+ const p = join(ctx.config.repoDir, tf);
56
+ if (!existsSync(p))
57
+ problem(file, `${id}: test file ${tf} does not exist`);
58
+ else if (!statSync(p).isFile())
59
+ problem(file, `${id}: test file ${tf} is not a file`);
60
+ else {
61
+ const text = readFileSync(p, "utf8");
62
+ if (!mention.test(text))
63
+ problem(file, `${id}: test file ${tf} does not mention ${id}`);
64
+ if (text.includes("GAME_DIR") && !needsGame)
65
+ problem(file, `${id}: test file ${tf} mentions GAME_DIR, but it has to run in CI`);
66
+ else if (text.includes("GAME_DIR") && !NEEDS_GAME.test(text))
67
+ problem(file, `${id}: test file ${tf} mentions GAME_DIR without a "needs: GAME_DIR" comment, so CI would skip it unseen`);
68
+ }
69
+ }
70
+ return listed;
71
+ }
@@ -21,7 +21,7 @@ export function generateParity(ctx, parity, generated) {
21
21
  "",
22
22
  "| Status | Rows |",
23
23
  "|---|---|",
24
- ...["unknown", "sourced", "supported", "established", "disputed", "implemented", "validated"].map((k) => `| ${k} | ${parityCounts.status[k] ?? 0} |`),
24
+ ...["unknown", "sourced", "supported", "established", "disputed", "implemented", "deviated", "validated"].map((k) => `| ${k} | ${parityCounts.status[k] ?? 0} |`),
25
25
  "",
26
26
  "| Code | Rows |",
27
27
  "|---|---|",
@@ -62,6 +62,7 @@
62
62
  import { readFileSync } from "node:fs";
63
63
  import { dirname } from "node:path";
64
64
  import { fileURLToPath } from "node:url";
65
+ import { checkArgumentCounts } from "./checks/arguments.js";
65
66
  import { checkBase } from "./checks/base.js";
66
67
  import { checkCommentAddresses } from "./checks/comment-addresses.js";
67
68
  import { checkAcrossEntries } from "./checks/cross-entry.js";
@@ -98,6 +99,7 @@ const spec = loadSpec({ config, problem });
98
99
  const ctx = { config, problem, skip, spec, codeFiles: createCodeFiles(config, dirname(selfPath)) };
99
100
  const formatNames = checkEntries(ctx);
100
101
  checkRules(ctx, formatNames);
102
+ checkArgumentCounts(ctx);
101
103
  checkFieldNames(ctx, formatNames);
102
104
  checkAcrossEntries(ctx, formatNames);
103
105
  compileKaitai(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scientific-method/standard-checker",
3
- "version": "2.0.0",
3
+ "version": "2.2.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",