@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.
- package/README.md +27 -23
- package/bin/launcher.js +45 -41
- package/bin/packaging.js +39 -33
- package/docs/AI.md +157 -35
- package/docs/INSTALL.md +13 -13
- package/llms.txt +1 -1
- package/package.json +9 -6
- package/runtime/nish.d.ts +85 -1
- package/runtime/nish.h +62 -11
- package/runtime/nish.mjs +38 -3
- package/runtime/runtime-host.c +178 -0
- package/runtime/{runtime_os.c → runtime-os.c} +16 -1
- package/runtime/{runtime_parallel.c → runtime-parallel.c} +112 -4
- package/runtime/{runtime_wasm.c → runtime-wasm.c} +6 -1
- package/runtime/runtime.c +5 -5
- package/runtime/shim.mjs +128 -6
- package/scripts/bootstrap.sh +29 -29
- package/scripts/build.sh +19 -13
- package/scripts/changelog-gen.mjs +260 -192
- package/scripts/ci-profile.mjs +80 -68
- package/scripts/codes-registry.js +25 -13
- package/scripts/gen-diagnostic-codes.mjs +124 -85
- package/scripts/nish-compiler.sh +2 -0
- package/scripts/platform-package.mjs +26 -26
- package/scripts/postinstall.mjs +46 -36
- package/scripts/size-report.sh +3 -3
- package/scripts/smoke.sh +1 -1
- package/std/README.md +42 -2
- package/std/collections.ts +194 -188
- package/std/crypto/base64url.ts +145 -0
- package/std/crypto/ct.ts +64 -0
- package/std/crypto/hkdf.ts +118 -0
- package/std/crypto/hmac.ts +155 -0
- package/std/crypto/sha256.ts +444 -0
- package/std/crypto/sha512.ts +510 -0
- package/std/crypto/x25519.ts +494 -0
- package/std/json.ts +136 -136
- package/std/map.ts +9 -7
- package/std/pair.ts +2 -2
- package/std/testing.ts +67 -67
- package/std/text.ts +54 -54
- package/std/threads.ts +126 -38
package/scripts/ci-profile.mjs
CHANGED
|
@@ -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 =
|
|
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)
|
|
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)
|
|
171
|
-
|
|
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)
|
|
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)
|
|
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]
|
|
186
|
-
|
|
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
|
-
* `
|
|
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/
|
|
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.** `
|
|
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 =
|
|
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:
|
|
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
|
|
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 `
|
|
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/
|
|
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, `
|
|
28
|
-
* in the tables: `NL0000` (no rule
|
|
29
|
-
* `
|
|
30
|
-
* A performance fragment is in
|
|
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
|
-
* `
|
|
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 =
|
|
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
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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 `
|
|
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,
|
|
104
|
-
|
|
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(`
|
|
110
|
-
continue
|
|
126
|
+
found.push(`src/codes.ts has no \`${name}\` table`)
|
|
127
|
+
continue
|
|
111
128
|
}
|
|
112
129
|
try {
|
|
113
|
-
const pairs = parseCodesRegistry(body, `
|
|
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,
|
|
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/
|
|
131
|
-
let whole = []
|
|
147
|
+
// `tests/diagnostic-coverage.js` never asks about.
|
|
148
|
+
let whole = []
|
|
132
149
|
try {
|
|
133
|
-
whole = parseCodesRegistry(text, "
|
|
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(`
|
|
155
|
+
found.push(`src/codes.ts holds ${whole.length} pairs, ${all.length} of them inside the three tables`)
|
|
139
156
|
}
|
|
140
157
|
|
|
141
|
-
|
|
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))
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
found.push(
|
|
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)
|
|
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(`
|
|
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)
|
package/scripts/nish-compiler.sh
CHANGED
|
@@ -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") ;;
|