@scientific-method/standard-checker 0.1.0 → 0.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.
Files changed (77) hide show
  1. package/README.md +6 -0
  2. package/dist/checks/base.d.ts +7 -0
  3. package/dist/checks/base.js +72 -0
  4. package/dist/checks/claims.d.ts +4 -0
  5. package/dist/checks/claims.js +83 -0
  6. package/dist/checks/comment-addresses.d.ts +3 -0
  7. package/dist/checks/comment-addresses.js +111 -0
  8. package/dist/checks/cross-entry.d.ts +4 -0
  9. package/dist/checks/cross-entry.js +177 -0
  10. package/dist/checks/deviations.d.ts +12 -0
  11. package/dist/checks/deviations.js +101 -0
  12. package/dist/checks/entries.d.ts +7 -0
  13. package/dist/checks/entries.js +130 -0
  14. package/dist/checks/evidence-entries.d.ts +6 -0
  15. package/dist/checks/evidence-entries.js +167 -0
  16. package/dist/checks/formats.d.ts +13 -0
  17. package/dist/checks/formats.js +194 -0
  18. package/dist/checks/kaitai.d.ts +6 -0
  19. package/dist/checks/kaitai.js +103 -0
  20. package/dist/checks/parity.d.ts +30 -0
  21. package/dist/checks/parity.js +189 -0
  22. package/dist/checks/references.d.ts +4 -0
  23. package/dist/checks/references.js +37 -0
  24. package/dist/checks/rules.d.ts +4 -0
  25. package/dist/checks/rules.js +259 -0
  26. package/dist/checks/screens.d.ts +4 -0
  27. package/dist/checks/screens.js +44 -0
  28. package/dist/checks/validation.d.ts +7 -0
  29. package/dist/checks/validation.js +116 -0
  30. package/dist/code-comments.d.ts +3 -0
  31. package/dist/code-comments.js +150 -0
  32. package/dist/code-files.d.ts +11 -0
  33. package/dist/code-files.js +41 -0
  34. package/dist/context.d.ts +34 -0
  35. package/dist/context.js +2 -0
  36. package/dist/evidence.d.ts +37 -0
  37. package/dist/evidence.js +91 -0
  38. package/dist/files.d.ts +11 -0
  39. package/dist/files.js +35 -0
  40. package/dist/generate/indexes.d.ts +3 -0
  41. package/dist/generate/indexes.js +135 -0
  42. package/dist/generate/layout.d.ts +18 -0
  43. package/dist/generate/layout.js +49 -0
  44. package/dist/generate/parity-md.d.ts +7 -0
  45. package/dist/generate/parity-md.js +44 -0
  46. package/dist/generate/write.d.ts +8 -0
  47. package/dist/generate/write.js +75 -0
  48. package/dist/ids.d.ts +13 -0
  49. package/dist/ids.js +22 -0
  50. package/dist/load/builds.d.ts +9 -0
  51. package/dist/load/builds.js +124 -0
  52. package/dist/load/code-ranges.d.ts +4 -0
  53. package/dist/load/code-ranges.js +79 -0
  54. package/dist/load/entries.d.ts +7 -0
  55. package/dist/load/entries.js +57 -0
  56. package/dist/load/glossary.d.ts +12 -0
  57. package/dist/load/glossary.js +70 -0
  58. package/dist/load/readme.d.ts +3 -0
  59. package/dist/load/readme.js +36 -0
  60. package/dist/load/spec.d.ts +6 -0
  61. package/dist/load/spec.js +25 -0
  62. package/dist/locations.d.ts +19 -0
  63. package/dist/locations.js +57 -0
  64. package/dist/markdown.d.ts +27 -0
  65. package/dist/markdown.js +162 -0
  66. package/dist/options.d.ts +34 -0
  67. package/dist/options.js +87 -0
  68. package/dist/problems.d.ts +27 -0
  69. package/dist/problems.js +27 -0
  70. package/dist/standard-checker.js +51 -3033
  71. package/dist/standard.d.ts +65 -0
  72. package/dist/standard.js +173 -0
  73. package/dist/types.d.ts +91 -0
  74. package/dist/types.js +2 -0
  75. package/dist/yaml.d.ts +6 -0
  76. package/dist/yaml.js +173 -0
  77. package/package.json +1 -1
@@ -0,0 +1,57 @@
1
+ // The entries: one Markdown file per ID in the directory of its kind.
2
+ import { readdirSync, statSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { isAlias, kindOf } from "../ids.js";
5
+ import { readEntry } from "../markdown.js";
6
+ import { KINDS } from "../standard.js";
7
+ /**
8
+ * Reads every entry under spec/, by ID, and reports a directory of spec/ the standard does not
9
+ * define. An entry without an ID or of an unknown kind is reported and left out.
10
+ */
11
+ export function loadEntries({ config, problem }) {
12
+ const { specDir } = config;
13
+ const entries = new Map();
14
+ for (const [kind, { dir }] of Object.entries(KINDS)) {
15
+ const d = join(specDir, dir);
16
+ if (!existsSync(d))
17
+ continue;
18
+ for (const name of readdirSync(d)) {
19
+ const file = join(d, name);
20
+ if (statSync(file).isDirectory() || !name.endsWith(".md"))
21
+ continue;
22
+ const read = readEntry(file, problem);
23
+ if (!read)
24
+ continue;
25
+ const entry = Object.assign(read, { kind });
26
+ const id = entry.meta.id;
27
+ if (typeof id !== "string") {
28
+ problem(file, "has no id", "IDENTIFIERS-1");
29
+ continue;
30
+ }
31
+ if (name !== `${id}.md`)
32
+ problem(file, `file name must be ${id}.md`);
33
+ // Later checks look the kind up in KINDS, so an entry of an unknown kind is reported and dropped.
34
+ if (!KINDS[kindOf(id)]) {
35
+ problem(file, `${id} is not an ID of a known kind`, "IDENTIFIERS-1");
36
+ continue;
37
+ }
38
+ if (kindOf(id) !== kind)
39
+ problem(file, `a ${kindOf(id)} entry does not belong in spec/${dir}/`);
40
+ // IDENTIFIERS-3 makes a number unique within its kind and area, and IDENTIFIERS-4 an alias.
41
+ if (entries.has(id))
42
+ problem(file, `ID ${id} is used twice`, isAlias(id) ? "IDENTIFIERS-4" : "IDENTIFIERS-3");
43
+ entries.set(id, entry);
44
+ }
45
+ }
46
+ // Stray Markdown anywhere else in spec/ that looks like an entry.
47
+ for (const dir of readdirSync(specDir)) {
48
+ const d = join(specDir, dir);
49
+ if (!statSync(d).isDirectory() ||
50
+ Object.values(KINDS).some((k) => k.dir === dir) ||
51
+ dir === "index" ||
52
+ dir === "glossary")
53
+ continue;
54
+ problem(d, "is not a directory the standard defines");
55
+ }
56
+ return entries;
57
+ }
@@ -0,0 +1,12 @@
1
+ import type { LoadContext } from "../context.ts";
2
+ /** The glossary as read from spec/glossary/ and the --glossary drafts. */
3
+ export interface Glossary {
4
+ /** spec/glossary/. */
5
+ glossaryDir: string;
6
+ /** Term -> the text after its heading. */
7
+ glossary: Map<string, string>;
8
+ /** Term -> its file in spec/glossary/. */
9
+ glossaryFiles: Map<string, string>;
10
+ }
11
+ /** Reads and checks spec/glossary/, then adds the terms of the --glossary drafts. */
12
+ export declare function loadGlossary({ config, problem }: LoadContext): Glossary;
@@ -0,0 +1,70 @@
1
+ // The glossary: one file per term in spec/glossary/, named after the term and opening with it as a
2
+ // # heading. A glossary file is not an entry, so it has no front matter.
3
+ import { existsSync, statSync } from "node:fs";
4
+ import { basename, join } from "node:path";
5
+ import { termFiles } from "../files.js";
6
+ import { readText } from "../markdown.js";
7
+ import { RESERVED_NAMES } from "../standard.js";
8
+ function readTerm(problem, file, report = true) {
9
+ const text = readText(file);
10
+ const say = (message) => {
11
+ if (report)
12
+ problem(file, message);
13
+ };
14
+ if (text.startsWith("---\n"))
15
+ say("a glossary file has no front matter");
16
+ const m = /^# (.+)\n?([\s\S]*)$/.exec(text.replace(/^---\n[\s\S]*?\n---\n/, "").replace(/^\s+/, ""));
17
+ if (!m) {
18
+ say("opens with the term as a # heading");
19
+ return null;
20
+ }
21
+ const term = m[1].trim().replaceAll("`", "");
22
+ if (basename(file) !== `${term}.md`)
23
+ say(`is named after its term, ${term}.md`);
24
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(term))
25
+ say(`${term} is not a name the pseudocode can use`);
26
+ if (RESERVED_NAMES.test(term))
27
+ say(`${term} is a name Windows reserves for a device, so no file can have it`);
28
+ return { term, text: m[2] };
29
+ }
30
+ /** Reads and checks spec/glossary/, then adds the terms of the --glossary drafts. */
31
+ export function loadGlossary({ config, problem }) {
32
+ const { specDir } = config;
33
+ const glossaryDir = join(specDir, "glossary");
34
+ const glossary = new Map(); // term -> text after the heading
35
+ const glossaryFiles = new Map(); // term -> file
36
+ if (existsSync(join(specDir, "glossary.md")))
37
+ problem(join(specDir, "glossary.md"), "the glossary is the directory spec/glossary/; move each ## term to spec/glossary/<term>.md with the term as its # heading");
38
+ if (!existsSync(glossaryDir))
39
+ problem(null, "spec/glossary/ is missing");
40
+ else {
41
+ const folded = new Map();
42
+ for (const file of termFiles(glossaryDir)) {
43
+ if (statSync(file).isDirectory() || !file.endsWith(".md")) {
44
+ problem(file, "is not a glossary file; spec/glossary/ holds one <term>.md per term");
45
+ continue;
46
+ }
47
+ const t = readTerm(problem, file);
48
+ if (!t)
49
+ continue;
50
+ const key = t.term.toLowerCase();
51
+ if (folded.has(key))
52
+ problem(file, `${t.term} differs only in case from ${folded.get(key)}`);
53
+ else
54
+ folded.set(key, t.term);
55
+ glossary.set(t.term, t.text);
56
+ glossaryFiles.set(t.term, file);
57
+ }
58
+ }
59
+ // --glossary <path> adds the terms of a draft term file, or of a directory of them, for checking
60
+ // entries before their terms are merged into spec/glossary/.
61
+ for (const draft of config.glossaryDrafts) {
62
+ const files = statSync(draft).isDirectory() ? termFiles(draft).filter((f) => f.endsWith(".md")) : [draft];
63
+ for (const file of files) {
64
+ const t = readTerm(problem, file, false);
65
+ if (t && !glossary.has(t.term))
66
+ glossary.set(t.term, t.text);
67
+ }
68
+ }
69
+ return { glossaryDir, glossary, glossaryFiles };
70
+ }
@@ -0,0 +1,3 @@
1
+ import type { LoadContext } from "../context.ts";
2
+ /** Checks spec/README.md and returns its areas, in the order it lists them. */
3
+ export declare function loadAreas({ config, problem }: LoadContext): string[];
@@ -0,0 +1,36 @@
1
+ // spec/README.md: its sections and the area list.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { splitSections, tables } from "../markdown.js";
5
+ /** Checks spec/README.md and returns its areas, in the order it lists them. */
6
+ export function loadAreas({ config, problem }) {
7
+ const { specDir } = config;
8
+ const readme = existsSync(join(specDir, "README.md"))
9
+ ? readFileSync(join(specDir, "README.md"), "utf8").replace(/\r\n/g, "\n")
10
+ : "";
11
+ if (!readme)
12
+ problem(null, "spec/README.md is missing");
13
+ const areas = [];
14
+ const readmeSections = splitSections(readme);
15
+ const titles = readmeSections.map((s) => s.title);
16
+ const expected = ["Scope", "Standard version", "Areas"];
17
+ if (titles.join("|") !== expected.join("|"))
18
+ problem(join(specDir, "README.md"), `sections must be ${expected.join(", ")} in that order, found ${titles.join(", ")}`);
19
+ const areaSection = readmeSections.find((s) => s.title === "Areas");
20
+ const areaTable = areaSection && tables(areaSection.text)[0];
21
+ if (!areaTable || areaTable.header.join("|") !== "Area|Covers")
22
+ problem(join(specDir, "README.md"), "the area list must be a table with the columns Area | Covers");
23
+ else
24
+ for (const row of areaTable.rows) {
25
+ const a = row[0].replaceAll("`", "");
26
+ if (!/^[A-Z][A-Z0-9]*$/.test(a))
27
+ problem(join(specDir, "README.md"), `area ${a} must be upper-case letters and digits starting with a letter`, "IDENTIFIERS-2");
28
+ if (areas.includes(a))
29
+ problem(join(specDir, "README.md"), `area ${a} is listed twice`);
30
+ areas.push(a);
31
+ }
32
+ const version = readmeSections.find((s) => s.title === "Standard version");
33
+ if (version && !/version 1 of the/.test(version.text))
34
+ problem(join(specDir, "README.md"), "the Standard version section must say which version it follows (version 1)");
35
+ return areas;
36
+ }
@@ -0,0 +1,6 @@
1
+ import type { LoadContext, Spec } from "../context.ts";
2
+ /**
3
+ * Reads spec/ and reports what is wrong with its layout, the README, the glossary and the build
4
+ * manifests. Exits with 1 when there is no spec/ directory.
5
+ */
6
+ export declare function loadSpec(ctx: LoadContext): Spec;
@@ -0,0 +1,25 @@
1
+ // Reading the spec: the README's areas, the entries, the glossary and what the build entries name.
2
+ import { existsSync } from "node:fs";
3
+ import { checkOtherFiles, loadBuildFiles } from "./builds.js";
4
+ import { loadCodeRanges } from "./code-ranges.js";
5
+ import { loadEntries } from "./entries.js";
6
+ import { loadGlossary } from "./glossary.js";
7
+ import { loadAreas } from "./readme.js";
8
+ /**
9
+ * Reads spec/ and reports what is wrong with its layout, the README, the glossary and the build
10
+ * manifests. Exits with 1 when there is no spec/ directory.
11
+ */
12
+ export function loadSpec(ctx) {
13
+ const { repoDir, specDir } = ctx.config;
14
+ if (!existsSync(specDir)) {
15
+ console.error(`No spec/ directory in ${repoDir}.`);
16
+ process.exit(1);
17
+ }
18
+ const areas = loadAreas(ctx);
19
+ const entries = loadEntries(ctx);
20
+ const { glossaryDir, glossary, glossaryFiles } = loadGlossary(ctx);
21
+ const buildFiles = loadBuildFiles(ctx, entries);
22
+ checkOtherFiles(ctx, entries, buildFiles);
23
+ const codeRanges = loadCodeRanges(ctx, entries, buildFiles);
24
+ return { areas, entries, glossaryDir, glossary, glossaryFiles, buildFiles, codeRanges };
25
+ }
@@ -0,0 +1,19 @@
1
+ import type { Problem } from "./problems.ts";
2
+ import type { Meta, Yaml } from "./types.ts";
3
+ /**
4
+ * Reports an address that is not in the notation of format. An unlisted format is already reported
5
+ * against its manifest, so it is skipped here.
6
+ */
7
+ export declare function checkAddress(problem: Problem, file: string, value: Yaml, format: Yaml): void;
8
+ /**
9
+ * An offset names one byte, or a half-open range of two: 0x20..0x3C covers 0x20 up to but not
10
+ * including 0x3C. Returns [start, end) as BigInts, or null when the notation is wrong.
11
+ */
12
+ export declare function parseOffset(value: Yaml): [bigint, bigint] | null;
13
+ /**
14
+ * An offset is into the shipped file bf, so the bytes it covers lie within bf.size. Returns the
15
+ * parsed range when it is well formed.
16
+ * `bf` is the file the offset is into, given as { path, size }: the shipped file, or the unpacked
17
+ * form of a packed one.
18
+ */
19
+ export declare function checkOffset(problem: Problem, file: string, value: Yaml, bf: Meta, what?: string): [bigint, bigint] | null;
@@ -0,0 +1,57 @@
1
+ // Addresses and offsets, the two ways a location in a build's file is given.
2
+ import { locationRule } from "./standard.js";
3
+ /**
4
+ * Reports an address that is not in the notation of format. An unlisted format is already reported
5
+ * against its manifest, so it is skipped here.
6
+ */
7
+ export function checkAddress(problem, file, value, format) {
8
+ const rule = locationRule(format);
9
+ if (!rule)
10
+ return;
11
+ const re = rule.address;
12
+ if (!re) {
13
+ problem(file, `an address cannot be given in a file of format ${format}; use offset`);
14
+ return;
15
+ }
16
+ const parts = String(value).split("..");
17
+ if (parts.length > 2 || parts.some((p) => !re.test(p)))
18
+ problem(file, `address ${value} is not in the notation for a ${format} file`);
19
+ }
20
+ /**
21
+ * An offset names one byte, or a half-open range of two: 0x20..0x3C covers 0x20 up to but not
22
+ * including 0x3C. Returns [start, end) as BigInts, or null when the notation is wrong.
23
+ */
24
+ export function parseOffset(value) {
25
+ const parts = String(value).split("..");
26
+ if (parts.length > 2 || parts.some((p) => !/^0x[0-9A-F]{2,}$/.test(p)))
27
+ return null;
28
+ const [start, end] = parts.map((p) => BigInt(p));
29
+ return [start, end ?? start + 1n];
30
+ }
31
+ /**
32
+ * An offset is into the shipped file bf, so the bytes it covers lie within bf.size. Returns the
33
+ * parsed range when it is well formed.
34
+ * `bf` is the file the offset is into, given as { path, size }: the shipped file, or the unpacked
35
+ * form of a packed one.
36
+ */
37
+ export function checkOffset(problem, file, value, bf, what = "shipped file") {
38
+ const range = parseOffset(value);
39
+ if (!range) {
40
+ problem(file, `offset ${value} must be 0x followed by at least two upper-case hex digits, or a range of two`);
41
+ return null;
42
+ }
43
+ const [start, end] = range;
44
+ if (start > end) {
45
+ problem(file, "offset range is reversed");
46
+ return null;
47
+ }
48
+ if (start === end) {
49
+ problem(file, `offset range ${value} is empty; a range is half-open`);
50
+ return null;
51
+ }
52
+ if (Number.isSafeInteger(bf.size) && bf.size >= 0 && end > BigInt(bf.size)) {
53
+ problem(file, `offset ${value} is outside the ${what} ${bf.path} (${bf.size} bytes)`);
54
+ return null;
55
+ }
56
+ return range;
57
+ }
@@ -0,0 +1,27 @@
1
+ import type { Problem } from "./problems.ts";
2
+ import type { Section, Table } from "./types.ts";
3
+ /** The number of lines of text, not counting the empty one after a final newline. */
4
+ export declare const lineCount: (text: string) => number;
5
+ /** Reads a text file with CRLF line ends read as LF. */
6
+ export declare const readText: (file: string) => string;
7
+ /** Reads an entry's front matter, body and sections, or reports why it cannot and returns null. */
8
+ export declare function readEntry(file: string, problem: Problem): {
9
+ file: string;
10
+ meta: import("./types.ts").Meta;
11
+ body: string;
12
+ sections: Section[];
13
+ } | null;
14
+ /** The `##` sections of a Markdown body, skipping fenced code. */
15
+ export declare function splitSections(body: string): Section[];
16
+ /** Every Markdown table in a section, with the `###` heading above it. */
17
+ export declare function tables(text: string): Table[];
18
+ /** The cells of a table row, with `\|` read as a pipe and pipes inside code spans kept. */
19
+ export declare function cells(line: string): string[];
20
+ /**
21
+ * A value file: CSV as RFC 4180 defines it, with a header row. Returns { header, rows }, or null
22
+ * after reporting a problem.
23
+ */
24
+ export declare function readCsv(file: string, problem: Problem): {
25
+ header: string[];
26
+ rows: string[][];
27
+ } | null;
@@ -0,0 +1,162 @@
1
+ // Markdown and CSV: entries with front matter, sections, tables and value files.
2
+ import { readFileSync } from "node:fs";
3
+ import { parseYaml } from "./yaml.js";
4
+ /** The number of lines of text, not counting the empty one after a final newline. */
5
+ export const lineCount = (text) => (text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0));
6
+ /** Reads a text file with CRLF line ends read as LF. */
7
+ export const readText = (file) => readFileSync(file, "utf8").replace(/\r\n/g, "\n");
8
+ /** Reads an entry's front matter, body and sections, or reports why it cannot and returns null. */
9
+ export function readEntry(file, problem) {
10
+ const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
11
+ if (!text.startsWith("---\n")) {
12
+ problem(file, "has no front matter");
13
+ return null;
14
+ }
15
+ const end = text.indexOf("\n---\n", 4);
16
+ if (end < 0) {
17
+ problem(file, "front matter is not closed");
18
+ return null;
19
+ }
20
+ const meta = parseYaml(text.slice(4, end), file, problem);
21
+ const body = text.slice(end + 5);
22
+ return { file, meta, body, sections: splitSections(body) };
23
+ }
24
+ /** The `##` sections of a Markdown body, skipping fenced code. */
25
+ export function splitSections(body) {
26
+ const sections = [];
27
+ let current = null;
28
+ let fence = false;
29
+ for (const line of body.split("\n")) {
30
+ if (/^(```|~~~)/.test(line))
31
+ fence = !fence;
32
+ const m = !fence && /^## (.+)$/.exec(line);
33
+ if (m) {
34
+ current = { title: m[1].trim(), lines: [] };
35
+ sections.push(current);
36
+ }
37
+ else if (current)
38
+ current.lines.push(line);
39
+ }
40
+ return sections.map((s) => ({ title: s.title, text: s.lines.join("\n") }));
41
+ }
42
+ /** Every Markdown table in a section, with the `###` heading above it. */
43
+ export function tables(text) {
44
+ const out = [];
45
+ const lines = text.split("\n");
46
+ let heading = null;
47
+ let fence = false;
48
+ for (let i = 0; i < lines.length; i++) {
49
+ if (/^(```|~~~)/.test(lines[i]))
50
+ fence = !fence;
51
+ if (fence)
52
+ continue;
53
+ const h = /^### (.+)$/.exec(lines[i]);
54
+ if (h)
55
+ heading = h[1].trim();
56
+ if (lines[i].startsWith("|") && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
57
+ const header = cells(lines[i]);
58
+ const rows = [];
59
+ let j = i + 2;
60
+ while (j < lines.length && lines[j].startsWith("|")) {
61
+ rows.push(cells(lines[j]));
62
+ j++;
63
+ }
64
+ out.push({ heading, header, rows, line: i });
65
+ i = j - 1;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /** The cells of a table row, with `\|` read as a pipe and pipes inside code spans kept. */
71
+ export function cells(line) {
72
+ const parts = [];
73
+ let cur = "";
74
+ let code = false;
75
+ const s = line.trim().replace(/^\|/, "").replace(/\|$/, "");
76
+ for (let k = 0; k < s.length; k++) {
77
+ const ch = s[k];
78
+ if (ch === "`")
79
+ code = !code;
80
+ if (ch === "\\" && s[k + 1] === "|") {
81
+ cur += "|";
82
+ k++;
83
+ continue;
84
+ }
85
+ if (ch === "|" && !code) {
86
+ parts.push(cur.trim());
87
+ cur = "";
88
+ continue;
89
+ }
90
+ cur += ch;
91
+ }
92
+ parts.push(cur.trim());
93
+ return parts;
94
+ }
95
+ /**
96
+ * A value file: CSV as RFC 4180 defines it, with a header row. Returns { header, rows }, or null
97
+ * after reporting a problem.
98
+ */
99
+ export function readCsv(file, problem) {
100
+ // Spreadsheet programs start a UTF-8 CSV with a byte order mark, which would join the first header.
101
+ const text = readFileSync(file, "utf8").replace(/^/, "");
102
+ const records = [];
103
+ let record = [];
104
+ let field = "";
105
+ let quoted = false;
106
+ let wasQuoted = false;
107
+ for (let i = 0; i < text.length; i++) {
108
+ const ch = text[i];
109
+ if (quoted) {
110
+ if (ch === '"' && text[i + 1] === '"') {
111
+ field += '"';
112
+ i++;
113
+ }
114
+ else if (ch === '"')
115
+ quoted = false;
116
+ else
117
+ field += ch;
118
+ }
119
+ else if (ch === '"' && field === "" && !wasQuoted) {
120
+ quoted = true;
121
+ wasQuoted = true;
122
+ }
123
+ else if (ch === ",") {
124
+ record.push(field);
125
+ field = "";
126
+ wasQuoted = false;
127
+ }
128
+ else if (ch === "\n" || ch === "\r") {
129
+ if (ch === "\r" && text[i + 1] === "\n")
130
+ i++;
131
+ record.push(field);
132
+ records.push(record);
133
+ record = [];
134
+ field = "";
135
+ wasQuoted = false;
136
+ }
137
+ else
138
+ field += ch;
139
+ }
140
+ if (quoted) {
141
+ problem(file, "has a quoted field that is never closed");
142
+ return null;
143
+ }
144
+ if (field !== "" || record.length > 0) {
145
+ record.push(field);
146
+ records.push(record);
147
+ }
148
+ // Blank lines at the end of the file are not rows.
149
+ while (records.length > 0 && records.at(-1).length === 1 && records.at(-1)[0] === "")
150
+ records.pop();
151
+ if (records.length === 0) {
152
+ problem(file, "has no header row");
153
+ return null;
154
+ }
155
+ const [header, ...rows] = records;
156
+ for (const row of rows)
157
+ if (row.length !== header.length) {
158
+ problem(file, `the row ${row.join(",")} has ${row.length} fields, not ${header.length}`);
159
+ return null;
160
+ }
161
+ return { header, rows };
162
+ }
@@ -0,0 +1,34 @@
1
+ /** What one run of the checker was asked to do, from its command line. */
2
+ export interface Config {
3
+ /** The repository being checked (--root), absolute. */
4
+ repoDir: string;
5
+ /** Its spec/ directory. */
6
+ specDir: string;
7
+ /** --check: report stale generated files instead of rewriting them. */
8
+ checkOnly: boolean;
9
+ /** --no-ksy: skip compiling the Kaitai definitions. */
10
+ skipKsy: boolean;
11
+ /** --base, or null when it was not given. */
12
+ baseArg: string | null;
13
+ /** Every --glossary path, in order. */
14
+ glossaryDrafts: string[];
15
+ /** The --code directories, relative to repoDir. */
16
+ codeRoots: string[];
17
+ /** The --references directories, relative to repoDir. */
18
+ referenceRoots: string[];
19
+ /** The --images ranges, as half-open [low, high). */
20
+ images: Array<[number, number]>;
21
+ /** --max-range in bytes. */
22
+ maxRange: number;
23
+ /** The --data-dirs directories, or undefined when the option was not given. */
24
+ dataDirs: string[] | undefined;
25
+ /** The --record-validation build IDs, or undefined when the option was not given. */
26
+ recordValidation: string[] | undefined;
27
+ }
28
+ /**
29
+ * Splits a comma-separated option into its trimmed, non-empty parts, or returns fallback when the
30
+ * option was not given.
31
+ */
32
+ export declare const dirList: (value: string | undefined, fallback: string[]) => string[];
33
+ /** Reads the arguments after the script's path. An invalid one prints why and exits with 2. */
34
+ export declare function parseOptions(argv: string[]): Config;
@@ -0,0 +1,87 @@
1
+ // The command line, read into a Config. An invalid option prints why and exits with 2.
2
+ import { join, resolve } from "node:path";
3
+ /**
4
+ * Splits a comma-separated option into its trimmed, non-empty parts, or returns fallback when the
5
+ * option was not given.
6
+ */
7
+ export const dirList = (value, fallback) => value === undefined
8
+ ? fallback
9
+ : value
10
+ .split(",")
11
+ .map((x) => x.trim())
12
+ .filter(Boolean);
13
+ const FLAGS = ["--check", "--no-ksy"];
14
+ const VALUED = [
15
+ "--root",
16
+ "--base",
17
+ "--glossary",
18
+ "--code",
19
+ "--references",
20
+ "--images",
21
+ "--max-range",
22
+ "--data-dirs",
23
+ "--record-validation",
24
+ ];
25
+ /** Reads the arguments after the script's path. An invalid one prints why and exits with 2. */
26
+ export function parseOptions(argv) {
27
+ const options = { glossary: [] };
28
+ for (let k = 0; k < argv.length; k++) {
29
+ const arg = argv[k];
30
+ if (FLAGS.includes(arg))
31
+ options[arg.slice(2)] = true;
32
+ else if (VALUED.includes(arg)) {
33
+ if (k + 1 >= argv.length) {
34
+ console.error(`${arg} needs a value; see --help`);
35
+ process.exit(2);
36
+ }
37
+ if (arg === "--glossary")
38
+ options.glossary.push(argv[++k]);
39
+ else
40
+ options[arg.slice(2)] = argv[++k];
41
+ }
42
+ else {
43
+ console.error(`unknown option ${arg}; see --help`);
44
+ process.exit(2);
45
+ }
46
+ }
47
+ const repoDir = resolve(options.root ?? ".");
48
+ const specDir = join(repoDir, "spec");
49
+ const checkOnly = options.check === true;
50
+ if (checkOnly && options["record-validation"] !== undefined) {
51
+ console.error("--record-validation writes VALIDATION.md, so it cannot be combined with --check");
52
+ process.exit(2);
53
+ }
54
+ const skipKsy = options["no-ksy"] === true;
55
+ const baseArg = options.base ?? null;
56
+ const codeRoots = dirList(options.code, ["src", "tests", "tools"]);
57
+ const referenceRoots = dirList(options.references, []);
58
+ // Half-open [low, high) ranges, as numbers: flat 32-bit addresses fit exactly.
59
+ const images = dirList(options.images, []).map((range) => {
60
+ const m = /^0x([0-9A-Fa-f]{8})\.\.0x([0-9A-Fa-f]{8})$/.exec(range);
61
+ const [low, high] = m ? [parseInt(m[1], 16), parseInt(m[2], 16)] : [NaN, NaN];
62
+ if (!m || high <= low) {
63
+ console.error(`--images takes half-open ranges such as 0x00400000..0x004C9000, not ${range}`);
64
+ process.exit(2);
65
+ }
66
+ return [low, high];
67
+ });
68
+ const maxRange = options["max-range"] === undefined ? 0x10000 : Number(options["max-range"]);
69
+ if (!Number.isSafeInteger(maxRange) || maxRange < 1) {
70
+ console.error(`--max-range takes a positive number of bytes, such as 0x10000, not ${options["max-range"]}`);
71
+ process.exit(2);
72
+ }
73
+ return {
74
+ repoDir,
75
+ specDir,
76
+ checkOnly,
77
+ skipKsy,
78
+ baseArg,
79
+ glossaryDrafts: options.glossary,
80
+ codeRoots,
81
+ referenceRoots,
82
+ images,
83
+ maxRange,
84
+ dataDirs: options["data-dirs"] === undefined ? undefined : dirList(options["data-dirs"], []),
85
+ recordValidation: options["record-validation"] === undefined ? undefined : dirList(options["record-validation"], []),
86
+ };
87
+ }
@@ -0,0 +1,27 @@
1
+ /** The whole numbers from 1 to N. */
2
+ type UpTo<N extends number, Seen extends 0[] = [0]> = Seen["length"] | (Seen["length"] extends N ? never : UpTo<N, [...Seen, 0]>);
3
+ /**
4
+ * A numbered rule of the documentation standard, such as `STATUS-14`. The standard opens each rule
5
+ * with the heading `###### STATUS-14`, anchored at `#status-14` on the site and in vendored copies.
6
+ * Each count is the last rule the standard numbers in that section, so a label that names no rule
7
+ * does not compile. Raise a count, or add a section, when the standard numbers more rules.
8
+ */
9
+ export type Rule = `IDENTIFIERS-${UpTo<7>}` | `STATUS-${UpTo<41>}` | `ENTRY-TYPES-${UpTo<8>}`;
10
+ /**
11
+ * Records a problem with file (or with the spec as a whole when file is null). A problem that breaks
12
+ * a numbered rule names it.
13
+ */
14
+ export type Problem = (file: string | null, message: string, rule?: Rule) => void;
15
+ /** The problem collector of one run. */
16
+ export interface Problems {
17
+ /** Records a problem. */
18
+ problem: Problem;
19
+ /**
20
+ * Prints every distinct problem in the order found, with the summary, and exits with 1. Returns
21
+ * when there are none.
22
+ */
23
+ report(entryCount: number): void;
24
+ }
25
+ /** Creates the collector for a run over repoDir, against which problem paths are printed. */
26
+ export declare function createProblems(repoDir: string): Problems;
27
+ export {};
@@ -0,0 +1,27 @@
1
+ // The problems a run finds, kept in the order they are found and printed together at the end.
2
+ import { relative } from "node:path";
3
+ /** Creates the collector for a run over repoDir, against which problem paths are printed. */
4
+ export function createProblems(repoDir) {
5
+ const problems = [];
6
+ let citedRule = false;
7
+ // A problem that breaks a numbered rule ends with the rule's label, so whoever fixes it can read
8
+ // that one rule instead of the whole section.
9
+ const problem = (file, message, rule) => {
10
+ if (rule)
11
+ citedRule = true;
12
+ problems.push(`${file ? relative(repoDir, file).replaceAll("\\", "/") : "spec"}: ${message}${rule ? ` [${rule}]` : ""}`);
13
+ };
14
+ const report = (entryCount) => {
15
+ // The same problem can be found twice, such as a term that cites one finding in two places.
16
+ const unique = [...new Set(problems)];
17
+ if (unique.length) {
18
+ for (const p of unique)
19
+ console.error(p);
20
+ console.error(`\n${unique.length} problem(s) in ${entryCount} spec entries.`);
21
+ if (citedRule)
22
+ 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.");
23
+ process.exit(1);
24
+ }
25
+ };
26
+ return { problem, report };
27
+ }