@amritk/nish 0.12.0 → 0.14.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 (42) hide show
  1. package/README.md +27 -23
  2. package/bin/launcher.js +45 -41
  3. package/bin/packaging.js +39 -33
  4. package/docs/AI.md +157 -35
  5. package/docs/INSTALL.md +13 -13
  6. package/llms.txt +1 -1
  7. package/package.json +9 -6
  8. package/runtime/nish.d.ts +85 -1
  9. package/runtime/nish.h +62 -11
  10. package/runtime/nish.mjs +38 -3
  11. package/runtime/runtime-host.c +178 -0
  12. package/runtime/{runtime_os.c → runtime-os.c} +16 -1
  13. package/runtime/{runtime_parallel.c → runtime-parallel.c} +112 -4
  14. package/runtime/{runtime_wasm.c → runtime-wasm.c} +6 -1
  15. package/runtime/runtime.c +5 -5
  16. package/runtime/shim.mjs +128 -6
  17. package/scripts/bootstrap.sh +29 -29
  18. package/scripts/build.sh +19 -13
  19. package/scripts/changelog-gen.mjs +260 -192
  20. package/scripts/ci-profile.mjs +80 -68
  21. package/scripts/codes-registry.js +25 -13
  22. package/scripts/gen-diagnostic-codes.mjs +124 -85
  23. package/scripts/nish-compiler.sh +2 -0
  24. package/scripts/platform-package.mjs +26 -26
  25. package/scripts/postinstall.mjs +46 -36
  26. package/scripts/size-report.sh +3 -3
  27. package/scripts/smoke.sh +1 -1
  28. package/std/README.md +42 -2
  29. package/std/collections.ts +194 -188
  30. package/std/crypto/base64url.ts +145 -0
  31. package/std/crypto/ct.ts +64 -0
  32. package/std/crypto/hkdf.ts +118 -0
  33. package/std/crypto/hmac.ts +155 -0
  34. package/std/crypto/sha256.ts +444 -0
  35. package/std/crypto/sha512.ts +510 -0
  36. package/std/crypto/x25519.ts +494 -0
  37. package/std/json.ts +136 -136
  38. package/std/map.ts +9 -7
  39. package/std/pair.ts +2 -2
  40. package/std/testing.ts +67 -67
  41. package/std/text.ts +54 -54
  42. package/std/threads.ts +126 -38
@@ -33,20 +33,21 @@
33
33
  * runner measured about 1.34x this box on the same suite, so the shape
34
34
  * transfers and the absolute seconds do not.
35
35
  */
36
- import { spawn } from "node:child_process";
36
+ import { spawn } from "node:child_process"
37
37
 
38
- const argv = process.argv.slice(2);
39
- const asJson = argv.includes("--json");
40
- const topAt = argv.indexOf("--top");
41
- const TOP = topAt >= 0 && Number.isFinite(Number(argv[topAt + 1])) ? Number(argv[topAt + 1]) : 40;
38
+ const argv = process.argv.slice(2)
39
+ const asJson = argv.includes("--json")
40
+ const topAt = argv.indexOf("--top")
41
+ const TOP = topAt >= 0 && Number.isFinite(Number(argv[topAt + 1])) ? Number(argv[topAt + 1]) : 40
42
42
 
43
43
  /**
44
44
  * Everything after `--` is the command to profile. The default is the suite as
45
45
  * CI runs it, minus the build: `npm test` would rebuild `dist/` first, and a
46
46
  * `tsc` run is not what this is measuring.
47
47
  */
48
- const dashdash = argv.indexOf("--");
49
- const command = dashdash >= 0 && argv.length > dashdash + 1 ? argv.slice(dashdash + 1) : ["node", "tests/run.js"];
48
+ const dashdash = argv.indexOf("--")
49
+ const command =
50
+ dashdash >= 0 && argv.length > dashdash + 1 ? argv.slice(dashdash + 1) : ["node", "tests/run.js"]
50
51
 
51
52
  /**
52
53
  * One row per line the child printed, with `dt` the gap since the line before
@@ -54,26 +55,26 @@ const command = dashdash >= 0 && argv.length > dashdash + 1 ? argv.slice(dashdas
54
55
  * check in the log, and `t` is kept as well as `dt` because "which minute of
55
56
  * the run was this" is how a reader locates a slow block.
56
57
  */
57
- const rows = [];
58
- const t0 = process.hrtime.bigint();
59
- let last = 0;
60
- let buffered = "";
58
+ const rows = []
59
+ const t0 = process.hrtime.bigint()
60
+ let last = 0
61
+ let buffered = ""
61
62
 
62
63
  const takeLine = (line) => {
63
- const t = Number(process.hrtime.bigint() - t0) / 1e6;
64
- rows.push({ t, dt: t - last, text: line });
65
- last = t;
66
- };
64
+ const t = Number(process.hrtime.bigint() - t0) / 1e6
65
+ rows.push({ t, dt: t - last, text: line })
66
+ last = t
67
+ }
67
68
 
68
69
  const onData = (data) => {
69
- buffered += data;
70
- let i = buffered.indexOf("\n");
70
+ buffered += data
71
+ let i = buffered.indexOf("\n")
71
72
  while (i >= 0) {
72
- takeLine(buffered.slice(0, i));
73
- buffered = buffered.slice(i + 1);
74
- i = buffered.indexOf("\n");
73
+ takeLine(buffered.slice(0, i))
74
+ buffered = buffered.slice(i + 1)
75
+ i = buffered.indexOf("\n")
75
76
  }
76
- };
77
+ }
77
78
 
78
79
  /**
79
80
  * A heartbeat on stderr, because the tables below cannot be printed until the
@@ -83,27 +84,29 @@ const onData = (data) => {
83
84
  * the oracles working or the run wedged.
84
85
  */
85
86
  const heartbeat = setInterval(() => {
86
- const latest = rows.length > 0 ? rows[rows.length - 1].text.slice(0, 72) : "nothing printed yet";
87
- const elapsed = (Number(process.hrtime.bigint() - t0) / 1e9).toFixed(0);
88
- process.stderr.write(`[ci-profile] ${elapsed}s, ${rows.length} lines: ${latest}\n`);
89
- }, 30_000);
90
- heartbeat.unref();
91
-
92
- const child = spawn(command[0], command.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
93
- child.stdout.setEncoding("utf8");
94
- child.stderr.setEncoding("utf8");
95
- child.stdout.on("data", onData);
96
- child.stderr.on("data", onData);
87
+ const latest = rows.length > 0 ? rows[rows.length - 1].text.slice(0, 72) : "nothing printed yet"
88
+ const elapsed = (Number(process.hrtime.bigint() - t0) / 1e9).toFixed(0)
89
+ process.stderr.write(`[ci-profile] ${elapsed}s, ${rows.length} lines: ${latest}\n`)
90
+ }, 30_000)
91
+ heartbeat.unref()
92
+
93
+ const child = spawn(command[0], command.slice(1), { stdio: ["ignore", "pipe", "pipe"] })
94
+ child.stdout.setEncoding("utf8")
95
+ child.stderr.setEncoding("utf8")
96
+ child.stdout.on("data", onData)
97
+ child.stderr.on("data", onData)
97
98
 
98
99
  const status = await new Promise((resolve) => {
99
- child.on("close", (code) => resolve(code ?? 1));
100
+ child.on("close", (code) => resolve(code ?? 1))
100
101
  child.on("error", (err) => {
101
- console.error(`could not run ${command.join(" ")}: ${err.message}`);
102
- resolve(1);
103
- });
104
- });
105
- clearInterval(heartbeat);
106
- if (buffered.length > 0) takeLine(buffered);
102
+ console.error(`could not run ${command.join(" ")}: ${err.message}`)
103
+ resolve(1)
104
+ })
105
+ })
106
+ clearInterval(heartbeat)
107
+ if (buffered.length > 0) {
108
+ takeLine(buffered)
109
+ }
107
110
 
108
111
  /**
109
112
  * A check's name up to its first colon, which is how this suite names a family:
@@ -113,23 +116,23 @@ if (buffered.length > 0) takeLine(buffered);
113
116
  * keep the oracles — each of which prints one long line — apart.
114
117
  */
115
118
  const familyOf = (text) => {
116
- const body = text.replace(/^(PASS|FAIL|SKIP)\s+/, "");
117
- const colon = body.indexOf(":");
118
- return colon > 0 ? body.slice(0, colon) : body.slice(0, 40);
119
- };
119
+ const body = text.replace(/^(PASS|FAIL|SKIP)\s+/, "")
120
+ const colon = body.indexOf(":")
121
+ return colon > 0 ? body.slice(0, colon) : body.slice(0, 40)
122
+ }
120
123
 
121
- const families = new Map();
124
+ const families = new Map()
122
125
  for (const row of rows) {
123
- const key = familyOf(row.text);
124
- const seen = families.get(key) ?? { ms: 0, lines: 0 };
125
- seen.ms += row.dt;
126
- seen.lines += 1;
127
- families.set(key, seen);
126
+ const key = familyOf(row.text)
127
+ const seen = families.get(key) ?? { ms: 0, lines: 0 }
128
+ seen.ms += row.dt
129
+ seen.lines += 1
130
+ families.set(key, seen)
128
131
  }
129
132
 
130
- const total = rows.length > 0 ? rows[rows.length - 1].t : 0;
131
- const byCost = [...rows].sort((a, b) => b.dt - a.dt).slice(0, TOP);
132
- const byFamily = [...families].sort((a, b) => b[1].ms - a[1].ms).slice(0, TOP);
133
+ const total = rows.length > 0 ? rows[rows.length - 1].t : 0
134
+ const byCost = [...rows].sort((a, b) => b.dt - a.dt).slice(0, TOP)
135
+ const byFamily = [...families].sort((a, b) => b[1].ms - a[1].ms).slice(0, TOP)
133
136
 
134
137
  if (asJson) {
135
138
  console.log(
@@ -145,14 +148,14 @@ if (asJson) {
145
148
  null,
146
149
  2
147
150
  )
148
- );
149
- process.exit(status);
151
+ )
152
+ process.exit(status)
150
153
  }
151
154
 
152
- const secs = (ms) => `${(ms / 1000).toFixed(1)}s`.padStart(8);
155
+ const secs = (ms) => `${(ms / 1000).toFixed(1)}s`.padStart(8)
153
156
 
154
- console.log(`\n${command.join(" ")}`);
155
- console.log(`exit ${status} — ${secs(total).trim()} of wall clock over ${rows.length} printed lines\n`);
157
+ console.log(`\n${command.join(" ")}`)
158
+ console.log(`exit ${status} — ${secs(total).trim()} of wall clock over ${rows.length} printed lines\n`)
156
159
 
157
160
  /**
158
161
  * The run's own verdict, echoed verbatim.
@@ -164,25 +167,34 @@ console.log(`exit ${status} — ${secs(total).trim()} of wall clock over ${rows.
164
167
  * the exit status the thing to do). So the summary is printed before the
165
168
  * timings, where it cannot be missed.
166
169
  */
167
- const verdict = rows.filter((r) => r.text.trim().length > 0).slice(-3);
170
+ const verdict = rows.filter((r) => r.text.trim().length > 0).slice(-3)
168
171
  if (verdict.length > 0) {
169
- console.log("=== what the run itself reported ===");
170
- for (const row of verdict) console.log(` ${row.text}`);
171
- console.log("");
172
+ console.log("=== what the run itself reported ===")
173
+ for (const row of verdict) {
174
+ console.log(` ${row.text}`)
175
+ }
176
+ console.log("")
172
177
  }
173
178
 
174
- console.log(`=== the ${byCost.length} most expensive checks (cost = the gap before the line was printed) ===`);
175
- for (const row of byCost) console.log(`${secs(row.dt)} at ${secs(row.t)} ${row.text.slice(0, 104)}`);
179
+ console.log(`=== the ${byCost.length} most expensive checks (cost = the gap before the line was printed) ===`)
180
+ for (const row of byCost) {
181
+ console.log(`${secs(row.dt)} at ${secs(row.t)} ${row.text.slice(0, 104)}`)
182
+ }
176
183
 
177
- console.log(`\n=== the ${byFamily.length} most expensive families (cumulative) ===`);
178
- for (const [name, f] of byFamily) console.log(`${secs(f.ms)} ${String(f.lines).padStart(5)} line(s) ${name.slice(0, 84)}`);
184
+ console.log(`\n=== the ${byFamily.length} most expensive families (cumulative) ===`)
185
+ for (const [name, f] of byFamily) {
186
+ console.log(`${secs(f.ms)} ${String(f.lines).padStart(5)} line(s) ${name.slice(0, 84)}`)
187
+ }
179
188
 
180
189
  /**
181
190
  * The share the expensive tail accounts for, because "the top ten are 70% of
182
191
  * the run" is the sentence that decides whether to optimise a check or the
183
192
  * shape of the job around it.
184
193
  */
185
- const topTen = [...rows].sort((a, b) => b.dt - a.dt).slice(0, 10).reduce((sum, r) => sum + r.dt, 0);
186
- console.log(`\nthe ten most expensive checks are ${((topTen / total) * 100).toFixed(0)}% of the run.`);
194
+ const topTen = [...rows]
195
+ .sort((a, b) => b.dt - a.dt)
196
+ .slice(0, 10)
197
+ .reduce((sum, r) => sum + r.dt, 0)
198
+ console.log(`\nthe ten most expensive checks are ${((topTen / total) * 100).toFixed(0)}% of the run.`)
187
199
 
188
- process.exit(status);
200
+ process.exit(status)
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * The diagnostic-code registry, read back out of a `codes.ts`.
3
3
  *
4
- * `self/codes.ts` holds the table -- a fragment line, then the `NL####` line
4
+ * `src/codes.ts` holds the table -- a fragment line, then the `NL####` line
5
5
  * that names its rule. Three places in this repository read it back:
6
6
  * `scripts/gen-diagnostic-codes.mjs`, which checks the registry's shape, its
7
- * order and that no number is used twice; `tests/diagnostic_coverage.js`,
7
+ * order and that no number is used twice; `tests/diagnostic-coverage.js`,
8
8
  * which asks which codes the suite reaches; and the `codes:` checks in
9
9
  * `tests/run.js`. This module is that parse, once, so the copies cannot drift
10
10
  * apart again ([issue #96](https://github.com/amritk/nish/issues/96)).
@@ -22,7 +22,7 @@
22
22
  * Two properties are the whole point of having it, and both are here because
23
23
  * they have failed:
24
24
  *
25
- * - **The indentation is not part of the contract.** `self/codes.ts` lost a
25
+ * - **The indentation is not part of the contract.** `src/codes.ts` lost a
26
26
  * level when WP22 stage C rewrote its tables as arrows with concise
27
27
  * bodies, and every reader keyed on four literal spaces then read it as
28
28
  * *empty* rather than as changed. `^\s+` is what a pair is recognised by.
@@ -31,17 +31,29 @@
31
31
  * codes" the same answer, which is what let the first instance of this
32
32
  * survive unnoticed. This one raises instead.
33
33
  */
34
- import fs from "node:fs";
35
- import path from "node:path";
34
+ import fs from "node:fs"
35
+ import path from "node:path"
36
36
 
37
- const root = path.resolve(import.meta.dirname, "..");
37
+ const root = path.resolve(import.meta.dirname, "..")
38
+
39
+ /**
40
+ * One string literal in either quote: the formatter writes a fragment that
41
+ * holds a `"` with single quotes, because that needs no escape.
42
+ */
43
+ export const STRING_LITERAL = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g
38
44
 
39
45
  /**
40
46
  * One entry of the emitted table: the quoted fragment on its own line, then
41
47
  * the code on the next. Anchored to the line rather than to a column, for the
42
48
  * reason in the header.
43
49
  */
44
- const PAIR = /^\s+("(?:[^"\\]|\\.)*"),\n\s+"(NL\d{4})",$/gm;
50
+ const PAIR = new RegExp(`^\\s+(${STRING_LITERAL.source}),\\n\\s+"(NL\\d{4})",$`, "gm")
51
+
52
+ /** The text of a string literal in either quote, as JavaScript reads it. */
53
+ const unquote = (literal) =>
54
+ literal.startsWith("'")
55
+ ? JSON.parse(`"${literal.slice(1, -1).replaceAll("\\'", "'").replaceAll('"', '\\"')}"`)
56
+ : JSON.parse(literal)
45
57
 
46
58
  /**
47
59
  * Every `{ fragment, code }` of one registry file, in the order the file holds
@@ -58,16 +70,16 @@ const PAIR = /^\s+("(?:[^"\\]|\\.)*"),\n\s+"(NL\d{4})",$/gm;
58
70
  * message names the text by.
59
71
  */
60
72
  export const parseCodesRegistry = (text, label) => {
61
- const pairs = [];
73
+ const pairs = []
62
74
  for (const m of text.matchAll(PAIR)) {
63
- pairs.push({ fragment: JSON.parse(m[1]), code: m[2] });
75
+ pairs.push({ fragment: unquote(m[1]), code: m[2] })
64
76
  }
65
77
  if (pairs.length === 0) {
66
- throw new Error(`${label}: no diagnostic codes parsed -- the registry's shape has moved`);
78
+ throw new Error(`${label}: no diagnostic codes parsed -- the registry's shape has moved`)
67
79
  }
68
- return pairs;
69
- };
80
+ return pairs
81
+ }
70
82
 
71
83
  /** The same, for a registry on disk. The three callers all read a file. */
72
84
  export const readCodesRegistry = (file) =>
73
- parseCodesRegistry(fs.readFileSync(file, "utf8"), path.relative(root, file));
85
+ parseCodesRegistry(fs.readFileSync(file, "utf8"), path.relative(root, file))
@@ -10,13 +10,14 @@
10
10
  * with one line broken to show that each rule below is really enforced.
11
11
  *
12
12
  * **The registry is kept by hand now.** It used to be generated: this script
13
- * scanned `src/` for every diagnostic message, cut each at its interpolations,
14
- * and wrote the same table into `src/codes.ts` and `self/codes.ts`. That scan
13
+ * scanned stage0's `src/` for every diagnostic message, cut each at its interpolations,
14
+ * and wrote the same table into stage0's `src/codes.ts` and this compiler's `self/codes.ts`
15
+ * (now `src/codes.ts`). That scan
15
16
  * read stage0's source, and stage0 is deleted (wp19 §5 R6), so the generator
16
- * was frozen with the table it last wrote and `self/codes.ts` became the
17
+ * was frozen with the table it last wrote and `src/codes.ts` became the
17
18
  * registry. A new diagnostic gets its code by hand: append a fragment and the
18
19
  * next free number in its band (the second mode above prints those), at the
19
- * position the ordering rule below puts it. `tests/diagnostic_coverage.js`
20
+ * position the ordering rule below puts it. `tests/diagnostic-coverage.js`
20
21
  * is what notices a diagnostic that has no code, by counting `NL0000`.
21
22
  *
22
23
  * What `--check` still holds, because each is what makes a code worth keying
@@ -24,10 +25,12 @@
24
25
  *
25
26
  * - **Every number is well-formed and in its band.** `NL1xxx` Phase 0,
26
27
  * `NL2xxx` the checker, `NL3xxx` the driver, `NL4xxx` the interop
27
- * sidecars, `NL9xxx` a WP15 section 8 performance warning. Band 0 is not
28
- * in the tables: `NL0000` (no rule matched), `NL0001` (a syntax error),
29
- * `NL0002` (the toolchain) and `NL0003` (an internal error) are constants.
30
- * A performance fragment is in `performanceRules` and nowhere else.
28
+ * sidecars, `NL8xxx` a WP33 portability warning, `NL9xxx` a WP15 section
29
+ * 8 performance warning. Band 0 is not in the tables: `NL0000` (no rule
30
+ * matched), `NL0001` (a syntax error), `NL0002` (the toolchain) and
31
+ * `NL0003` (an internal error) are constants. A performance fragment is in
32
+ * `performanceRules` and nowhere else, and a portability fragment is in
33
+ * `portabilityRules` and nowhere else.
31
34
  * - **Nothing is used twice.** A number handed out once is never handed to a
32
35
  * different rule, and a retired rule keeps its entry -- it matches nothing,
33
36
  * so carrying it costs a string -- precisely so that its number stays
@@ -39,7 +42,7 @@
39
42
  * - **No fragment too short to identify a rule** (ten characters, trimmed),
40
43
  * which would match half the suite.
41
44
  * - **Every string in a table is half of a pair.** `codeFor` in
42
- * `self/codes.ts` reads each table as one flat array and steps through it
45
+ * `src/codes.ts` reads each table as one flat array and steps through it
43
46
  * by two, so a fragment added without its code line -- or a code without
44
47
  * its fragment -- shifts every pairing after it, and from there on
45
48
  * messages get their neighbour's code. The pair reader cannot see that:
@@ -47,7 +50,8 @@
47
50
  * simply not a match. So each table's strings are counted on their own
48
51
  * and have to come to twice its pairs ([#107](https://github.com/amritk/nish/issues/107)).
49
52
  * - **The `NL9xxx` codes run from `NL9001` with no gap**, one per WP15
50
- * section 8 rule, so a missing number is a rule that lost its code.
53
+ * section 8 rule, so a missing number is a rule that lost its code; and
54
+ * the `NL8xxx` codes run from `NL8001` the same way, one per WP33 row.
51
55
  * - **`RULE_COUNT` is the number of entries.**
52
56
  *
53
57
  * A fragment is the longest literal run of its message's template -- the rule
@@ -57,32 +61,49 @@
57
61
  * before the name, never the name, so `branding.ts` stays the only place it is
58
62
  * spelled (rule 5 in `.claude/orientation.md`). Keep to that when adding one.
59
63
  */
60
- import fs from "node:fs";
61
- import path from "node:path";
62
- import { fileURLToPath } from "node:url";
63
- import { parseCodesRegistry } from "./codes-registry.js";
64
+ import fs from "node:fs"
65
+ import path from "node:path"
66
+ import { fileURLToPath } from "node:url"
67
+ import { parseCodesRegistry, STRING_LITERAL } from "./codes-registry.js"
64
68
 
65
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
66
- const REGISTRY = process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? path.join(ROOT, "self", "codes.ts");
69
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
70
+ const REGISTRY =
71
+ process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? path.join(ROOT, "src", "codes.ts")
67
72
 
68
73
  /** The bands a table entry may use. Band 0 is constants, never a table row. */
69
- const BANDS = new Set(["1", "2", "3", "4", "9"]);
74
+ const BANDS = new Set(["1", "2", "3", "4", "8", "9"])
70
75
 
71
76
  /** Shorter than this, a fragment would match half the suite. */
72
- const MIN_FRAGMENT = 10;
77
+ const MIN_FRAGMENT = 10
73
78
 
74
79
  /**
75
- * The text of one table in `self/codes.ts`: from its `name = (): string[] => [`
80
+ * The three tables, the band each warning table owns alone, and that warning
81
+ * class's name: a warning table holds only its band's codes and its band's
82
+ * codes live only there, because `codeFor` matches a warning's message against
83
+ * its own table and nothing else. `null` is the table of errors, which takes
84
+ * every other band.
85
+ */
86
+ const TABLES = [
87
+ ["diagnosticRules", null, null],
88
+ ["portabilityRules", "8", "portability"],
89
+ ["performanceRules", "9", "performance"],
90
+ ]
91
+
92
+ /**
93
+ * The text of one table in `src/codes.ts`: from its `name = (): string[] => [`
76
94
  * to the `];` that closes it. Parsed per table, because the table a fragment
77
95
  * sits in is part of what it means -- a performance rule is matched only
78
96
  * against a performance message.
79
97
  */
80
98
  const tableText = (text, name) => {
81
- const open = text.indexOf(`export const ${name} = (): string[] => [`);
82
- if (open < 0) return null;
83
- const close = text.indexOf("\n];", open);
84
- return close < 0 ? null : text.slice(open, close + 1);
85
- };
99
+ const header = new RegExp(`^(?:export )?const ${name} = \\(\\): string\\[\\] => \\[`, "m").exec(text)
100
+ const open = header === null ? -1 : header.index
101
+ if (open < 0) {
102
+ return null
103
+ }
104
+ const close = text.indexOf("\n]", open)
105
+ return close < 0 ? null : text.slice(open, close + 1)
106
+ }
86
107
 
87
108
  /**
88
109
  * How many strings one table holds: every literal after the line that opens
@@ -90,110 +111,128 @@ const tableText = (text, name) => {
90
111
  * its own rather than read off the pairs -- a stray literal is exactly what
91
112
  * the pair reader skips.
92
113
  */
93
- const STRING = /"(?:[^"\\]|\\.)*"/g;
94
- const literalCount = (body) => body.slice(body.indexOf("\n")).match(STRING)?.length ?? 0;
114
+ const literalCount = (body) => body.slice(body.indexOf("\n")).match(STRING_LITERAL)?.length ?? 0
95
115
 
96
116
  /** The order the compilers rely on: longest fragment first, then `localeCompare`. */
97
- const inOrder = (a, b) => b.fragment.length - a.fragment.length || a.fragment.localeCompare(b.fragment);
117
+ const inOrder = (a, b) => b.fragment.length - a.fragment.length || a.fragment.localeCompare(b.fragment)
98
118
 
99
- /** Every rule of `self/codes.ts` it breaks, as a line each; empty when it holds. */
119
+ /** Every rule of `src/codes.ts` it breaks, as a line each; empty when it holds. */
100
120
  const problems = (text) => {
101
- const found = [];
102
- const tables = [];
103
- for (const [name, perf] of [
104
- ["diagnosticRules", false],
105
- ["performanceRules", true],
106
- ]) {
107
- const body = tableText(text, name);
121
+ const found = []
122
+ const tables = []
123
+ for (const [name, owns, kind] of TABLES) {
124
+ const body = tableText(text, name)
108
125
  if (body === null) {
109
- found.push(`self/codes.ts has no \`${name}\` table`);
110
- continue;
126
+ found.push(`src/codes.ts has no \`${name}\` table`)
127
+ continue
111
128
  }
112
129
  try {
113
- const pairs = parseCodesRegistry(body, `self/codes.ts ${name}`);
114
- const literals = literalCount(body);
130
+ const pairs = parseCodesRegistry(body, `src/codes.ts ${name}`)
131
+ const literals = literalCount(body)
115
132
  if (literals !== 2 * pairs.length) {
116
133
  found.push(
117
134
  `\`${name}\` holds ${literals} strings and ${pairs.length} fragment/code pairs: a string ` +
118
135
  "without its other half shifts every later pairing `codeFor` makes"
119
- );
136
+ )
120
137
  }
121
- tables.push({ name, perf, pairs });
138
+ tables.push({ name, owns, kind, pairs })
122
139
  } catch (err) {
123
- found.push(err.message);
140
+ found.push(err.message)
124
141
  }
125
142
  }
126
- const all = tables.flatMap((t) => t.pairs);
143
+ const all = tables.flatMap((t) => t.pairs)
127
144
 
128
145
  // The tables are the whole registry: a pair the reader finds outside them is
129
146
  // one the compilers never match, and one of theirs the reader misses is one
130
- // `tests/diagnostic_coverage.js` never asks about.
131
- let whole = [];
147
+ // `tests/diagnostic-coverage.js` never asks about.
148
+ let whole = []
132
149
  try {
133
- whole = parseCodesRegistry(text, "self/codes.ts");
150
+ whole = parseCodesRegistry(text, "src/codes.ts")
134
151
  } catch (err) {
135
- found.push(err.message);
152
+ found.push(err.message)
136
153
  }
137
154
  if (whole.length !== all.length) {
138
- found.push(`self/codes.ts holds ${whole.length} pairs, ${all.length} of them inside the two tables`);
155
+ found.push(`src/codes.ts holds ${whole.length} pairs, ${all.length} of them inside the three tables`)
139
156
  }
140
157
 
141
- for (const { name, perf, pairs } of tables) {
158
+ const warningBands = TABLES.map(([, owns]) => owns).filter((band) => band !== null)
159
+ for (const { name, owns, pairs } of tables) {
142
160
  for (let i = 0; i < pairs.length; i++) {
143
- const { fragment, code } = pairs[i];
144
- const band = code[2];
145
- if (!BANDS.has(band)) found.push(`${code} is not in a table band (1, 2, 3, 4 or 9): ${JSON.stringify(fragment)}`);
146
- if (perf !== (band === "9")) {
147
- found.push(`${code} is in \`${name}\`, which holds ${perf ? "only" : "no"} NL9xxx codes`);
161
+ const { fragment, code } = pairs[i]
162
+ const band = code[2]
163
+ if (!BANDS.has(band)) {
164
+ found.push(`${code} is not in a table band (1, 2, 3, 4, 8 or 9): ${JSON.stringify(fragment)}`)
165
+ }
166
+ if (owns !== null && band !== owns) {
167
+ found.push(`${code} is in \`${name}\`, which holds only NL${owns}xxx codes`)
168
+ }
169
+ if (owns === null && warningBands.includes(band)) {
170
+ found.push(`${code} is in \`${name}\`, which holds no NL${band}xxx codes`)
148
171
  }
149
172
  if (fragment.trim().length < MIN_FRAGMENT) {
150
- found.push(`${code}'s fragment ${JSON.stringify(fragment)} is under ${MIN_FRAGMENT} characters`);
173
+ found.push(`${code}'s fragment ${JSON.stringify(fragment)} is under ${MIN_FRAGMENT} characters`)
151
174
  }
152
175
  if (i > 0 && inOrder(pairs[i - 1], pairs[i]) > 0) {
153
- found.push(`${code} is out of order in \`${name}\`: it has to come before ${pairs[i - 1].code}`);
176
+ found.push(`${code} is out of order in \`${name}\`: it has to come before ${pairs[i - 1].code}`)
154
177
  }
155
178
  }
156
179
  }
157
180
 
158
- const perfCodes = tables
159
- .filter((t) => t.perf)
160
- .flatMap((t) => t.pairs.map((p) => Number(p.code.slice(3))))
161
- .sort((a, b) => a - b);
162
- const gap = perfCodes.findIndex((n, i) => n !== i + 1);
163
- if (gap >= 0) {
164
- found.push(`\`performanceRules\` has no NL${9000 + gap + 1} in its place: its codes run from NL9001 with no gap`);
181
+ // A warning band is gap-free from its first number, so a missing one is a
182
+ // rule that lost its code rather than a number nobody took yet.
183
+ for (const { name, owns, kind, pairs } of tables) {
184
+ if (owns === null) {
185
+ continue
186
+ }
187
+ const numbers = pairs.map((p) => Number(p.code.slice(3))).sort((a, b) => a - b)
188
+ const gap = numbers.findIndex((n, i) => n !== i + 1)
189
+ if (gap >= 0) {
190
+ const base = Number(owns) * 1000
191
+ found.push(
192
+ `\`${name}\` has no NL${base + gap + 1} in its place: its ${kind} codes run from NL${base + 1} with no gap`
193
+ )
194
+ }
165
195
  }
166
196
 
167
197
  const seen = (key) => {
168
- const counts = new Map();
169
- for (const pair of all) counts.set(pair[key], (counts.get(pair[key]) ?? 0) + 1);
170
- return [...counts].filter(([, n]) => n > 1).map(([value]) => value);
171
- };
172
- for (const code of seen("code")) found.push(`${code} names two rules`);
173
- for (const fragment of seen("fragment")) found.push(`${JSON.stringify(fragment)} has two entries`);
174
-
175
- const count = /export const RULE_COUNT: i32 = (\d+);/.exec(text);
176
- if (count === null) found.push("self/codes.ts has no `RULE_COUNT`");
177
- else if (Number(count[1]) !== all.length) {
178
- found.push(`RULE_COUNT is ${count[1]} and the tables hold ${all.length} rules`);
198
+ const counts = new Map()
199
+ for (const pair of all) {
200
+ counts.set(pair[key], (counts.get(pair[key]) ?? 0) + 1)
201
+ }
202
+ return [...counts].filter(([, n]) => n > 1).map(([value]) => value)
203
+ }
204
+ for (const code of seen("code")) {
205
+ found.push(`${code} names two rules`)
206
+ }
207
+ for (const fragment of seen("fragment")) {
208
+ found.push(`${JSON.stringify(fragment)} has two entries`)
209
+ }
210
+
211
+ const count = /export const RULE_COUNT: i32 = (\d+);?$/m.exec(text)
212
+ if (count === null) {
213
+ found.push("src/codes.ts has no `RULE_COUNT`")
214
+ } else if (Number(count[1]) !== all.length) {
215
+ found.push(`RULE_COUNT is ${count[1]} and the tables hold ${all.length} rules`)
179
216
  }
180
217
 
181
- return { found, all };
182
- };
218
+ return { found, all }
219
+ }
183
220
 
184
221
  /** The next number nobody has held, per band -- what a hand-added rule takes. */
185
222
  const nextFree = (pairs) => {
186
- const highest = new Map();
223
+ const highest = new Map()
187
224
  for (const { code } of pairs) {
188
- const band = code[2];
189
- highest.set(band, Math.max(highest.get(band) ?? 0, Number(code.slice(3))));
225
+ const band = code[2]
226
+ highest.set(band, Math.max(highest.get(band) ?? 0, Number(code.slice(3))))
190
227
  }
191
- return [...BANDS].map((band) => `NL${band}${String((highest.get(band) ?? 0) + 1).padStart(3, "0")}`);
192
- };
228
+ return [...BANDS].map((band) => `NL${band}${String((highest.get(band) ?? 0) + 1).padStart(3, "0")}`)
229
+ }
193
230
 
194
- const { found, all } = problems(fs.readFileSync(REGISTRY, "utf8"));
195
- for (const problem of found) console.error(`error: ${problem}`);
231
+ const { found, all } = problems(fs.readFileSync(REGISTRY, "utf8"))
232
+ for (const problem of found) {
233
+ console.error(`error: ${problem}`)
234
+ }
196
235
  if (found.length === 0 && !process.argv.includes("--check")) {
197
- console.log(`self/codes.ts: ${all.length} rules, well-formed; next free: ${nextFree(all).join(" ")}`);
236
+ console.log(`src/codes.ts: ${all.length} rules, well-formed; next free: ${nextFree(all).join(" ")}`)
198
237
  }
199
- process.exit(found.length > 0 ? 1 : 0);
238
+ process.exit(found.length > 0 ? 1 : 0)
@@ -1,3 +1,4 @@
1
+ # shellcheck shell=bash
1
2
  # Sourced, not run: how a shell script turns a compiler path into a command.
2
3
  #
3
4
  # . scripts/nish-compiler.sh
@@ -9,6 +10,7 @@
9
10
  # scripts/bootstrap.sh and tests/self/seed.js follows as `NODE_ENTRY`, so one
10
11
  # path names a compiler the same way to every tool. Bash, because the answer is
11
12
  # an array: a compiler path with a space in it stays one word.
13
+ # shellcheck disable=SC2034 # `compiler` is the answer, read by the script that sourced this
12
14
  nish_compiler() {
13
15
  case "$1" in
14
16
  *.js | *.mjs | *.cjs) compiler=(node "$1") ;;