@amritk/nish 0.10.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.
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Where a CI job's wall clock actually goes, per check.
4
+ *
5
+ * node scripts/ci-profile.mjs profile `node tests/run.js`
6
+ * node scripts/ci-profile.mjs --top 60 more rows in each table
7
+ * node scripts/ci-profile.mjs --json the same numbers, machine-readable
8
+ * node scripts/ci-profile.mjs -- node tests/run.js --seed <nish>
9
+ * node scripts/ci-profile.mjs -- npm run test:nish
10
+ *
11
+ * Why this exists. `tests/run.js` is one process printing about nineteen
12
+ * hundred `PASS` lines, and a slow run says only that the total grew. Every
13
+ * attempt to find the slow part by hand ran into the trap that a filtered run
14
+ * is not a measurement (`.claude/testing.md`: a filtered count means nothing
15
+ * until `rm -rf build/test`, and fifteen `fs.existsSync` guards drop checks
16
+ * silently rather than skipping them). So this profiles the *unfiltered* run
17
+ * instead, from the outside, and attributes time to the checks that were
18
+ * actually printed. It needs no change to the suite and cannot alter what the
19
+ * suite proves.
20
+ *
21
+ * **How to read the numbers, including the one way they mislead.** A check's
22
+ * cost here is the gap between the previous printed line and its own, so the
23
+ * work is attributed to the line printed *after* it finished. That is the right
24
+ * answer for a check that does its work and then reports, which is every check
25
+ * in this suite. It is the wrong answer in one case worth naming: because
26
+ * `tests/run.js` drives 234 `spawnSync` calls, the event loop is blocked for
27
+ * the whole of each one and queued writes flush in bursts when it unblocks. So
28
+ * a *gap* is real, and the line it lands on is real, but the ordering of the
29
+ * lines inside one burst is not something to read a per-line cost out of. Look
30
+ * at the expensive rows, not at the cheap ones.
31
+ *
32
+ * The totals are wall clock on the machine this runs on. A `ubuntu-latest`
33
+ * runner measured about 1.34x this box on the same suite, so the shape
34
+ * transfers and the absolute seconds do not.
35
+ */
36
+ import { spawn } from "node:child_process";
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;
42
+
43
+ /**
44
+ * Everything after `--` is the command to profile. The default is the suite as
45
+ * CI runs it, minus the build: `npm test` would rebuild `dist/` first, and a
46
+ * `tsc` run is not what this is measuring.
47
+ */
48
+ const dashdash = argv.indexOf("--");
49
+ const command = dashdash >= 0 && argv.length > dashdash + 1 ? argv.slice(dashdash + 1) : ["node", "tests/run.js"];
50
+
51
+ /**
52
+ * One row per line the child printed, with `dt` the gap since the line before
53
+ * it. `text` keeps the line as it was printed so that a reader can find the
54
+ * check in the log, and `t` is kept as well as `dt` because "which minute of
55
+ * the run was this" is how a reader locates a slow block.
56
+ */
57
+ const rows = [];
58
+ const t0 = process.hrtime.bigint();
59
+ let last = 0;
60
+ let buffered = "";
61
+
62
+ 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
+ };
67
+
68
+ const onData = (data) => {
69
+ buffered += data;
70
+ let i;
71
+ while ((i = buffered.indexOf("\n")) >= 0) {
72
+ takeLine(buffered.slice(0, i));
73
+ buffered = buffered.slice(i + 1);
74
+ }
75
+ };
76
+
77
+ /**
78
+ * A heartbeat on stderr, because the tables below cannot be printed until the
79
+ * child has exited and `npm test` takes eleven minutes. It goes to stderr so
80
+ * that `--json` on stdout stays machine-readable, and it names the last check
81
+ * seen rather than only a count: that is what says whether a long silence is
82
+ * the oracles working or the run wedged.
83
+ */
84
+ const heartbeat = setInterval(() => {
85
+ const latest = rows.length > 0 ? rows[rows.length - 1].text.slice(0, 72) : "nothing printed yet";
86
+ const elapsed = (Number(process.hrtime.bigint() - t0) / 1e9).toFixed(0);
87
+ process.stderr.write(`[ci-profile] ${elapsed}s, ${rows.length} lines: ${latest}\n`);
88
+ }, 30_000);
89
+ heartbeat.unref();
90
+
91
+ const child = spawn(command[0], command.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
92
+ child.stdout.setEncoding("utf8");
93
+ child.stderr.setEncoding("utf8");
94
+ child.stdout.on("data", onData);
95
+ child.stderr.on("data", onData);
96
+
97
+ const status = await new Promise((resolve) => {
98
+ child.on("close", (code) => resolve(code ?? 1));
99
+ child.on("error", (err) => {
100
+ console.error(`could not run ${command.join(" ")}: ${err.message}`);
101
+ resolve(1);
102
+ });
103
+ });
104
+ clearInterval(heartbeat);
105
+ if (buffered.length > 0) takeLine(buffered);
106
+
107
+ /**
108
+ * A check's name up to its first colon, which is how this suite names a family:
109
+ * `link/argv_import: llvm-as accepts args.ll` and its six siblings are one
110
+ * fixture compiled once, and the family is what a reader can act on. A line
111
+ * with no colon is grouped under its first forty characters, which is enough to
112
+ * keep the oracles — each of which prints one long line — apart.
113
+ */
114
+ const familyOf = (text) => {
115
+ const body = text.replace(/^(PASS|FAIL|SKIP)\s+/, "");
116
+ const colon = body.indexOf(":");
117
+ return colon > 0 ? body.slice(0, colon) : body.slice(0, 40);
118
+ };
119
+
120
+ const families = new Map();
121
+ for (const row of rows) {
122
+ const key = familyOf(row.text);
123
+ const seen = families.get(key) ?? { ms: 0, lines: 0 };
124
+ seen.ms += row.dt;
125
+ seen.lines += 1;
126
+ families.set(key, seen);
127
+ }
128
+
129
+ const total = rows.length > 0 ? rows[rows.length - 1].t : 0;
130
+ const byCost = [...rows].sort((a, b) => b.dt - a.dt).slice(0, TOP);
131
+ const byFamily = [...families].sort((a, b) => b[1].ms - a[1].ms).slice(0, TOP);
132
+
133
+ if (asJson) {
134
+ console.log(
135
+ JSON.stringify(
136
+ {
137
+ command,
138
+ status,
139
+ totalMs: Math.round(total),
140
+ lines: rows.length,
141
+ checks: byCost.map((r) => ({ ms: Math.round(r.dt), atMs: Math.round(r.t), text: r.text })),
142
+ families: byFamily.map(([name, f]) => ({ name, ms: Math.round(f.ms), lines: f.lines })),
143
+ },
144
+ null,
145
+ 2
146
+ )
147
+ );
148
+ process.exit(status);
149
+ }
150
+
151
+ const secs = (ms) => `${(ms / 1000).toFixed(1)}s`.padStart(8);
152
+
153
+ console.log(`\n${command.join(" ")}`);
154
+ console.log(`exit ${status} — ${secs(total).trim()} of wall clock over ${rows.length} printed lines\n`);
155
+
156
+ /**
157
+ * The run's own verdict, echoed verbatim.
158
+ *
159
+ * A profile that swallowed this would be worse than useless here, because exit
160
+ * 0 is not the whole answer: `tests/run.js` reports a *skip count* beside the
161
+ * passes, and a green run with more skips than the last one proved less while
162
+ * looking the same (`.claude/orientation.md` calls reading the count rather than
163
+ * the exit status the thing to do). So the summary is printed before the
164
+ * timings, where it cannot be missed.
165
+ */
166
+ const verdict = rows.filter((r) => r.text.trim().length > 0).slice(-3);
167
+ if (verdict.length > 0) {
168
+ console.log("=== what the run itself reported ===");
169
+ for (const row of verdict) console.log(` ${row.text}`);
170
+ console.log("");
171
+ }
172
+
173
+ console.log(`=== the ${byCost.length} most expensive checks (cost = the gap before the line was printed) ===`);
174
+ for (const row of byCost) console.log(`${secs(row.dt)} at ${secs(row.t)} ${row.text.slice(0, 104)}`);
175
+
176
+ console.log(`\n=== the ${byFamily.length} most expensive families (cumulative) ===`);
177
+ for (const [name, f] of byFamily) console.log(`${secs(f.ms)} ${String(f.lines).padStart(5)} line(s) ${name.slice(0, 84)}`);
178
+
179
+ /**
180
+ * The share the expensive tail accounts for, because "the top ten are 70% of
181
+ * the run" is the sentence that decides whether to optimise a check or the
182
+ * shape of the job around it.
183
+ */
184
+ const topTen = [...rows].sort((a, b) => b.dt - a.dt).slice(0, 10).reduce((sum, r) => sum + r.dt, 0);
185
+ console.log(`\nthe ten most expensive checks are ${((topTen / total) * 100).toFixed(0)}% of the run.`);
186
+
187
+ process.exit(status);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The diagnostic-code registry, read back out of a `codes.ts`.
3
+ *
4
+ * `self/codes.ts` holds the table -- a fragment line, then the `NL####` line
5
+ * that names its rule. Three places in this repository read it back:
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`,
8
+ * which asks which codes the suite reaches; and the `codes:` checks in
9
+ * `tests/run.js`. This module is that parse, once, so the copies cannot drift
10
+ * apart again ([issue #96](https://github.com/amritk/nish/issues/96)).
11
+ *
12
+ * It lives here rather than under `tests/` for two reasons. The format was
13
+ * written by `rows()` in the generator next door until the registry was
14
+ * frozen and kept by hand (R6), and #96 was the writer and the readers
15
+ * drifting apart -- WP22 stage C changed what the emitter wrote and no reader
16
+ * followed -- so the reader belongs beside the checker. And `scripts/` ships in
17
+ * the npm tarball while `tests/` does not, so a shared module under `tests/`
18
+ * would leave the shipped checker importing a path the package cannot
19
+ * resolve, which `tests/run.js` refuses
20
+ * (`npm pack ships no script whose imports it cannot resolve`).
21
+ *
22
+ * Two properties are the whole point of having it, and both are here because
23
+ * they have failed:
24
+ *
25
+ * - **The indentation is not part of the contract.** `self/codes.ts` lost a
26
+ * level when WP22 stage C rewrote its tables as arrows with concise
27
+ * bodies, and every reader keyed on four literal spaces then read it as
28
+ * *empty* rather than as changed. `^\s+` is what a pair is recognised by.
29
+ * - **Nothing is an empty registry.** A reader that answers `[]` for a file
30
+ * whose shape has moved makes "every code is covered" and "there are no
31
+ * codes" the same answer, which is what let the first instance of this
32
+ * survive unnoticed. This one raises instead.
33
+ */
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+
37
+ const root = path.resolve(import.meta.dirname, "..");
38
+
39
+ /**
40
+ * One entry of the emitted table: the quoted fragment on its own line, then
41
+ * the code on the next. Anchored to the line rather than to a column, for the
42
+ * reason in the header.
43
+ */
44
+ const PAIR = /^\s+("(?:[^"\\]|\\.)*"),\n\s+"(NL\d{4})",$/gm;
45
+
46
+ /**
47
+ * Every `{ fragment, code }` of one registry file, in the order the file holds
48
+ * them -- which is the order the compilers match in, longest fragment first,
49
+ * so a caller comparing two registries is comparing their behaviour and not
50
+ * just their contents.
51
+ *
52
+ * Pairs rather than a `Map` because the three callers key it three different
53
+ * ways, and because a `Map` would quietly swallow a duplicated fragment that a
54
+ * caller may want to see.
55
+ *
56
+ * Throws when it parses nothing: an unreadable registry is a broken one, and
57
+ * the one thing it may not do is pass for an empty one. `label` is what that
58
+ * message names the text by.
59
+ */
60
+ export const parseCodesRegistry = (text, label) => {
61
+ const pairs = [];
62
+ for (const m of text.matchAll(PAIR)) {
63
+ pairs.push({ fragment: JSON.parse(m[1]), code: m[2] });
64
+ }
65
+ if (pairs.length === 0) {
66
+ throw new Error(`${label}: no diagnostic codes parsed -- the registry's shape has moved`);
67
+ }
68
+ return pairs;
69
+ };
70
+
71
+ /** The same, for a registry on disk. The three callers all read a file. */
72
+ export const readCodesRegistry = (file) =>
73
+ parseCodesRegistry(fs.readFileSync(file, "utf8"), path.relative(root, file));
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Check the stable diagnostic-code registry: the `code` field of `--json`.
4
+ *
5
+ * node scripts/gen-diagnostic-codes.mjs --check exit 1 if the registry is malformed
6
+ * node scripts/gen-diagnostic-codes.mjs the same, then the next free code per band
7
+ * node scripts/gen-diagnostic-codes.mjs --check F check the registry in file F instead
8
+ *
9
+ * The third form is for `tests/run.js`, which hands it a copy of the registry
10
+ * with one line broken to show that each rule below is really enforced.
11
+ *
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
15
+ * 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
+ * registry. A new diagnostic gets its code by hand: append a fragment and the
18
+ * 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
+ * is what notices a diagnostic that has no code, by counting `NL0000`.
21
+ *
22
+ * What `--check` still holds, because each is what makes a code worth keying
23
+ * on:
24
+ *
25
+ * - **Every number is well-formed and in its band.** `NL1xxx` Phase 0,
26
+ * `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.
31
+ * - **Nothing is used twice.** A number handed out once is never handed to a
32
+ * different rule, and a retired rule keeps its entry -- it matches nothing,
33
+ * so carrying it costs a string -- precisely so that its number stays
34
+ * reserved. Two entries for one fragment would make the second dead.
35
+ * - **Longest fragment first**, ties in `localeCompare` order, the order the
36
+ * generator wrote: the compilers take the first fragment the message
37
+ * contains, so a specific rule has to come before a general one it
38
+ * contains ("Cannot assign to `length` of " before "Cannot assign ").
39
+ * - **No fragment too short to identify a rule** (ten characters, trimmed),
40
+ * which would match half the suite.
41
+ * - **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
43
+ * by two, so a fragment added without its code line -- or a code without
44
+ * its fragment -- shifts every pairing after it, and from there on
45
+ * messages get their neighbour's code. The pair reader cannot see that:
46
+ * it matches a fragment line followed by a code line, and a stray line is
47
+ * simply not a match. So each table's strings are counted on their own
48
+ * and have to come to twice its pairs ([#107](https://github.com/amritk/nish/issues/107)).
49
+ * - **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.
51
+ * - **`RULE_COUNT` is the number of entries.**
52
+ *
53
+ * A fragment is the longest literal run of its message's template -- the rule
54
+ * in words, with every interpolated name, type and count removed -- matched as
55
+ * a substring. Cutting at the interpolations is also what keeps the project's
56
+ * name out of the table: "... is forbidden in ${LANGUAGE}" contributes the run
57
+ * before the name, never the name, so `branding.ts` stays the only place it is
58
+ * spelled (rule 5 in `.claude/orientation.md`). Keep to that when adding one.
59
+ */
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
+
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");
67
+
68
+ /** 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"]);
70
+
71
+ /** Shorter than this, a fragment would match half the suite. */
72
+ const MIN_FRAGMENT = 10;
73
+
74
+ /**
75
+ * The text of one table in `self/codes.ts`: from its `name = (): string[] => [`
76
+ * to the `];` that closes it. Parsed per table, because the table a fragment
77
+ * sits in is part of what it means -- a performance rule is matched only
78
+ * against a performance message.
79
+ */
80
+ 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
+ };
86
+
87
+ /**
88
+ * How many strings one table holds: every literal after the line that opens
89
+ * it. That is what `codeFor` steps through two at a time, so it is counted on
90
+ * its own rather than read off the pairs -- a stray literal is exactly what
91
+ * the pair reader skips.
92
+ */
93
+ const STRING = /"(?:[^"\\]|\\.)*"/g;
94
+ const literalCount = (body) => body.slice(body.indexOf("\n")).match(STRING)?.length ?? 0;
95
+
96
+ /** 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);
98
+
99
+ /** Every rule of `self/codes.ts` it breaks, as a line each; empty when it holds. */
100
+ 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);
108
+ if (body === null) {
109
+ found.push(`self/codes.ts has no \`${name}\` table`);
110
+ continue;
111
+ }
112
+ try {
113
+ const pairs = parseCodesRegistry(body, `self/codes.ts ${name}`);
114
+ const literals = literalCount(body);
115
+ if (literals !== 2 * pairs.length) {
116
+ found.push(
117
+ `\`${name}\` holds ${literals} strings and ${pairs.length} fragment/code pairs: a string ` +
118
+ "without its other half shifts every later pairing `codeFor` makes"
119
+ );
120
+ }
121
+ tables.push({ name, perf, pairs });
122
+ } catch (err) {
123
+ found.push(err.message);
124
+ }
125
+ }
126
+ const all = tables.flatMap((t) => t.pairs);
127
+
128
+ // The tables are the whole registry: a pair the reader finds outside them is
129
+ // 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 = [];
132
+ try {
133
+ whole = parseCodesRegistry(text, "self/codes.ts");
134
+ } catch (err) {
135
+ found.push(err.message);
136
+ }
137
+ if (whole.length !== all.length) {
138
+ found.push(`self/codes.ts holds ${whole.length} pairs, ${all.length} of them inside the two tables`);
139
+ }
140
+
141
+ for (const { name, perf, pairs } of tables) {
142
+ 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`);
148
+ }
149
+ if (fragment.trim().length < MIN_FRAGMENT) {
150
+ found.push(`${code}'s fragment ${JSON.stringify(fragment)} is under ${MIN_FRAGMENT} characters`);
151
+ }
152
+ 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}`);
154
+ }
155
+ }
156
+ }
157
+
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`);
165
+ }
166
+
167
+ 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`);
179
+ }
180
+
181
+ return { found, all };
182
+ };
183
+
184
+ /** The next number nobody has held, per band -- what a hand-added rule takes. */
185
+ const nextFree = (pairs) => {
186
+ const highest = new Map();
187
+ for (const { code } of pairs) {
188
+ const band = code[2];
189
+ highest.set(band, Math.max(highest.get(band) ?? 0, Number(code.slice(3))));
190
+ }
191
+ return [...BANDS].map((band) => `NL${band}${String((highest.get(band) ?? 0) + 1).padStart(3, "0")}`);
192
+ };
193
+
194
+ const { found, all } = problems(fs.readFileSync(REGISTRY, "utf8"));
195
+ for (const problem of found) console.error(`error: ${problem}`);
196
+ if (found.length === 0 && !process.argv.includes("--check")) {
197
+ console.log(`self/codes.ts: ${all.length} rules, well-formed; next free: ${nextFree(all).join(" ")}`);
198
+ }
199
+ process.exit(found.length > 0 ? 1 : 0);
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env python3
2
+ """Regenerate the two power-of-five tables `runtime/runtime.c` uses for Ryu.
3
+
4
+ python3 scripts/gen-pow5-tables.py
5
+
6
+ Prints the C to stdout; paste it over the `NISH_POW5_INV_SPLIT` and
7
+ `NISH_POW5_SPLIT` definitions. The point of having this in the tree is that
8
+ the ~10 KB of constants in the runtime are *derived*, with exact integer
9
+ arithmetic, rather than transcribed from somewhere -- so a reader can check
10
+ them rather than trust them. See docs/wp15-performance.md section 7a.
11
+ """
12
+ INV_BITCOUNT = 125
13
+ BITCOUNT = 125
14
+ M = (1 << 64) - 1
15
+
16
+ def pow5bits(i):
17
+ return (5 ** i).bit_length()
18
+
19
+ inv = []
20
+ for i in range(292):
21
+ p = 5 ** i
22
+ j = p.bit_length() - 1 + INV_BITCOUNT
23
+ v = (1 << j) // p + 1
24
+ assert v >> 128 == 0, i
25
+ inv.append((v & M, v >> 64))
26
+
27
+ spl = []
28
+ for i in range(326):
29
+ p = 5 ** i
30
+ j = p.bit_length() - BITCOUNT
31
+ v = p << -j if j < 0 else p >> j
32
+ assert v >> 128 == 0, i
33
+ spl.append((v & M, v >> 64))
34
+
35
+ def emit(name, rows):
36
+ out = [f"static const uint64_t {name}[{len(rows)}][2] = {{"]
37
+ for lo, hi in rows:
38
+ out.append(f" {{ {lo}u, {hi}u }},")
39
+ out.append("};")
40
+ return "\n".join(out)
41
+
42
+ print("/* Generated by scripts/gen-pow5-tables.py; do not hand-edit. */")
43
+ print(emit("NISH_POW5_INV_SPLIT", inv))
44
+ print()
45
+ print(emit("NISH_POW5_SPLIT", spl))
@@ -0,0 +1,17 @@
1
+ # Sourced, not run: how a shell script turns a compiler path into a command.
2
+ #
3
+ # . scripts/nish-compiler.sh
4
+ # nish_compiler "${NISH:-build/nish}"
5
+ # "${compiler[@]}" program.ts -o out.ll
6
+ #
7
+ # A Node entry point (.js, .mjs, .cjs) is run under node and anything else --
8
+ # a native `nish` -- directly. That is the rule `NISH_BOOTSTRAP` follows in
9
+ # scripts/bootstrap.sh and tests/self/seed.js follows as `NODE_ENTRY`, so one
10
+ # path names a compiler the same way to every tool. Bash, because the answer is
11
+ # an array: a compiler path with a space in it stays one word.
12
+ nish_compiler() {
13
+ case "$1" in
14
+ *.js | *.mjs | *.cjs) compiler=(node "$1") ;;
15
+ *) compiler=("$1") ;;
16
+ esac
17
+ }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Turn a staged release directory into a publishable platform package.
4
+ *
5
+ * node scripts/platform-package.mjs <stage-dir> <asset> [--version <v>]
6
+ *
7
+ * `release.yml`'s `binaries` job already stages exactly what one of these
8
+ * needs, because the release tarball and the npm package want the same thing:
9
+ * `bin/nish` beside the `runtime/` and `scripts/` it resolves one level up
10
+ * from itself. So this script adds a `package.json` to that directory and
11
+ * changes nothing else — `npm publish <stage-dir>` then uploads the same bytes
12
+ * the tarball carries, built and smoke-tested by the steps above it.
13
+ *
14
+ * The package declares `os` and `cpu`, which is the whole mechanism: it is an
15
+ * `optionalDependencies` entry of the main package, so npm installs the one
16
+ * that matches this machine and skips the others without failing. Nothing here
17
+ * is compiled on the way in.
18
+ *
19
+ * It deliberately declares no `bin`. The `nish` command belongs to the main
20
+ * package's launcher, which is the only thing that knows which platform's
21
+ * binary to hand over to -- and, since 0.6.0, the only thing that knows how to
22
+ * say so when none was installed; two packages claiming one command name would
23
+ * leave which binary wins up to npm's link order.
24
+ */
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { targetForAsset } from "../bin/packaging.js";
28
+
29
+ const args = process.argv.slice(2);
30
+ const flag = (name) => {
31
+ const i = args.indexOf(name);
32
+ return i < 0 ? null : args[i + 1];
33
+ };
34
+ const positional = args.filter((a, i) => !a.startsWith("--") && !(args[i - 1] ?? "").startsWith("--"));
35
+ const [stageDir, asset] = positional;
36
+
37
+ if (stageDir === undefined || asset === undefined) {
38
+ console.error("usage: platform-package.mjs <stage-dir> <asset> [--version <v>]");
39
+ process.exit(2);
40
+ }
41
+
42
+ const root = path.resolve(import.meta.dirname, "..");
43
+ const main = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
44
+ const version = flag("--version") ?? main.version;
45
+
46
+ const target = targetForAsset(asset);
47
+ if (target === null) {
48
+ console.error(`platform-package.mjs: ${asset} is not a platform this project builds for`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const binary = path.join(stageDir, "bin", "nish");
53
+ if (!fs.existsSync(binary)) {
54
+ console.error(`platform-package.mjs: ${binary} does not exist; stage the binary before packaging it`);
55
+ process.exit(1);
56
+ }
57
+
58
+ const pretty = { linux: "Linux", darwin: "macOS" }[target.os] ?? target.os;
59
+ const chip = { x64: "x86_64", arm64: "ARM64" }[target.cpu] ?? target.cpu;
60
+
61
+ const manifest = {
62
+ name: `${main.name}-${asset}`,
63
+ version,
64
+ description: `The ${main.name} native compiler for ${pretty} on ${chip}`,
65
+ license: main.license,
66
+ os: [target.os],
67
+ cpu: [target.cpu],
68
+ // `./package.json` is how the launcher finds this package: it resolves the
69
+ // manifest and joins `bin/nish` to its directory. Everything else is read by
70
+ // the binary itself, relative to its own path, never through a specifier.
71
+ exports: { "./package.json": "./package.json" },
72
+ // `std` is in this list for the reason `runtime` is, and it was missing until
73
+ // 2026-09-20: the compiler resolves a `nish/<module>` specifier against its
74
+ // own package root, so a platform package without it answers every one of
75
+ // them with ``Module `nish/text` is not part of the standard library``, while
76
+ // naming `text` among the modules it has. The message is the static list of
77
+ // module names; what is absent is the file. Nothing caught it because every
78
+ // program the release smoke-tests imports by relative path or not at all,
79
+ // and because the pack-and-install round trip in `tests/run.js` packs the
80
+ // MAIN package, which has carried `std` in its own `files` all along --
81
+ // so the fallback to `dist/` worked and the path a user actually gets did
82
+ // not. See docs/wp12-release.md and wp19 §5a.
83
+ files: ["bin", "runtime", "scripts", "std", "LICENSE", "INSTALL.md"],
84
+ repository: main.repository,
85
+ bugs: main.bugs,
86
+ homepage: main.homepage,
87
+ };
88
+
89
+ const out = path.join(stageDir, "package.json");
90
+ fs.writeFileSync(out, `${JSON.stringify(manifest, null, 2)}\n`);
91
+ console.log(`${manifest.name}@${version} (os ${target.os}, cpu ${target.cpu}) -> ${out}`);