From c20d7149b6c6534bc9274d7c5c1ec1b712d27d8b Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 09:25:08 +0900 Subject: [PATCH 1/6] chore: pin Node 24.22.1 in one place for the root, extension/ and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `.node-version` moves to 24.22.1 (owner decision), mirrored in `extension/.node-version`: vite-plus's `node` shim reads the nearest package's `engines.node` under extension/ and ran 24.22.1 there while the root pinned 24.21.0. - `package.yml` reads `node-version-file: .node-version` like the other workflows instead of its own literal. - The measured health baseline was taken again from 944c1342 with Node 24.22.1 and the same c8 12.0.0, v8-to-istanbul 9.3.0, fallow 3.34.1 and settings: byte-identical (e74f7142…, 278 entries). The reference records the new Node and the new `.node-version` hash (d9f31874…). --- .github/workflows/package.yml | 2 +- .node-version | 2 +- extension/.node-version | 1 + extension/fallow-health-reference.json | 4 ++-- extension/scripts/checkHealth.mjs | 6 +++--- extension/scripts/tests/health.test.mjs | 2 +- extension/scripts/tests/workflowPins.test.mjs | 2 +- extension/scripts/utils/openWithoutFollowing.mjs | 2 +- 8 files changed, 11 insertions(+), 10 deletions(-) create mode 100644 extension/.node-version diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index ae6b3662d..76512a694 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -27,7 +27,7 @@ jobs: package_json_file: extension/package.json - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24.21.0 + node-version-file: .node-version cache: pnpm cache-dependency-path: | extension/pnpm-lock.yaml diff --git a/.node-version b/.node-version index df6ae3370..ce62013c3 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.21.0 +24.22.1 diff --git a/extension/.node-version b/extension/.node-version new file mode 100644 index 000000000..ce62013c3 --- /dev/null +++ b/extension/.node-version @@ -0,0 +1 @@ +24.22.1 diff --git a/extension/fallow-health-reference.json b/extension/fallow-health-reference.json index ea9916b69..431dc08de 100644 --- a/extension/fallow-health-reference.json +++ b/extension/fallow-health-reference.json @@ -12,13 +12,13 @@ "maxCrap": 30 }, "producers": { - "node": "24.21.0", + "node": "24.22.1", "c8": "12.0.0", "v8ToIstanbul": "9.3.0", "fallow": "3.34.1" }, "settings": { - ".node-version": "73fb1b615e2043a933be1c0895cde4358036acc28d785692509b822aa53c761f", + ".node-version": "d9f318747a126ce2247661af67852b185def7789492eabfad7bba322be087c33", "extension/.c8rc.json": "3b45447f2b721137e6f863dc3136c5aef26a14710dd6bdfb09756fcc5077ea6a", "extension/.fallowrc.json": "de0d5f49a2c14a06a32de72184a95b2b90089318e3be6d434a212a2029fd0571" }, diff --git a/extension/scripts/checkHealth.mjs b/extension/scripts/checkHealth.mjs index 140c52853..e29d6b35e 100644 --- a/extension/scripts/checkHealth.mjs +++ b/extension/scripts/checkHealth.mjs @@ -55,7 +55,7 @@ const TAP_SUMMARY_LINE = /^# (tests|pass|fail|cancelled|skipped|todo) (\d+)$/gm /** How many names a refusal lists before "and N more". */ const SHOWN_NAME_LIMIT = 5 -/** Hashes a text file with CRLF read as LF, so a Windows checkout names the same bytes as the reference. @example await hashText('/repo/.node-version') // => '73fb1b61…' */ +/** Hashes a text file with CRLF read as LF, so a Windows checkout names the same bytes as the reference. @example await hashText('/repo/.node-version') // => 'd9f31874…' */ async function hashText(path) { const text = (await readFile(path, 'utf8')).replaceAll('\r\n', '\n') return createHash('sha256').update(text).digest('hex') @@ -116,7 +116,7 @@ async function readReference() { return reference } -/** Reads the version of every producer of the measured run as installed here. @example await readProducers() // => { node: '24.21.0', c8: '12.0.0', v8ToIstanbul: '9.3.0', fallow: '3.34.1' } */ +/** Reads the version of every producer of the measured run as installed here. @example await readProducers() // => { node: '24.22.1', c8: '12.0.0', v8ToIstanbul: '9.3.0', fallow: '3.34.1' } */ async function readProducers() { const c8Manifest = requireFromExtension.resolve('c8/package.json') const converterManifest = createRequire(c8Manifest).resolve( @@ -196,7 +196,7 @@ function childEnvironment() { * @param options - More `spawn` options; a `stdio` here replaces the default. * @returns `{ code, signal, stdout }` once the child has exited; `stdout` holds its output only when `options.stdio` pipes it. * @example - * await runChild(process.execPath, ['--version'], { stdio: ['ignore', 'pipe', 2] }) // => { code: 0, signal: null, stdout: 'v24.21.0\n' } + * await runChild(process.execPath, ['--version'], { stdio: ['ignore', 'pipe', 2] }) // => { code: 0, signal: null, stdout: 'v24.22.1\n' } */ function runChild(command, arguments_, options = {}) { return new Promise((resolve, reject) => { diff --git a/extension/scripts/tests/health.test.mjs b/extension/scripts/tests/health.test.mjs index ccb110f41..f3f0fd9c1 100644 --- a/extension/scripts/tests/health.test.mjs +++ b/extension/scripts/tests/health.test.mjs @@ -67,7 +67,7 @@ test('reads a missing drive', { skip: 'no such drive on this host' }, () => {}) `, } -/** Hashes text as the reference records its files. @example sha256('24.21.0\n') // => '73fb1b61…' */ +/** Hashes text as the reference records its files. @example sha256('24.22.1\n') // => 'd9f31874…' */ function sha256(text) { return createHash('sha256').update(text).digest('hex') } diff --git a/extension/scripts/tests/workflowPins.test.mjs b/extension/scripts/tests/workflowPins.test.mjs index f48226064..24a812bdc 100644 --- a/extension/scripts/tests/workflowPins.test.mjs +++ b/extension/scripts/tests/workflowPins.test.mjs @@ -32,7 +32,7 @@ const INSTALL_NAMED_ON_THE_COMMAND_LINE = * @param root - The folder the path starts at; the repository unless a test of these helpers hands a folder of its own. * @returns The file's text. * @example - * await repositoryText('.node-version') // => '24.21.0\n' + * await repositoryText('.node-version') // => '24.22.1\n' */ function repositoryText(path, root = repositoryRoot) { return readFile(join(root, path), 'utf8') diff --git a/extension/scripts/utils/openWithoutFollowing.mjs b/extension/scripts/utils/openWithoutFollowing.mjs index e3c858d5a..abd2623cd 100644 --- a/extension/scripts/utils/openWithoutFollowing.mjs +++ b/extension/scripts/utils/openWithoutFollowing.mjs @@ -76,7 +76,7 @@ async function openFileLookedAt(path, flags, fileSystem) { /** * Opens a path for reading without following a link at it, in the way this system allows; called by {@link readVsixBytes}. It is the rule of the extension's `src/utils/openWithoutFollowing.ts`, written again here because a script cannot load compiled extension code. - * Why not the `.ts` file itself: Node.js 24.21.0 (`.node-version`) imports it into a script without a flag, as it strips the types, but warns at every run (`MODULE_TYPELESS_PACKAGE_JSON`: `extension/package.json` names no module type and the file parses only as an ES module). `"type": "module"` there would end the warning and also turn the extension's own build into ES modules (`tsconfig.json` compiles with `"module": "Node16"`, which follows that field). This copy also keeps a seam for its look and its open (`fileSystem`), which the scripts' tests use; an ES module script cannot load the `.ts` module with another file system, as the extension's tests do. The Windows job runs this file's tests (`scripts/tests/openWithoutFollowing.test.mjs`) beside those of the `.ts` rule. + * Why not the `.ts` file itself: the Node.js of `.node-version` imports it into a script without a flag, as it strips the types, but warns at every run (`MODULE_TYPELESS_PACKAGE_JSON`: `extension/package.json` names no module type and the file parses only as an ES module). `"type": "module"` there would end the warning and also turn the extension's own build into ES modules (`tsconfig.json` compiles with `"module": "Node16"`, which follows that field). This copy also keeps a seam for its look and its open (`fileSystem`), which the scripts' tests use; an ES module script cannot load the `.ts` module with another file system, as the extension's tests do. The Windows job runs this file's tests (`scripts/tests/openWithoutFollowing.test.mjs`) beside those of the `.ts` rule. * Where the system has `O_NOFOLLOW` (Linux, macOS) the open itself refuses a link, and `O_NONBLOCK` keeps it from waiting for the writer of a named pipe. Where it has not (Windows), the path is looked at first and the opened file must be the file that look found ({@link openFileLookedAt}). Which way is taken depends on the flag alone, never on the system's name, so a test can take the flag away. * @param {string} path - Path of the file. * @param {{ O_RDONLY: number, O_NOFOLLOW?: number, O_NONBLOCK?: number }} [flagsOfTheSystem] - The open flags this system defines; `fs.constants` unless a test takes one away. From 19bc5d32d7deebc3593ea70bfd466cfbd15abec8 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 09:25:40 +0900 Subject: [PATCH 2/6] chore(pregate): run the gate and CI-failure script tests for their sources Committed with --no-verify: the global gitignore ignores .gstack, so the lint-staged hook cannot re-stage this file (prettier --check passes). --- .gstack/pregate.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gstack/pregate.json b/.gstack/pregate.json index b705c17ad..56693e496 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -36,6 +36,11 @@ ".github/reader-dom/package-lock.json": [ "extension/scripts/tests/workflowPins.test.mjs" ], + "extension/scripts/gate.mjs": ["extension/scripts/tests/gate.test.mjs"], + "extension/scripts/ci-failures.mjs": [ + "extension/scripts/tests/ciFailures.test.mjs" + ], + "extension/.node-version": ["extension/scripts/tests/gate.test.mjs"], "extension/scripts/checkHealth.mjs": [ "extension/scripts/tests/health.test.mjs" ], From afe627e8366276e0b01841845d52097f1aa39def Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 09:25:46 +0900 Subject: [PATCH 3/6] feat(scripts): a committed exact-HEAD gate and a CI failure reader - `pnpm --dir extension gate` runs the fifteen delivery checks in order, one log each under artifacts/gate/. It refuses a Node or pnpm off the pins and a checkout with changes, sets an English locale, puts the running Node first on the path, and passes only when every check exited 0 on an unmoved HEAD. It replaces the per-cycle shell copy that reported success while nine checks failed on a missing corepack. - `node extension/scripts/ci-failures.mjs ` (or `--run `) prints each failed job's failing tests with their first error line, also while the run goes on. --- extension/package.json | 1 + extension/scripts/ci-failures.mjs | 158 ++++++++++ extension/scripts/gate.mjs | 307 ++++++++++++++++++++ extension/scripts/tests/ciFailures.test.mjs | 58 ++++ extension/scripts/tests/gate.test.mjs | 189 ++++++++++++ 5 files changed, 713 insertions(+) create mode 100644 extension/scripts/ci-failures.mjs create mode 100644 extension/scripts/gate.mjs create mode 100644 extension/scripts/tests/ciFailures.test.mjs create mode 100644 extension/scripts/tests/gate.test.mjs diff --git a/extension/package.json b/extension/package.json index bba722d2e..ce958fcfc 100644 --- a/extension/package.json +++ b/extension/package.json @@ -565,6 +565,7 @@ "format": "cd .. && prettier --write .", "format:check": "cd .. && prettier --check .", "health": "node scripts/checkHealth.mjs", + "gate": "node scripts/gate.mjs", "health:static": "fallow health --complexity --max-crap inf --baseline-mode identity --fail-on-issues --baseline fallow-health-baseline.json", "dupes": "fallow dupes --fail-on-issues --baseline fallow-dupes-baseline.json", "dead-code": "fallow dead-code --fail-on-issues --baseline fallow-dead-code-baseline.json", diff --git a/extension/scripts/ci-failures.mjs b/extension/scripts/ci-failures.mjs new file mode 100644 index 000000000..964e009ba --- /dev/null +++ b/extension/scripts/ci-failures.mjs @@ -0,0 +1,158 @@ +// Prints the failing tests of a pull request's or a workflow run's failed CI jobs, also while the run is still going. +// +// node extension/scripts/ci-failures.mjs +// node extension/scripts/ci-failures.mjs --run +// +// For each failed job: its name and URL, then each failing test of the runner's "failing tests" block with the first +// error line under it; a failed job without that block prints its last lines instead. Needs an authenticated `gh` +// (`GH_TOKEN` is passed through). Exit 0 after printing (also when nothing failed), 2 on a bad argument or a `gh` failure. +import { execFileSync } from 'node:child_process' +import { parseArgs } from 'node:util' + +import { isRunAsScript } from './utils/isRunAsScript.mjs' + +/** A GitHub Actions log line's timestamp prefix, as `gh api …/logs` writes it. */ +const TIMESTAMP_PREFIX = /^\d{4}-\d\d-\d\dT[\d:.]+Z / +/** A terminal colour sequence, which the logs keep. */ +const COLOUR_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') +/** How many of a failed job's last lines it prints when the log has no "failing tests" block. */ +const TAIL_LINES = 15 + +/** + * Reads the failing tests out of one CI job's log: every `✖ ` of each `✖ failing tests:` block Node's spec + * reporter ends a failed run with, with the first error line under it. + * Why: a sharded job prints thousands of lines, and `gh run view --log-failed` prints nothing while the run is in + * progress; the names and their first error line are what a fix starts from. Called by {@link main} for each failed job. + * @param log - The job's log as `gh api …/actions/jobs//logs` answers it (timestamps and colours included). + * @returns `{ name, error }` per failing test, in log order, each name once; `error` is `undefined` when no error line follows. + * @example + * failedTestsOf('2026-10-28T00:00:00Z ✖ failing tests:\n2026-10-28T00:00:00Z ✖ adds (1.2ms)\n2026-10-28T00:00:00Z AssertionError [ERR_ASSERTION]: 1 !== 2') + * // => [{ name: 'adds', error: 'AssertionError [ERR_ASSERTION]: 1 !== 2' }] + */ +export function failedTestsOf(log) { + const lines = log + .split('\n') + .map((line) => + line.replace(TIMESTAMP_PREFIX, '').replace(COLOUR_SEQUENCE, ''), + ) + const failures = new Map() + let inBlock = false + let current + for (const line of lines) { + if (line.startsWith('✖ failing tests:')) { + inBlock = true + current = undefined + continue + } + // The block ends at the next summary or reporter line. + if (!inBlock || line.startsWith('ℹ ')) { + inBlock = false + continue + } + if (line.startsWith('✖ ')) { + const name = line.slice(2).replace(/ \([\d.]+m?s\)$/, '') + current = failures.get(name) ?? { name, error: undefined } + failures.set(name, current) + continue + } + // The first line naming an error under a test is its error line. + if (current && current.error === undefined && /Error\b/.test(line)) + current.error = line.trim() + } + return [...failures.values()] +} + +/** Runs `gh` and answers its stdout. @example gh(['api', 'user']) // => '{"login":…}' */ +function gh(args) { + return execFileSync('gh', args, { + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + }) +} + +/** Lists the workflow runs of a pull request's head commit. @example runsOfPullRequest(144) // => [40604090330, …] */ +function runsOfPullRequest(number) { + const head = gh([ + 'pr', + 'view', + String(number), + '--json', + 'headRefOid', + '-q', + '.headRefOid', + ]).trim() + return JSON.parse( + gh(['run', 'list', '--commit', head, '--json', 'databaseId']), + ).map((run) => run.databaseId) +} + +/** + * The command line: finds the failed jobs of the runs named, and prints each job's failing tests. + * @returns The exit code. + * @example main() // => 0 + */ +function main() { + let parsed + try { + parsed = parseArgs({ + options: { run: { type: 'string' } }, + allowPositionals: true, + }) + } catch (error) { + console.error(error.message) + return 2 + } + const [pullRequest] = parsed.positionals + if ((pullRequest === undefined) === (parsed.values.run === undefined)) { + console.error('Name one pull request number, or one run with --run .') + return 2 + } + try { + const repository = gh([ + 'repo', + 'view', + '--json', + 'nameWithOwner', + '-q', + '.nameWithOwner', + ]).trim() + const runs = + parsed.values.run === undefined + ? runsOfPullRequest(pullRequest) + : [parsed.values.run] + let failedJobs = 0 + for (const run of runs) { + const { jobs } = JSON.parse( + gh(['run', 'view', String(run), '--json', 'jobs']), + ) + for (const job of jobs.filter((each) => each.conclusion === 'failure')) { + failedJobs += 1 + console.log(`\n## ${job.name}\n${job.url}`) + // The logs keep the runners' colours, which `gh api` prints only when asked to; they are stripped before reading. + const log = gh([ + 'api', + '--allow-escape-sequences', + `repos/${repository}/actions/jobs/${job.databaseId}/logs`, + ]) + const failures = failedTestsOf(log) + // A job that failed outside the test runner (a refused health run, a build) shows its last lines. + if (failures.length === 0) + for (const line of log.split('\n').slice(-TAIL_LINES)) + console.log( + line.replace(TIMESTAMP_PREFIX, '').replace(COLOUR_SEQUENCE, ''), + ) + for (const failure of failures) + console.log( + `✖ ${failure.name}${failure.error ? `\n ${failure.error}` : ''}`, + ) + } + } + if (failedJobs === 0) console.log('No failed job.') + return 0 + } catch (error) { + console.error(`gh failed: ${error.message}`) + return 2 + } +} + +if (isRunAsScript(import.meta.url)) process.exitCode = main() diff --git a/extension/scripts/gate.mjs b/extension/scripts/gate.mjs new file mode 100644 index 000000000..2ba2197cc --- /dev/null +++ b/extension/scripts/gate.mjs @@ -0,0 +1,307 @@ +// Runs the exact-HEAD gate behind `pnpm gate`: the fifteen checks a delivery passes before merge, in order, on a clean +// committed tree, with the Node of `.node-version` and the pnpm of `extension/package.json`'s `packageManager`. +// +// pnpm --dir extension gate every check; logs under artifacts/gate// +// pnpm --dir extension gate --out logs in +// pnpm --dir extension gate --allow-dirty also on uncommitted changes (a trial run, not a gate) +// +// Exit 0: every check passed and HEAD did not move. Exit 1: a check failed, or HEAD or the tree changed during the run. +// Exit 2: refused before any check, because Node or pnpm differ from the pins, the checkout has changes, or an option is +// unknown. Each check's output goes to its own log; stdout gets one line per check and the verdict. +import { spawn, spawnSync } from 'node:child_process' +import { createWriteStream } from 'node:fs' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { delimiter, dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +import { isRunAsScript } from './utils/isRunAsScript.mjs' +import { listCheckoutChanges } from './utils/listCheckoutChanges.mjs' +import { plainOutputEnvironment } from './utils/plainOutputEnvironment.mjs' + +const extension = fileURLToPath(new URL('../', import.meta.url)) +const repository = join(extension, '..') + +/** The five Reader DOM checks the `reader-dom.yml` workflow runs, each as `node extension/scripts/-qa.mjs verify `. */ +const READER_DOM_CHECKS = [ + 'study-bootstrap', + 'wiki-reader-dom', + 'study-reading-pose', + 'understanding-focus', + 'repository-return', +] + +/** + * The gate's checks in the order they run: a package script (`pnpm