@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.
- package/README.md +6 -0
- package/dist/checks/base.d.ts +7 -0
- package/dist/checks/base.js +72 -0
- package/dist/checks/claims.d.ts +4 -0
- package/dist/checks/claims.js +83 -0
- package/dist/checks/comment-addresses.d.ts +3 -0
- package/dist/checks/comment-addresses.js +111 -0
- package/dist/checks/cross-entry.d.ts +4 -0
- package/dist/checks/cross-entry.js +177 -0
- package/dist/checks/deviations.d.ts +12 -0
- package/dist/checks/deviations.js +101 -0
- package/dist/checks/entries.d.ts +7 -0
- package/dist/checks/entries.js +130 -0
- package/dist/checks/evidence-entries.d.ts +6 -0
- package/dist/checks/evidence-entries.js +167 -0
- package/dist/checks/formats.d.ts +13 -0
- package/dist/checks/formats.js +194 -0
- package/dist/checks/kaitai.d.ts +6 -0
- package/dist/checks/kaitai.js +103 -0
- package/dist/checks/parity.d.ts +30 -0
- package/dist/checks/parity.js +189 -0
- package/dist/checks/references.d.ts +4 -0
- package/dist/checks/references.js +37 -0
- package/dist/checks/rules.d.ts +4 -0
- package/dist/checks/rules.js +259 -0
- package/dist/checks/screens.d.ts +4 -0
- package/dist/checks/screens.js +44 -0
- package/dist/checks/validation.d.ts +7 -0
- package/dist/checks/validation.js +116 -0
- package/dist/code-comments.d.ts +3 -0
- package/dist/code-comments.js +150 -0
- package/dist/code-files.d.ts +11 -0
- package/dist/code-files.js +41 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +2 -0
- package/dist/evidence.d.ts +37 -0
- package/dist/evidence.js +91 -0
- package/dist/files.d.ts +11 -0
- package/dist/files.js +35 -0
- package/dist/generate/indexes.d.ts +3 -0
- package/dist/generate/indexes.js +135 -0
- package/dist/generate/layout.d.ts +18 -0
- package/dist/generate/layout.js +49 -0
- package/dist/generate/parity-md.d.ts +7 -0
- package/dist/generate/parity-md.js +44 -0
- package/dist/generate/write.d.ts +8 -0
- package/dist/generate/write.js +75 -0
- package/dist/ids.d.ts +13 -0
- package/dist/ids.js +22 -0
- package/dist/load/builds.d.ts +9 -0
- package/dist/load/builds.js +124 -0
- package/dist/load/code-ranges.d.ts +4 -0
- package/dist/load/code-ranges.js +79 -0
- package/dist/load/entries.d.ts +7 -0
- package/dist/load/entries.js +57 -0
- package/dist/load/glossary.d.ts +12 -0
- package/dist/load/glossary.js +70 -0
- package/dist/load/readme.d.ts +3 -0
- package/dist/load/readme.js +36 -0
- package/dist/load/spec.d.ts +6 -0
- package/dist/load/spec.js +25 -0
- package/dist/locations.d.ts +19 -0
- package/dist/locations.js +57 -0
- package/dist/markdown.d.ts +27 -0
- package/dist/markdown.js +162 -0
- package/dist/options.d.ts +34 -0
- package/dist/options.js +87 -0
- package/dist/problems.d.ts +27 -0
- package/dist/problems.js +27 -0
- package/dist/standard-checker.js +51 -3033
- package/dist/standard.d.ts +65 -0
- package/dist/standard.js +173 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +2 -0
- package/dist/yaml.d.ts +6 -0
- package/dist/yaml.js +173 -0
- package/package.json +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The four indexes in spec/index/: by kind, by area, by status, and what cites each entry.
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { evidenceFacts } from "../evidence.js";
|
|
4
|
+
import { toSlash } from "../files.js";
|
|
5
|
+
import { areaOf, asList, compareIds, idsIn, isAlias, kindOf } from "../ids.js";
|
|
6
|
+
import { CLAIM_STATUSES, EVIDENCE_STATUSES, KINDS } from "../standard.js";
|
|
7
|
+
import { GENERATED, layout } from "./layout.js";
|
|
8
|
+
/** Renders the four indexes, split where the line limit requires. Returns absolute path -> text. */
|
|
9
|
+
export function generateIndexes(ctx) {
|
|
10
|
+
const { entries, areas, glossary, glossaryFiles } = ctx.spec;
|
|
11
|
+
const { specDir } = ctx.config;
|
|
12
|
+
const esc = (s) => String(s ?? "").replaceAll("|", "\\|");
|
|
13
|
+
const sortedIds = [...entries.keys()].sort(compareIds);
|
|
14
|
+
const indexDir = join(specDir, "index");
|
|
15
|
+
const linkFrom = (path) => (id) => `[${id}](${toSlash(relative(dirname(join(indexDir, `${path}.md`)), entries.get(id).file))})`;
|
|
16
|
+
const opening = (path, what) => [`# ${path}`, "", GENERATED, "", what, ""];
|
|
17
|
+
// A file of the full index lists every group, empty ones too, so its counts read as a progress
|
|
18
|
+
// report. A file of a split index lists only the groups it has entries in.
|
|
19
|
+
const isWhole = (path) => !path.includes("/");
|
|
20
|
+
function renderByKind(ids, path) {
|
|
21
|
+
const link = linkFrom(path);
|
|
22
|
+
const out = opening(path, "Entries by kind.");
|
|
23
|
+
for (const [kind, { dir }] of Object.entries(KINDS)) {
|
|
24
|
+
const kindIds = ids.filter((x) => kindOf(x) === kind);
|
|
25
|
+
if (!kindIds.length && !isWhole(path))
|
|
26
|
+
continue;
|
|
27
|
+
out.push(`## ${dir}`, "", `${kindIds.length} entries.`, "");
|
|
28
|
+
if (kindIds.length)
|
|
29
|
+
out.push("| ID | Title | Status |", "|---|---|---|", ...kindIds.map((x) => `| ${link(x)} | ${esc(entries.get(x).meta.title)} | ${entries.get(x).meta.status ?? "None"} |`), "");
|
|
30
|
+
}
|
|
31
|
+
return out.join("\n");
|
|
32
|
+
}
|
|
33
|
+
function renderByArea(ids, path) {
|
|
34
|
+
const link = linkFrom(path);
|
|
35
|
+
const out = opening(path, "Entries by area.");
|
|
36
|
+
for (const a of areas) {
|
|
37
|
+
const areaIds = ids.filter((x) => areaOf(x) === a);
|
|
38
|
+
if (!areaIds.length && !isWhole(path))
|
|
39
|
+
continue;
|
|
40
|
+
out.push(`## ${a}`, "");
|
|
41
|
+
if (!areaIds.length)
|
|
42
|
+
out.push("None.", "");
|
|
43
|
+
else
|
|
44
|
+
out.push("| ID | Title | Status |", "|---|---|---|", ...areaIds.map((x) => `| ${link(x)} | ${esc(entries.get(x).meta.title)} | ${entries.get(x).meta.status} |`), "");
|
|
45
|
+
}
|
|
46
|
+
return out.join("\n");
|
|
47
|
+
}
|
|
48
|
+
function renderByStatus(ids, path) {
|
|
49
|
+
const link = linkFrom(path);
|
|
50
|
+
const out = opening(path, "Entries by status.");
|
|
51
|
+
const section = (title, intro, list, withStatus) => {
|
|
52
|
+
if (!list.length && !isWhole(path))
|
|
53
|
+
return;
|
|
54
|
+
out.push(`## ${title}`, "");
|
|
55
|
+
if (intro)
|
|
56
|
+
out.push(intro, "");
|
|
57
|
+
if (!list.length) {
|
|
58
|
+
out.push(intro ? "None." : "0 entries.", "");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!intro)
|
|
62
|
+
out.push(`${list.length} entries.`, "");
|
|
63
|
+
out.push(withStatus ? "| ID | Title | Status |" : "| ID | Title |", withStatus ? "|---|---|---|" : "|---|---|", ...list.map((x) => `| ${link(x)} | ${esc(entries.get(x).meta.title)} |${withStatus ? ` ${entries.get(x).meta.status} |` : ""}`), "");
|
|
64
|
+
};
|
|
65
|
+
for (const st of [...CLAIM_STATUSES, ...EVIDENCE_STATUSES.filter((x) => x !== "superseded")])
|
|
66
|
+
section(st, null, ids.filter((x) => entries.get(x).meta.status === st), false);
|
|
67
|
+
section("Established on unreproduced evidence", "Entries whose status is established and whose findings and experiments are all only recorded.", ids.filter((x) => KINDS[kindOf(x)].statuses === "claim" &&
|
|
68
|
+
entries.get(x).meta.status === "established" &&
|
|
69
|
+
asList(entries.get(x).meta.evidence)
|
|
70
|
+
.filter((y) => ["FND", "EXP"].includes(kindOf(y)))
|
|
71
|
+
.every((y) => entries.get(y)?.meta.status !== "reproduced")), false);
|
|
72
|
+
// Listed only when there are any, so that indexes written before complete readings stay fresh.
|
|
73
|
+
const byReading = ids.filter((x) => KINDS[kindOf(x)].statuses === "claim" &&
|
|
74
|
+
entries.get(x).meta.status === "established" &&
|
|
75
|
+
evidenceFacts(entries, entries.get(x)).dynamic === 0);
|
|
76
|
+
if (byReading.length)
|
|
77
|
+
section("Established by a complete reading alone", "Entries whose status is established and that no dynamic finding or experiment confirms.", byReading, false);
|
|
78
|
+
section("Open questions", "Entries whose Open questions section says more than None known.", ids.filter((x) => {
|
|
79
|
+
const s = entries.get(x).sections.find((y) => y.title === "Open questions");
|
|
80
|
+
return s && !/^\s*None( known)?\.\s*$/.test(s.text);
|
|
81
|
+
}), true);
|
|
82
|
+
return out.join("\n");
|
|
83
|
+
}
|
|
84
|
+
const refs = new Map(sortedIds.map((x) => [x, new Map()]));
|
|
85
|
+
const addRef = (to, from, how) => {
|
|
86
|
+
if (refs.has(to) && to !== from) {
|
|
87
|
+
const m = refs.get(to);
|
|
88
|
+
if (!m.has(from))
|
|
89
|
+
m.set(from, new Set());
|
|
90
|
+
m.get(from).add(how);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
for (const [id, e] of entries) {
|
|
94
|
+
for (const f of ["evidence", "conflicting", "related", "split_with", "superseded_by", "builds"])
|
|
95
|
+
for (const x of asList(e.meta[f]))
|
|
96
|
+
addRef(x, id, f);
|
|
97
|
+
for (const loc of asList(e.meta.locations))
|
|
98
|
+
if (loc?.build)
|
|
99
|
+
addRef(loc.build, id, "locations");
|
|
100
|
+
for (const x of idsIn(e.body))
|
|
101
|
+
addRef(x, id, "body");
|
|
102
|
+
}
|
|
103
|
+
for (const [term, text] of glossary)
|
|
104
|
+
for (const x of idsIn(text))
|
|
105
|
+
addRef(x, `glossary:${term}`, "glossary");
|
|
106
|
+
// One row per entry: everything that cites it goes in one cell.
|
|
107
|
+
function renderReferences(ids, path) {
|
|
108
|
+
const link = linkFrom(path);
|
|
109
|
+
const termLink = (term) => glossaryFiles.has(term)
|
|
110
|
+
? `[${term}](${toSlash(relative(dirname(join(indexDir, `${path}.md`)), glossaryFiles.get(term)))})`
|
|
111
|
+
: esc(term);
|
|
112
|
+
const out = opening(path, "For each entry, the entries and glossary terms that cite or relate to it, and the field they do it in.");
|
|
113
|
+
out.push("| ID | Cited by |", "|---|---|");
|
|
114
|
+
for (const x of ids) {
|
|
115
|
+
const cited = [...refs.get(x)]
|
|
116
|
+
.sort((a, b) => compareIds(a[0], b[0]))
|
|
117
|
+
.map(([from, hows]) => `${from.startsWith("glossary:") ? termLink(from.slice(9)) : link(from)} (${[...hows].sort().join(", ")})`);
|
|
118
|
+
out.push(`| ${link(x)} | ${cited.join(", ") || "None"} |`);
|
|
119
|
+
}
|
|
120
|
+
out.push("");
|
|
121
|
+
return out.join("\n");
|
|
122
|
+
}
|
|
123
|
+
const generated = new Map(); // absolute path -> text
|
|
124
|
+
const areaIds = sortedIds.filter((x) => !isAlias(x));
|
|
125
|
+
const indexes = {
|
|
126
|
+
"by-kind": [renderByKind, sortedIds],
|
|
127
|
+
"by-area": [renderByArea, areaIds],
|
|
128
|
+
"by-status": [renderByStatus, sortedIds],
|
|
129
|
+
references: [renderReferences, sortedIds],
|
|
130
|
+
};
|
|
131
|
+
for (const [name, [render, ids]] of Object.entries(indexes))
|
|
132
|
+
for (const [path, { text }] of layout(ctx, name, ids, render))
|
|
133
|
+
generated.set(join(indexDir, `${path}.md`), text);
|
|
134
|
+
return generated;
|
|
135
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import type { Render } from "../types.ts";
|
|
3
|
+
/** The block of an ID in a split file: its hundred, such as 100, or the first character of an alias. */
|
|
4
|
+
export declare const blockOf: (id: string) => string;
|
|
5
|
+
/**
|
|
6
|
+
* Lays out one generated file: `${name}.md` when render's text fits, otherwise a directory split only
|
|
7
|
+
* as far as the limit requires. render(ids, path) gives the text of the file at path, which has no
|
|
8
|
+
* .md and is relative to the generated tree. Returns a Map of path -> { ids, text }.
|
|
9
|
+
*/
|
|
10
|
+
export declare function layout(ctx: Context, name: string, ids: string[], render: Render, level?: number, out?: Map<string, {
|
|
11
|
+
ids: string[];
|
|
12
|
+
text: string;
|
|
13
|
+
}>): Map<string, {
|
|
14
|
+
ids: string[];
|
|
15
|
+
text: string;
|
|
16
|
+
}>;
|
|
17
|
+
/** The comment every generated file carries under its heading. */
|
|
18
|
+
export declare const GENERATED = "<!-- Generated by the documentation standard check. Do not edit. -->";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Splitting generated files. A generated file that would pass the line limit becomes a directory of
|
|
2
|
+
// the same name, split by area (BLD-SRC for builds and sources, which have no area), then by kind,
|
|
3
|
+
// then by a block of 100 numbers, or by the first character of a build's or source's alias.
|
|
4
|
+
import { areaOf, isAlias, kindOf } from "../ids.js";
|
|
5
|
+
import { lineCount } from "../markdown.js";
|
|
6
|
+
import { KINDS, LINE_LIMIT } from "../standard.js";
|
|
7
|
+
const groupOf = (id) => (isAlias(id) ? "BLD-SRC" : areaOf(id));
|
|
8
|
+
/** The block of an ID in a split file: its hundred, such as 100, or the first character of an alias. */
|
|
9
|
+
export const blockOf = (id) => {
|
|
10
|
+
const kind = kindOf(id);
|
|
11
|
+
if (kind === "BLD" || kind === "SRC")
|
|
12
|
+
return id.charAt(kind.length + 1);
|
|
13
|
+
return String(Math.floor(Number(id.split("-")[2]) / 100) * 100).padStart(3, "0");
|
|
14
|
+
};
|
|
15
|
+
const SPLIT_LEVELS = [groupOf, kindOf, blockOf];
|
|
16
|
+
function orderKeys(areas, level, keys) {
|
|
17
|
+
if (level === 0)
|
|
18
|
+
return [...areas.filter((a) => keys.includes(a)), ...keys.filter((k) => !areas.includes(k)).sort()];
|
|
19
|
+
if (level === 1)
|
|
20
|
+
return Object.keys(KINDS).filter((k) => keys.includes(k));
|
|
21
|
+
return keys.sort((a, b) => (/^\d+$/.test(a) && /^\d+$/.test(b) ? Number(a) - Number(b) : a < b ? -1 : a > b ? 1 : 0));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Lays out one generated file: `${name}.md` when render's text fits, otherwise a directory split only
|
|
25
|
+
* as far as the limit requires. render(ids, path) gives the text of the file at path, which has no
|
|
26
|
+
* .md and is relative to the generated tree. Returns a Map of path -> { ids, text }.
|
|
27
|
+
*/
|
|
28
|
+
export function layout(ctx, name, ids, render, level = 0, out = new Map()) {
|
|
29
|
+
const text = render(ids, name);
|
|
30
|
+
const { problem } = ctx;
|
|
31
|
+
if (lineCount(text) <= LINE_LIMIT || level === SPLIT_LEVELS.length) {
|
|
32
|
+
if (lineCount(text) > LINE_LIMIT)
|
|
33
|
+
problem(null, `${name}.md would pass ${LINE_LIMIT} lines even split by block`);
|
|
34
|
+
out.set(name, { ids, text });
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
const groups = new Map();
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
const key = SPLIT_LEVELS[level](id);
|
|
40
|
+
if (!groups.has(key))
|
|
41
|
+
groups.set(key, []);
|
|
42
|
+
groups.get(key).push(id);
|
|
43
|
+
}
|
|
44
|
+
for (const key of orderKeys(ctx.spec.areas, level, [...groups.keys()]))
|
|
45
|
+
layout(ctx, `${name}/${key}`, groups.get(key), render, level + 1, out);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** The comment every generated file carries under its heading. */
|
|
49
|
+
export const GENERATED = "<!-- Generated by the documentation standard check. Do not edit. -->";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import type { Parity } from "../checks/parity.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Adds the text of PARITY.md to generated, unless PARITY.md still holds the rows (which the parity
|
|
5
|
+
* check reports).
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateParity(ctx: Context, parity: Parity, generated: Map<string, string>): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// PARITY.md: the totals of the parity rows, and a link to each area's rows.
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { areaOf } from "../ids.js";
|
|
5
|
+
import { GENERATED } from "./layout.js";
|
|
6
|
+
/**
|
|
7
|
+
* Adds the text of PARITY.md to generated, unless PARITY.md still holds the rows (which the parity
|
|
8
|
+
* check reports).
|
|
9
|
+
*/
|
|
10
|
+
export function generateParity(ctx, parity, generated) {
|
|
11
|
+
const { areas } = ctx.spec;
|
|
12
|
+
const { repoDir } = ctx.config;
|
|
13
|
+
const parityDir = join(repoDir, "parity");
|
|
14
|
+
const { counts: parityCounts, rows: parityRows, legacy: legacyParity } = parity;
|
|
15
|
+
const out = [
|
|
16
|
+
"# Parity matrix",
|
|
17
|
+
"",
|
|
18
|
+
GENERATED,
|
|
19
|
+
"",
|
|
20
|
+
"How much of the spec in `spec/` the rebuild does. The rows are in `parity/`, one file per area.",
|
|
21
|
+
"",
|
|
22
|
+
"| Status | Rows |",
|
|
23
|
+
"|---|---|",
|
|
24
|
+
...["unknown", "sourced", "supported", "established", "disputed", "implemented", "validated"].map((k) => `| ${k} | ${parityCounts.status[k] ?? 0} |`),
|
|
25
|
+
"",
|
|
26
|
+
"| Code | Rows |",
|
|
27
|
+
"|---|---|",
|
|
28
|
+
...["missing", "partial", "complete"].map((k) => `| ${k} | ${parityCounts.code[k] ?? 0} |`),
|
|
29
|
+
"",
|
|
30
|
+
"## Areas",
|
|
31
|
+
"",
|
|
32
|
+
];
|
|
33
|
+
const withRows = areas.filter((a) => existsSync(join(parityDir, `${a}.md`)) || existsSync(join(parityDir, a)));
|
|
34
|
+
if (!withRows.length)
|
|
35
|
+
out.push("None yet.");
|
|
36
|
+
else
|
|
37
|
+
out.push("| Area | Rows |", "|---|---|", ...withRows.map((a) => {
|
|
38
|
+
const target = existsSync(join(parityDir, `${a}.md`)) ? `parity/${a}.md` : `parity/${a}/`;
|
|
39
|
+
return `| [${a}](${target}) | ${[...parityRows.keys()].filter((x) => areaOf(x) === a).length} |`;
|
|
40
|
+
}));
|
|
41
|
+
out.push("");
|
|
42
|
+
if (!legacyParity)
|
|
43
|
+
generated.set(join(repoDir, "PARITY.md"), out.join("\n"));
|
|
44
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Writes each generated file whose text has changed and removes any other file in spec/index/,
|
|
4
|
+
* printing what it did. With --check it reports them as problems instead.
|
|
5
|
+
*/
|
|
6
|
+
export declare function writeGenerated(ctx: Context, generated: Map<string, string>): void;
|
|
7
|
+
/** Reports every Markdown file the standard defines that is longer than LINE_LIMIT lines. */
|
|
8
|
+
export declare function checkLineLimits(ctx: Context): void;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Writing the generated files, or with --check reporting the stale ones, and the line limit of
|
|
2
|
+
// every Markdown file the standard defines.
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
|
+
import { markdownTree, toSlash, walk } from "../files.js";
|
|
6
|
+
import { lineCount, readText } from "../markdown.js";
|
|
7
|
+
import { LINE_LIMIT } from "../standard.js";
|
|
8
|
+
/**
|
|
9
|
+
* Writes each generated file whose text has changed and removes any other file in spec/index/,
|
|
10
|
+
* printing what it did. With --check it reports them as problems instead.
|
|
11
|
+
*/
|
|
12
|
+
export function writeGenerated(ctx, generated) {
|
|
13
|
+
const { problem } = ctx;
|
|
14
|
+
const { repoDir, specDir, checkOnly } = ctx.config;
|
|
15
|
+
const indexDir = join(specDir, "index");
|
|
16
|
+
const stale = [];
|
|
17
|
+
for (const [p, content] of generated) {
|
|
18
|
+
const current = existsSync(p) ? readText(p) : null;
|
|
19
|
+
if (current !== content)
|
|
20
|
+
stale.push(p);
|
|
21
|
+
}
|
|
22
|
+
const extra = [...(existsSync(indexDir) ? markdownTree(indexDir).values() : [])].filter((f) => !generated.has(f));
|
|
23
|
+
if (checkOnly) {
|
|
24
|
+
for (const p of stale)
|
|
25
|
+
problem(p, existsSync(p)
|
|
26
|
+
? "is stale; run the check without --check to rewrite it"
|
|
27
|
+
: "is missing; run the check without --check to write it");
|
|
28
|
+
for (const f of extra)
|
|
29
|
+
problem(f, "is not a file the check writes; run the check without --check to remove it");
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
for (const p of stale) {
|
|
33
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
34
|
+
writeFileSync(p, generated.get(p));
|
|
35
|
+
console.log(`wrote ${toSlash(relative(repoDir, p))}`);
|
|
36
|
+
}
|
|
37
|
+
for (const f of extra) {
|
|
38
|
+
rmSync(f);
|
|
39
|
+
console.log(`removed ${toSlash(relative(repoDir, f))}`);
|
|
40
|
+
}
|
|
41
|
+
// Directories left empty by a file that moved.
|
|
42
|
+
const prune = (dir) => {
|
|
43
|
+
for (const name of readdirSync(dir)) {
|
|
44
|
+
const p = join(dir, name);
|
|
45
|
+
if (statSync(p).isDirectory())
|
|
46
|
+
prune(p);
|
|
47
|
+
}
|
|
48
|
+
if (dir !== indexDir && readdirSync(dir).length === 0)
|
|
49
|
+
rmSync(dir, { recursive: true });
|
|
50
|
+
};
|
|
51
|
+
if (existsSync(indexDir))
|
|
52
|
+
prune(indexDir);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Reports every Markdown file the standard defines that is longer than LINE_LIMIT lines. */
|
|
56
|
+
export function checkLineLimits(ctx) {
|
|
57
|
+
const { problem } = ctx;
|
|
58
|
+
const { repoDir, specDir } = ctx.config;
|
|
59
|
+
const validationPath = join(repoDir, "VALIDATION.md");
|
|
60
|
+
const parityDir = join(repoDir, "parity");
|
|
61
|
+
const devDir = join(repoDir, "deviations");
|
|
62
|
+
const files = [join(repoDir, "PARITY.md"), validationPath];
|
|
63
|
+
for (const dir of [specDir, parityDir, devDir])
|
|
64
|
+
walk(dir, (f) => {
|
|
65
|
+
if (f.endsWith(".md"))
|
|
66
|
+
files.push(f);
|
|
67
|
+
});
|
|
68
|
+
for (const f of files) {
|
|
69
|
+
if (!existsSync(f))
|
|
70
|
+
continue;
|
|
71
|
+
const lines = lineCount(readText(f));
|
|
72
|
+
if (lines > LINE_LIMIT)
|
|
73
|
+
problem(f, `has ${lines} lines; the documentation standard allows ${LINE_LIMIT}. Split it as the standard's File size section describes`);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Yaml } from "./types.ts";
|
|
2
|
+
/** The distinct spec IDs in text, in the order they first appear. */
|
|
3
|
+
export declare const idsIn: (text: unknown) => string[];
|
|
4
|
+
/** The kind of an ID, such as `RULE`. */
|
|
5
|
+
export declare const kindOf: (id: string) => string;
|
|
6
|
+
/** The area of an ID, such as `COMBAT` (for a build or source, the start of its alias). */
|
|
7
|
+
export declare const areaOf: (id: string) => string;
|
|
8
|
+
/** Builds and sources have an alias in place of an area and a number. */
|
|
9
|
+
export declare const isAlias: (id: string) => boolean;
|
|
10
|
+
/** Compares two IDs for sorting: by number within one kind and area, otherwise by UTF-16 code unit. */
|
|
11
|
+
export declare const compareIds: (a: string, b: string) => -1 | 0 | 1;
|
|
12
|
+
/** A front matter value as a list: a list as is, nothing for null, undefined or "", else a list of one. */
|
|
13
|
+
export declare const asList: (v: Yaml) => Yaml[];
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Spec IDs: finding them in text, taking them apart and ordering them.
|
|
2
|
+
import { ID_RE } from "./standard.js";
|
|
3
|
+
/** The distinct spec IDs in text, in the order they first appear. */
|
|
4
|
+
export const idsIn = (text) => [...new Set(String(text ?? "").match(ID_RE) ?? [])];
|
|
5
|
+
/** The kind of an ID, such as `RULE`. */
|
|
6
|
+
export const kindOf = (id) => id.split("-")[0];
|
|
7
|
+
/** The area of an ID, such as `COMBAT` (for a build or source, the start of its alias). */
|
|
8
|
+
export const areaOf = (id) => id.split("-")[1];
|
|
9
|
+
/** Builds and sources have an alias in place of an area and a number. */
|
|
10
|
+
export const isAlias = (id) => ["BLD", "SRC"].includes(kindOf(id));
|
|
11
|
+
// Orders IDs of one kind and area by number, so RULE-A-999 comes before RULE-A-1000. Anything else
|
|
12
|
+
// compares by UTF-16 code unit, as Array.prototype.sort does, so the order does not depend on the
|
|
13
|
+
// machine's locale.
|
|
14
|
+
const idSortKey = (id) => id.replace(/^((?:FMT|RULE|FND|EXP|BUG|SCR|DEV)-[A-Z][A-Z0-9]*-)(\d+)$/, (_, head, n) => head + n.padStart(12, "0"));
|
|
15
|
+
/** Compares two IDs for sorting: by number within one kind and area, otherwise by UTF-16 code unit. */
|
|
16
|
+
export const compareIds = (a, b) => {
|
|
17
|
+
const x = idSortKey(a);
|
|
18
|
+
const y = idSortKey(b);
|
|
19
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
20
|
+
};
|
|
21
|
+
/** A front matter value as a list: a list as is, nothing for null, undefined or "", else a list of one. */
|
|
22
|
+
export const asList = (v) => Array.isArray(v) ? v : v === null || v === undefined || v === "" ? [] : [v];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LoadContext } from "../context.ts";
|
|
2
|
+
import type { Entry, Meta } from "../types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Reads and checks the manifest of every build entry, and reports a manifest or list of other files
|
|
5
|
+
* that belongs to no build. Returns build ID -> the items of its files list that are maps.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadBuildFiles({ config, problem }: LoadContext, entries: Map<string, Entry>): Map<string, Meta[]>;
|
|
8
|
+
/** Checks each build's list of other files against its manifest and its Other files section. */
|
|
9
|
+
export declare function checkOtherFiles({ problem }: LoadContext, entries: Map<string, Entry>, buildFiles: Map<string, Meta[]>): void;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Build manifests, which list a build's files, and the lists of the other files of its installation.
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { readText } from "../markdown.js";
|
|
5
|
+
import { locationRule, unlistedFormat } from "../standard.js";
|
|
6
|
+
import { parseYaml } from "../yaml.js";
|
|
7
|
+
/**
|
|
8
|
+
* Reads and checks the manifest of every build entry, and reports a manifest or list of other files
|
|
9
|
+
* that belongs to no build. Returns build ID -> the items of its files list that are maps.
|
|
10
|
+
*/
|
|
11
|
+
export function loadBuildFiles({ config, problem }, entries) {
|
|
12
|
+
const { specDir } = config;
|
|
13
|
+
// Build manifests: builds/<ID>.files.yaml holds a build's files list and nothing else.
|
|
14
|
+
const buildFiles = new Map();
|
|
15
|
+
for (const [id, e] of entries) {
|
|
16
|
+
if (e.kind !== "BLD")
|
|
17
|
+
continue;
|
|
18
|
+
const expected = `${id}.files.yaml`;
|
|
19
|
+
if ("files" in e.meta)
|
|
20
|
+
problem(e.file, `the files list belongs in the manifest ${expected}, not in the entry`);
|
|
21
|
+
if (e.meta.manifest !== expected) {
|
|
22
|
+
problem(e.file, `manifest must be ${expected}`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const path = join(dirname(e.file), expected);
|
|
26
|
+
if (!existsSync(path)) {
|
|
27
|
+
problem(e.file, `manifest ${expected} does not exist`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const manifest = parseYaml(readText(path), path, problem);
|
|
31
|
+
for (const key of Object.keys(manifest))
|
|
32
|
+
if (key !== "files")
|
|
33
|
+
problem(path, `a manifest has only the key files, not ${key}`);
|
|
34
|
+
if (!Array.isArray(manifest.files)) {
|
|
35
|
+
problem(path, "files must be a list");
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
// Later checks read f.path, so an item that is not a map is reported and left out.
|
|
39
|
+
if (manifest.files.some((f) => !f || typeof f !== "object"))
|
|
40
|
+
problem(path, "every item of files is a map of path, format, size and xxh3");
|
|
41
|
+
const files = manifest.files.filter((f) => f && typeof f === "object");
|
|
42
|
+
buildFiles.set(id, files);
|
|
43
|
+
for (const f of files) {
|
|
44
|
+
if (!f.path)
|
|
45
|
+
problem(path, "every file has a path");
|
|
46
|
+
if (f.format === undefined || f.format === null || f.format === "")
|
|
47
|
+
problem(path, `${f.path}: every file has a format`);
|
|
48
|
+
else if (!locationRule(f.format))
|
|
49
|
+
problem(path, `${f.path}: ${unlistedFormat(f.format)}`);
|
|
50
|
+
const unpackedFormat = f.unpacked?.format;
|
|
51
|
+
if (unpackedFormat !== undefined &&
|
|
52
|
+
unpackedFormat !== null &&
|
|
53
|
+
unpackedFormat !== "" &&
|
|
54
|
+
!locationRule(unpackedFormat))
|
|
55
|
+
problem(path, `${f.path}: unpacked ${unlistedFormat(unpackedFormat)}`);
|
|
56
|
+
if (!/^[0-9a-f]{32}$/.test(String(f.xxh3)))
|
|
57
|
+
problem(path, `${f.path}: xxh3 must be 32 lower-case hex digits`);
|
|
58
|
+
if (typeof f.size !== "number")
|
|
59
|
+
problem(path, `${f.path}: size must be a number`);
|
|
60
|
+
if (f.packer && !(f.unpacked && f.unpacked.size && f.unpacked.xxh3 && f.unpacked.format && f.unpacked.tool))
|
|
61
|
+
problem(path, `${f.path}: a packed file gives the size, xxh3, format and tool of its unpacked form`);
|
|
62
|
+
if (String(f.path).includes("\\"))
|
|
63
|
+
problem(path, `${f.path}: paths use forward slashes`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (existsSync(join(specDir, "builds")))
|
|
67
|
+
for (const name of readdirSync(join(specDir, "builds"))) {
|
|
68
|
+
const m = /^(.+)\.(?:other-)?files\.yaml$/.exec(name);
|
|
69
|
+
if (m && entries.get(m[1])?.kind !== "BLD")
|
|
70
|
+
problem(join(specDir, "builds", name), `belongs to no build entry (${m[1]})`);
|
|
71
|
+
}
|
|
72
|
+
return buildFiles;
|
|
73
|
+
}
|
|
74
|
+
/** Checks each build's list of other files against its manifest and its Other files section. */
|
|
75
|
+
export function checkOtherFiles({ problem }, entries, buildFiles) {
|
|
76
|
+
// Other files: every path of the installation's listing that the manifest leaves out, each with
|
|
77
|
+
// its reason, in the build entry's Other files section or, for a long list, in
|
|
78
|
+
// builds/<ID>.other-files.yaml, which that section names.
|
|
79
|
+
for (const [id, e] of entries) {
|
|
80
|
+
if (e.kind !== "BLD")
|
|
81
|
+
continue;
|
|
82
|
+
const name = `${id}.other-files.yaml`;
|
|
83
|
+
const path = join(dirname(e.file), name);
|
|
84
|
+
const named = (e.sections.find((s) => s.title === "Other files")?.text ?? "").includes(name);
|
|
85
|
+
if (!existsSync(path)) {
|
|
86
|
+
if (named)
|
|
87
|
+
problem(e.file, `Other files names ${name}, which does not exist`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!named)
|
|
91
|
+
problem(e.file, `the Other files section names ${name}, which lists the paths the manifest leaves out`);
|
|
92
|
+
const list = parseYaml(readText(path), path, problem);
|
|
93
|
+
for (const key of Object.keys(list))
|
|
94
|
+
if (key !== "other_files")
|
|
95
|
+
problem(path, `a list of other files has only the key other_files, not ${key}`);
|
|
96
|
+
if (!Array.isArray(list.other_files)) {
|
|
97
|
+
problem(path, "other_files must be a list");
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// The front matter reader turns a bare name such as 1990 into a number, so paths compare as text.
|
|
101
|
+
const inManifest = new Set((buildFiles.get(id) ?? []).map((f) => String(f.path)));
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
for (const item of list.other_files) {
|
|
104
|
+
if (!item || typeof item !== "object" || Object.keys(item).sort().join(",") !== "path,reason") {
|
|
105
|
+
problem(path, "every item of other_files is a map of path and reason");
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (item.path === null || item.path === undefined || item.path === "") {
|
|
109
|
+
problem(path, "every other file has a path");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const other = String(item.path);
|
|
113
|
+
if (item.reason === null || String(item.reason).trim() === "")
|
|
114
|
+
problem(path, `${other}: every other file gives the reason the manifest leaves it out`);
|
|
115
|
+
if (other.includes("\\"))
|
|
116
|
+
problem(path, `${other}: paths use forward slashes`);
|
|
117
|
+
if (inManifest.has(other))
|
|
118
|
+
problem(path, `${other} is in the manifest, so it is not one of the other files`);
|
|
119
|
+
if (seen.has(other))
|
|
120
|
+
problem(path, `${other} is listed twice`);
|
|
121
|
+
seen.add(other);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { LoadContext } from "../context.ts";
|
|
2
|
+
import type { CodeRange, Entry, Meta } from "../types.ts";
|
|
3
|
+
/** Reads and checks the Code ranges section of every build entry. Returns build ID -> its ranges. */
|
|
4
|
+
export declare function loadCodeRanges({ problem }: LoadContext, entries: Map<string, Entry>, buildFiles: Map<string, Meta[]>): Map<string, CodeRange[]>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Code ranges: the half-open ranges of each file that hold code located by offset, each with the
|
|
2
|
+
// finding that shows it. A table File | Range | Overlay | Finding, or None.
|
|
3
|
+
import { asList, idsIn, kindOf } from "../ids.js";
|
|
4
|
+
import { checkOffset, parseOffset } from "../locations.js";
|
|
5
|
+
import { tables } from "../markdown.js";
|
|
6
|
+
import { locationRule } from "../standard.js";
|
|
7
|
+
const CODE_RANGES = ["File", "Range", "Overlay", "Finding"];
|
|
8
|
+
const unticked = (cell) => cell.replace(/^`(.*)`$/, "$1").trim();
|
|
9
|
+
/** Reads and checks the Code ranges section of every build entry. Returns build ID -> its ranges. */
|
|
10
|
+
export function loadCodeRanges({ problem }, entries, buildFiles) {
|
|
11
|
+
const codeRanges = new Map(); // build ID -> [{ file, start, end }]
|
|
12
|
+
for (const [id, e] of entries) {
|
|
13
|
+
if (e.kind !== "BLD")
|
|
14
|
+
continue;
|
|
15
|
+
const section = e.sections.find((s) => s.title === "Code ranges");
|
|
16
|
+
// A missing section is reported with the other sections.
|
|
17
|
+
if (!section)
|
|
18
|
+
continue;
|
|
19
|
+
const ranges = [];
|
|
20
|
+
if (/^\s*None\.\s*$/.test(section.text)) {
|
|
21
|
+
codeRanges.set(id, ranges);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const found = tables(section.text);
|
|
25
|
+
// A malformed section is reported once here; offsets into the build are then not measured
|
|
26
|
+
// against it, as with a missing section, rather than each failing again.
|
|
27
|
+
if (found.length !== 1 || found[0].header.join("|") !== CODE_RANGES.join("|") || found[0].rows.length === 0) {
|
|
28
|
+
problem(e.file, `the Code ranges section is one table with the columns ${CODE_RANGES.join(" | ")}, or None.`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
codeRanges.set(id, ranges);
|
|
32
|
+
const files = buildFiles.get(id) ?? [];
|
|
33
|
+
for (const row of found[0].rows) {
|
|
34
|
+
if (row.length !== CODE_RANGES.length) {
|
|
35
|
+
problem(e.file, `Code ranges row ${row.join(" | ")}: a row has ${CODE_RANGES.length} cells, not ${row.length}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const [path, range, overlay, finding] = row.map(unticked);
|
|
39
|
+
const at = `Code ranges row ${path} ${range}`;
|
|
40
|
+
const bf = files.find((f) => f.path === path);
|
|
41
|
+
if (!bf) {
|
|
42
|
+
problem(e.file, `${at}: ${path} is not in the manifest`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// Only overlay code is located by offset, so a row is for a file whose unpacked format takes
|
|
46
|
+
// both addresses and offsets (MZ). An unlisted format is already reported against its manifest.
|
|
47
|
+
const format = bf.unpacked?.format ?? bf.format;
|
|
48
|
+
const rule = locationRule(format);
|
|
49
|
+
if (rule && !(rule.offset && rule.address))
|
|
50
|
+
problem(e.file, `${at}: ${path} is a ${format} file, which holds no code located by offset`);
|
|
51
|
+
// The notation is parseOffset's; a row additionally needs both ends of the range.
|
|
52
|
+
if (!range.includes("..") || !parseOffset(range)) {
|
|
53
|
+
problem(e.file, `${at}: the range is one half-open offset range, 0x followed by upper-case hex digits on each side of ..`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!/^(?:-|\d+|0x[0-9A-F]+)$/.test(overlay))
|
|
57
|
+
problem(e.file, `${at}: the overlay is its number, or - where there is none`);
|
|
58
|
+
// A finding that does not exist is reported with the other unresolved IDs of the body.
|
|
59
|
+
const ids = idsIn(finding);
|
|
60
|
+
const cited = entries.get(ids[0]);
|
|
61
|
+
if (ids.length !== 1 || kindOf(ids[0]) !== "FND" || finding !== ids[0])
|
|
62
|
+
problem(e.file, `${at}: the finding column holds the ID of one finding`);
|
|
63
|
+
else if (cited && !asList(cited.meta.builds).includes(id))
|
|
64
|
+
problem(e.file, `${at}: ${ids[0]} does not list ${id}`);
|
|
65
|
+
else if (cited?.meta.status === "superseded")
|
|
66
|
+
problem(e.file, `${at}: cites ${ids[0]}, which is superseded`, "STATUS-17");
|
|
67
|
+
// The finding shows code in this file of this build, so it has a location there that is not
|
|
68
|
+
// file data. One with no locations there, or only file data there, shows no code in the row.
|
|
69
|
+
else if (cited &&
|
|
70
|
+
!asList(cited.meta.locations).some((loc) => loc?.build === id && loc?.file === path && loc?.kind !== "file-data")) {
|
|
71
|
+
problem(e.file, `${at}: ${ids[0]} has no code location in ${path} of ${id}, so it cannot establish a code range there`);
|
|
72
|
+
}
|
|
73
|
+
const parsed = checkOffset(problem, e.file, range, bf);
|
|
74
|
+
if (parsed)
|
|
75
|
+
ranges.push({ file: path, start: parsed[0], end: parsed[1] });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return codeRanges;
|
|
79
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { LoadContext } from "../context.ts";
|
|
2
|
+
import type { Entry } from "../types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Reads every entry under spec/, by ID, and reports a directory of spec/ the standard does not
|
|
5
|
+
* define. An entry without an ID or of an unknown kind is reported and left out.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadEntries({ config, problem }: LoadContext): Map<string, Entry>;
|