@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 +3 -1
- package/dist/checks/arguments.d.ts +17 -0
- package/dist/checks/arguments.js +195 -0
- package/dist/checks/deviations.d.ts +6 -0
- package/dist/checks/deviations.js +29 -2
- package/dist/checks/fields.js +3 -4
- package/dist/checks/parity.js +19 -23
- package/dist/checks/rules.d.ts +16 -0
- package/dist/checks/rules.js +32 -12
- package/dist/files.d.ts +10 -0
- package/dist/files.js +38 -2
- package/dist/generate/parity-md.js +1 -1
- package/dist/standard-checker.js +2 -0
- package/package.json +1 -1
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;
|
package/dist/checks/fields.js
CHANGED
|
@@ -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
|
|
188
|
-
|
|
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"));
|
package/dist/checks/parity.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 {
|
package/dist/checks/rules.d.ts
CHANGED
|
@@ -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;
|
package/dist/checks/rules.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
"|---|---|",
|
package/dist/standard-checker.js
CHANGED
|
@@ -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.
|
|
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",
|