@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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/bin/launcher.js +186 -0
- package/bin/nish +23 -0
- package/bin/packaging.js +220 -0
- package/docs/AI.md +1076 -0
- package/docs/INSTALL.md +468 -0
- package/llms.txt +49 -0
- package/package.json +87 -0
- package/runtime/nish.d.ts +290 -0
- package/runtime/nish.h +340 -0
- package/runtime/nish.mjs +143 -0
- package/runtime/runtime.c +1184 -0
- package/runtime/runtime_os.c +351 -0
- package/runtime/runtime_parallel.c +156 -0
- package/runtime/runtime_wasm.c +99 -0
- package/runtime/shim.mjs +672 -0
- package/scripts/bootstrap.sh +357 -0
- package/scripts/build.sh +279 -0
- package/scripts/changelog-gen.mjs +528 -0
- package/scripts/changelog-section.sh +28 -0
- package/scripts/ci-profile.mjs +187 -0
- package/scripts/codes-registry.js +73 -0
- package/scripts/gen-diagnostic-codes.mjs +199 -0
- package/scripts/gen-pow5-tables.py +45 -0
- package/scripts/nish-compiler.sh +17 -0
- package/scripts/platform-package.mjs +91 -0
- package/scripts/postinstall.mjs +133 -0
- package/scripts/size-report.sh +72 -0
- package/scripts/smoke.sh +94 -0
- package/scripts/verify-binaries.sh +213 -0
- package/std/README.md +185 -0
- package/std/json.ts +402 -0
- package/std/pair.ts +28 -0
- package/std/testing.ts +347 -0
- package/std/text.ts +193 -0
|
@@ -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}`);
|