@scientific-method/standard-checker 2.1.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
+ }
@@ -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"));
@@ -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))
@@ -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.1.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",