diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16dbcf47f..fd6b15f43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,30 +208,36 @@ jobs: run: | parts="$RUNNER_TEMP/parts" mkdir -p "$parts" + doc="$parts/${PART}.attempt-${GITHUB_RUN_ATTEMPT}.json" status=0 bun run scripts/verify.ts --only "$ONLY" ${SHARD:+--shard "$SHARD"} --json \ - > "$parts/${PART}.json" || status=$? - echo "the part's document: $(wc -c < "$parts/${PART}.json") bytes, uploaded as verify-part-${PART}" + > "$doc" || status=$? + echo "the part's document: $(wc -c < "$doc") bytes, uploaded as verify-part-${PART}-attempt-${GITHUB_RUN_ATTEMPT}" table="$(jq -r ' (.steps // [])[] | "\(if .skipped then "-" elif .ok then "✓" else "✗" end) \(.name) \(.durationMs)ms\(if .tests then " \(.tests.ran) ran, \(.tests.skipped) skipped\(if .tests.errors then ", \(.tests.errors) errored" else "" end)" else "" end)", (.findings[]? | " \(.code)\(if .at then " (\(.at))" else "" end)\n cause: \(.cause)\n fix: \(.fix)"), (select(.ok | not) | .output // empty | split("\n") | map(select(test("^\\s*\\(fail\\)"))) | unique | select(length > 0) | " failed tests:\n\(map(" " + ltrimstr(" ")) | join("\n"))"), (select(.ok | not) | .output // empty | " output (last lines):\n\(split("\n")[-40:] | map(" " + .) | join("\n"))") - ' "$parts/${PART}.json" || true)" + ' "$doc" || true)" echo "$table" echo "### gate ($PART)" >> "$GITHUB_STEP_SUMMARY" - bun run scripts/verify-summary.ts "$parts/${PART}.json" \ - "{shown} of {total} finding(s) shown. Every finding and each red step's output: this job's log, and the verify-part-${PART} artifact." \ + bun run scripts/verify-summary.ts "$doc" \ + "{shown} of {total} finding(s) shown. Every finding and each red step's output: this job's log, and the verify-part-${PART}-attempt-${GITHUB_RUN_ATTEMPT} artifact." \ >> "$GITHUB_STEP_SUMMARY" || echo "the step summary could not be rendered; the part's verdict is the log above" exit "$status" # Uploaded when the part is RED too: the verdict is the merge's, and a part that failed # without a document would read there as a step nobody ran rather than a step that failed. + # + # Named by the run ATTEMPT, artifact and file both: a "re-run failed jobs" keeps the earlier + # attempt's artifacts in the run, and under one name `verify` downloaded the RED document the + # rerun replaced (run 37918316075, attempt 2) — a fixed run stayed red until the artifact was + # deleted by hand. `scripts/verify-parts.ts` merges each part's newest attempt. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: - name: verify-part-${{ matrix.part }} - path: ${{ runner.temp }}/parts/${{ matrix.part }}.json + name: verify-part-${{ matrix.part }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/parts/${{ matrix.part }}.attempt-${{ github.run_attempt }}.json if-no-files-found: error retention-days: 7 @@ -277,10 +283,14 @@ jobs: - name: x verify merge — every step, from exactly one part, green run: | status=0 - bun run scripts/verify.ts merge ${{ runner.temp }}/parts/*.json > "$RUNNER_TEMP/verdict.txt" 2>&1 \ + # Each part's newest attempt only: an earlier attempt's document of a re-run part is + # still in the download, and merged beside its replacement it is the old verdict. + bun run scripts/verify-parts.ts ${{ runner.temp }}/parts + mapfile -t docs < <(bun run scripts/verify-parts.ts ${{ runner.temp }}/parts --json | jq -r '.data[]') + bun run scripts/verify.ts merge "${docs[@]}" > "$RUNNER_TEMP/verdict.txt" 2>&1 \ || status=$? cat "$RUNNER_TEMP/verdict.txt" - bun run scripts/verify.ts merge ${{ runner.temp }}/parts/*.json --json > "$RUNNER_TEMP/verdict.json" \ + bun run scripts/verify.ts merge "${docs[@]}" --json > "$RUNNER_TEMP/verdict.json" \ || true echo "### verify — the verdict" >> "$GITHUB_STEP_SUMMARY" bun run scripts/verify-summary.ts "$RUNNER_TEMP/verdict.json" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index b8b9f80cb..e8544d8c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ Tier 0 — core. Tier 1 — i18n. Tier 3 — jobs, query. Tier 4 — ui. Tier 5 - **A catalog slice crosses into an island (#710, owner decision 19).** `catalogSubset(t, keys)` (`@ultimat3/i18n`) resolves the named templates, plural variants included, into JSON; `subsetTranslator(subset)` (`@ultimat3/i18n/subset`, an entry that installs no framework catalog) is the island's full translator over it. `uiCatalog(t)` (`@ultimat3/ui`) is the design system's own slice: every `UI_KEYS` template, so `` renders no `⟦ui.*⟧`. The reference app's `shared/ui-strings.ts`, `shared/ui-strings-server.ts` and `shared/wire.ts` are deleted. - **The generator's directory form is the one layout (#710, owner decision 10).** One primitive per file, in its kind's directory — `/actions/.ts`, `live/`, `queries/`, `jobs/`, `tasks/` — what `x g` writes. `x verify`'s `boundaries` step now refuses a primitive module beside its directory in an app's surfaces — `actions.ts` with `actions/`, or `live`, `queries`, `jobs`, `tasks` (`X_LAYOUT_SIBLING_MODULE`); a component module beside its folder (`ui.tsx` + `ui/`, what `x g resource` writes) is not one: `./actions` resolves to the file and never the directory, so the two homes disagree silently. Both tracked apps migrated: `examples/dummy`'s 56 primitives and the demo's 11 each in their own file (`api/tasks.ts`'s two tasks moved into their slices' `tasks/`), registered module by module in `api/index.ts`. `docs/architecture/12-generated-app.md`, `wiki/Project-Layout.md` and `wiki/Actions.md` say so. An app that keeps `actions.ts` with no `actions/` beside it is not refused. +### Fixed + +Tier 5 — testing. + +- **A launched Chrome runs no component-extension page (`--disable-component-extensions-with-background-pages`).** `--disable-extensions` left Google Chrome's built-in extensions running, each with a background page; the hangouts one loading `thunk.js` at start-up was paused by `x shot`'s browser-wide interception and logged as an off-list request of the page under test. Held by `packages/testing/e2e/cdp-launch-targets.e2e.test.ts`: the only target of a fresh launch is `page about:blank`. +- **The framework's CI no longer goes red on four flakes.** `scaffold-helm-retire.test.ts` awaits `helm template` instead of `Bun.spawnSync`, whose wait inside a `bun test --parallel` worker missed the child's exit (exit 0 and empty stdout at exactly its 30 s timeout, or no wake at all until the `unit` step's 480 s deadline). A "re-run failed jobs" merges each gate part's newest attempt (`scripts/verify-parts.ts`, `X_VERIFY_PART_UNNAMED`), never the red document the rerun replaced. The late-profile-writer test drives `remove` with a settle wider than its writer, instead of racing a forking 5 ms poller against the 50 ms one on a loaded runner. + ## 27.3.0 - 2026-10-09 ### Added diff --git a/docs/architecture/guards.md b/docs/architecture/guards.md index 9fd58d82e..19bca8afa 100644 --- a/docs/architecture/guards.md +++ b/docs/architecture/guards.md @@ -89,6 +89,7 @@ hand-edited; `--check` refuses drift (`X_GUARDS_DOC_DRIFT`). A guard is a runnab | `bun run scripts/to-throw-returns.ts` | Enforce, as a gate step, that `expect(fn).toThrow(...)` is given a function that THROWS. | `X_TEST_THROW_NOT_THROWN` | | `bun run scripts/trust-publishers.ts` | Attaches the OIDC trusted publisher to every published package, and verifies it stayed attached. | `X_TRUST_2FA_REQUIRED` `X_TRUST_ENVIRONMENT_EMPTY` `X_TRUST_NPM_TOO_OLD` `X_TRUST_PUBLISHER_FAILED` `X_TRUST_PUBLISHER_MISSING` | | `bun run url-pathname` | Enforce that no source reads `.pathname` off a URL built from `import.meta.url` as a filesystem path. | `X_URL_PATHNAME_AS_PATH` | +| `bun run scripts/verify-parts.ts` | The gate-part documents the `verify` CI job merges: each part's newest attempt, from the directory `download-artifact` filled. | `X_CLI_BAD_FLAG` `X_VERIFY_PART_UNNAMED` | | `bun run verify` | The gate for the framework repo itself: `x verify`, run at the repo root. | `X_CONFIG_IMPORT_OUTSIDE_LOADER` `X_MANIFEST_DRIFT` `X_VERIFY_FAILED` | | `bun run scripts/version-stamps.ts` | Enforce, as a gate step, the two version facts this repo states in prose and checked nowhere. | `X_LOCKFILE_STALE` `X_VERSION_LOCKSTEP_BROKEN` `X_VERSION_STAMP_DUPLICATE` `X_VERSION_STAMP_PIN_STALE` `X_VERSION_STAMP_STALE` `X_VERSION_STAMP_UNSCANNED` | | `bun run wiki-anchors` | Enforce, as a gate rule, that every `Page#anchor` and `#anchor` link on a `wiki/` page lands on a heading of its target page. | `X_WIKI_ANCHOR_DEAD` `X_WIKI_ANCHOR_UNSCANNED` | diff --git a/framework.manifest.json b/framework.manifest.json index 5e02d840c..632c9efa9 100644 --- a/framework.manifest.json +++ b/framework.manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "buildId": "687d4607d5bf64d4d27b15ed97d81f2c9eabde11379b9b7d20de3ceba99f25f4", + "buildId": "65a0813c3cc6a15521e7e31008b9707247810bbefeab00310dc7412b4115ad1c", "tiers": { "0": [ "core", @@ -4599,6 +4599,11 @@ "owner": "cli", "at": "packages/cli/src/error-codes.ts" }, + { + "code": "X_VERIFY_PART_UNNAMED", + "owner": "scripts", + "at": "scripts/verify-parts.ts" + }, { "code": "X_VERIFY_SHARD_INVALID", "owner": "cli", diff --git a/packages/cli/src/templates/scaffold-helm-retire.test.ts b/packages/cli/src/templates/scaffold-helm-retire.test.ts index e24d4cea3..8e0025454 100644 --- a/packages/cli/src/templates/scaffold-helm-retire.test.ts +++ b/packages/cli/src/templates/scaffold-helm-retire.test.ts @@ -21,10 +21,14 @@ const HELM = Bun.which('helm'); * first render took over Bun's default 5 s while the child itself was allowed 30 s, so the TEST * died first (CI, 2026-10-07). Each test renders once; the scaffold's chart is written once. * - * The child is killed with SIGKILL, never the default SIGTERM: `helm template` runs helm's install - * path, which traps SIGTERM to cancel a context and keeps running, so a wedged render outlived its - * 30 s budget — and `spawnSync` holds the event loop, so the test's own timeout never fired either. - * The `unit` step's 480 s deadline was the first thing that could stop it (CI, 2026-10-08). + * Awaited, never `Bun.spawnSync`. Inside a `bun test --parallel` worker the synchronous wait missed + * its child's exit: run 37882529833 got exit 0 and EMPTY stdout back at exactly 30000 ms — helm had + * rendered and exited, and only the timeout woke the wait — and five runs on 2026-10-09 never woke + * at all, so the worker sat until the `unit` step's 480 s deadline killed it, with no helm left + * running to kill. A synchronous wait also holds the event loop, so this file's own timeout could + * not fire either. Awaited, the child keeps its own deadline (SIGKILL, so nothing it traps can + * stretch it), and the test's timeout stays live behind it. Nothing here touches the network: the + * charts have no dependencies and `--kube-version` stands in for a cluster. */ const HELM_RENDER_MS = 30_000; const TEST_MS = 2 * HELM_RENDER_MS + 5_000; @@ -64,8 +68,8 @@ function scaffoldChart(): Promise { } /** Each Deployment by role, `--` cut down to the role. */ -function render(chart: string, extra: readonly string[]): Map { - const result = Bun.spawnSync( +async function render(chart: string, extra: readonly string[]): Promise> { + const child = Bun.spawn( [ HELM ?? 'helm', 'template', @@ -79,15 +83,30 @@ function render(chart: string, extra: readonly string[]): Map '(stderr still open)')]); + return expect.unreachable(`helm template ${chart} (${how}): ${said}`); } - const parsed: unknown = Bun.YAML.parse(result.stdout.toString()); + const parsed: unknown = Bun.YAML.parse(await stdout); + if (parsed === null) + return expect.unreachable(`helm template ${chart} printed nothing: ${await stderr}`); const docs = (Array.isArray(parsed) ? parsed : [parsed]) as Deployment[]; return new Map(docs.map((doc) => [doc.metadata.name.split('-').at(-1) ?? '', doc])); } @@ -101,7 +120,7 @@ describe.skipIf(HELM === null)('unit · roles.worker.retireSeconds, in both char `${which}: off by default — no retire preStop, the drain-only grace`, async () => { const chart = which === 'framework' ? FRAMEWORK : await scaffoldChart(); - const worker = render(chart, []).get('worker'); + const worker = (await render(chart, [])).get('worker'); expect(preStopOf(worker)).toBeUndefined(); expect(worker?.spec.template.spec.terminationGracePeriodSeconds).toBe(35); }, @@ -112,7 +131,7 @@ describe.skipIf(HELM === null)('unit · roles.worker.retireSeconds, in both char `${which}: set, the worker sends PID 1 the retire signal and waits it out`, async () => { const chart = which === 'framework' ? FRAMEWORK : await scaffoldChart(); - const rendered = render(chart, ['--set', 'roles.worker.retireSeconds=7200']); + const rendered = await render(chart, ['--set', 'roles.worker.retireSeconds=7200']); const worker = rendered.get('worker'); const command = preStopOf(worker); expect(command?.[0]).toBe('bun'); @@ -130,7 +149,7 @@ describe.skipIf(HELM === null)('unit · roles.worker.retireSeconds, in both char `${which}: drain.workerDeadlineSeconds sizes the worker's grace alone`, async () => { const chart = which === 'framework' ? FRAMEWORK : await scaffoldChart(); - const rendered = render(chart, [ + const rendered = await render(chart, [ '--set', 'drain.workerDeadlineSeconds=7500', '--set', diff --git a/packages/testing/e2e/cdp-launch-targets.e2e.test.ts b/packages/testing/e2e/cdp-launch-targets.e2e.test.ts new file mode 100644 index 000000000..3eb3f7cfc --- /dev/null +++ b/packages/testing/e2e/cdp-launch-targets.e2e.test.ts @@ -0,0 +1,49 @@ +// A launched browser runs the page it was handed and nothing else. Google Chrome's component +// extensions survive `--disable-extensions`, each with a page of its own, and one of them loading +// `thunk.js` at start-up was paused by `x shot`'s browser-wide interception and logged as the page's +// off-list request (CI, 2026-10-09). Skips with no Chrome, and refuses to skip under +// `E2E_BROWSER_REQUIRED=1`. + +import { describe, expect, test } from 'bun:test'; +import { E2E_BROWSER_OPEN_MS } from '../src/cdp-browser'; +import { findChrome, launchChrome } from '../src/cdp-launch'; + +const chrome = await findChrome(process.env); +const required = process.env['E2E_BROWSER_REQUIRED'] === '1'; + +interface TargetInfo { + readonly type: string; + readonly url: string; +} + +const isTargetInfo = (value: unknown): value is TargetInfo => + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string' && + typeof (value as { url?: unknown }).url === 'string'; + +describe.skipIf(chrome === undefined && !required)('launchChrome — the browser it starts', () => { + test( + 'runs no extension page or worker, and the one page it was handed', + async () => { + if (chrome === undefined) + expect.unreachable('E2E_BROWSER_REQUIRED=1 and no Chrome was found'); + const browser = await launchChrome({ executable: chrome, timeoutMs: 20_000 }); + try { + const answer = await browser.connection.send('Target.getTargets'); + const infos = (answer.result as { targetInfos?: unknown } | undefined)?.targetInfos; + const targets = (Array.isArray(infos) ? infos : []).filter(isTargetInfo); + // Chrome's own browser UI (`browser_ui chrome://omnibox-popup…` on CI's build) loads no + // web resource; an extension's page or worker does, and it is the one interception pauses. + const extensions = targets.filter((target) => target.url.startsWith('chrome-extension://')); + expect(extensions.map((target) => `${target.type} ${target.url}`)).toEqual([]); + expect(targets.filter((target) => target.type === 'page').map((t) => t.url)).toEqual([ + 'about:blank', + ]); + } finally { + await browser.close(); + } + }, + E2E_BROWSER_OPEN_MS, + ); +}); diff --git a/packages/testing/src/cdp-launch-profile.test.ts b/packages/testing/src/cdp-launch-profile.test.ts index 959eb608d..c8465fb58 100644 --- a/packages/testing/src/cdp-launch-profile.test.ts +++ b/packages/testing/src/cdp-launch-profile.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'; // why: joining the script and its profile-log paths. import { join } from 'node:path'; import { launchChrome } from './cdp-launch'; +import { throwawayProfile } from './cdp-launch-profile'; /** Record `--user-data-dir`, answer the first call, then run `rest`. */ const script = (rest: string): string => `#!/bin/bash @@ -74,24 +75,36 @@ describe.skipIf(WINDOWS)('launchChrome — the profile outlives no close', () => }); }, 20_000); - test('a writer OUTSIDE the process group that re-creates the profile does not outlast the close', async () => { - // Its own process group (`set -m`), so the group reap cannot see it — Chrome's crashpad - // handler is one — and it re-creates the profile each time it goes, three times. A close that - // removes once after the reap leaves the third. - const late = `set -m -( for n in 1 2 3; do - for _ in $(seq 400); do [ -d "$PROFILE" ] || break; sleep 0.005; done - mkdir -p "$PROFILE/Default/Cache/Cache_Data" - done ) & -set +m`; - await withFake(late, async (fake) => { - const launched = await launchChrome({ executable: fake, timeoutMs: 5_000 }); - const profile = await profileOf(fake); - await launched.close(); - // Long past the writer's 5 ms poll: a profile it re-created after the close has reappeared. - await Bun.sleep(250); - expect(existsSync(profile)).toBe(false); - }); + // The profile's own removal, not a launch: the writer is outside the browser's process group, so + // the reap never sees it, and what outlasts it is `remove` alone. Driven through `remove` with a + // settle wider than the writer's deliberate 100 ms: through `close()` the settle is + // PROFILE_SETTLE_MS (50 ms), and a 5 ms poller forking `sleep` and `mkdir` on a loaded 4-CPU + // runner reacted slower than that — the profile came back after the close (CI, 2026-10-09). + test('a writer OUTSIDE the process group that re-creates the profile does not outlast the remove', async () => { + const profile = throwawayProfile(); + // Re-creates the profile each time it goes, three times, each 100 ms after it saw it gone. A + // remove that looks once after the first removal leaves the third. + const writer = Bun.spawn( + [ + 'bash', + '-c', + `for n in 1 2 3; do + for _ in $(seq 2000); do [ -d "$1" ] || break; sleep 0.005; done + sleep 0.1; mkdir -p "$1/Default/Cache/Cache_Data" + done`, + 'writer', + profile.path, + ], + { stdout: 'ignore', stderr: 'ignore' }, + ); + try { + await profile.remove(undefined, 15_000, 1_000); + expect(await writer.exited).toBe(0); + expect(existsSync(profile.path)).toBe(false); + } finally { + writer.kill('SIGKILL'); + await rm(profile.path, { recursive: true, force: true }); + } }, 20_000); test("Chrome's singleton socket directory beside the profile goes with it", async () => { diff --git a/packages/testing/src/cdp-launch-profile.ts b/packages/testing/src/cdp-launch-profile.ts index 8b730ee12..9f80acbeb 100644 --- a/packages/testing/src/cdp-launch-profile.ts +++ b/packages/testing/src/cdp-launch-profile.ts @@ -41,10 +41,10 @@ export interface ThrowawayProfile { singletonDir(): string | undefined; /** * Remove the profile — and `singleton`, when it holds Chrome's singleton files and nothing else — - * and answer once neither has come back for `PROFILE_SETTLE_MS`, or at `deadlineMs`. Never throws: - * a directory that will not go is litter, never the close's verdict. + * and answer once neither has come back for `settleMs` (`PROFILE_SETTLE_MS`), or at `deadlineMs`. + * Never throws: a directory that will not go is litter, never the close's verdict. */ - remove(singleton: string | undefined, deadlineMs: number): Promise; + remove(singleton: string | undefined, deadlineMs: number, settleMs?: number): Promise; } const quietly = (work: () => void): void => { @@ -86,7 +86,7 @@ export function throwawayProfile(root: string = tmpdir()): ThrowawayProfile { return undefined; } }, - async remove(singleton, deadlineMs) { + async remove(singleton, deadlineMs, settleMs = PROFILE_SETTLE_MS) { const until = performance.now() + deadlineMs; const owned = singleton !== undefined && isSingletonDir(singleton) ? singleton : undefined; const present = (): boolean => existsSync(path) || (owned !== undefined && existsSync(owned)); @@ -94,11 +94,11 @@ export function throwawayProfile(root: string = tmpdir()): ThrowawayProfile { quietly(() => rmSync(path, { recursive: true, force: true })); if (owned !== undefined) removeSingleton(owned); if (present()) { - await Bun.sleep(PROFILE_SETTLE_MS / 5); + await Bun.sleep(settleMs / 5); continue; } // Gone NOW is not gone: the look after the settle is what a late writer fails. - await Bun.sleep(PROFILE_SETTLE_MS); + await Bun.sleep(settleMs); if (!present()) return; } while (performance.now() < until); }, diff --git a/packages/testing/src/cdp-launch.ts b/packages/testing/src/cdp-launch.ts index e56735004..eb29d78c3 100644 --- a/packages/testing/src/cdp-launch.ts +++ b/packages/testing/src/cdp-launch.ts @@ -87,6 +87,11 @@ export const chromeLaunchFlags = ( '--no-first-run', '--no-default-browser-check', '--disable-extensions', + // `--disable-extensions` leaves Google Chrome's COMPONENT extensions running, each with a page of + // its own: the hangouts one (nkeimhogjdpnpccoofpliimaahmaaome) loads `thunk.js` while the browser + // starts, and `x shot`'s browser-wide interception paused it and logged it as an off-list request + // of the page under test (CI, 2026-10-09). A test browser runs no page it was not handed. + '--disable-component-extensions-with-background-pages', 'about:blank', ]; diff --git a/scripts/ci-workflow-shape-runs.test.ts b/scripts/ci-workflow-shape-runs.test.ts index bd622468c..221902b82 100644 --- a/scripts/ci-workflow-shape-runs.test.ts +++ b/scripts/ci-workflow-shape-runs.test.ts @@ -59,11 +59,15 @@ describe('unit · ci.yml · the verdict`s summary page', () => { expect(writes.length).toBe(2); expect(writes[0]).toMatch(/^\s*echo "### [^"]*" >> "\$GITHUB_STEP_SUMMARY"$/); expect(writes[1]).toMatch( - /^\s*bun run scripts\/verify-summary\.ts "[^"]+\.json" "[^"]*\{shown\}[^"]*\{total\}[^"]*" >> "\$GITHUB_STEP_SUMMARY" \|\| echo "[^"]*"$/, + /^\s*bun run scripts\/verify-summary\.ts "(?:[^"]+\.json|\$doc)" "[^"]*\{shown\}[^"]*\{total\}[^"]*" >> "\$GITHUB_STEP_SUMMARY" \|\| echo "[^"]*"$/, ); } - // The part's own document, spelled without a `${` the linter would read as a template. - expect(summaryWrites('gate')[1]).toContain(['"$parts/$', '{PART}.json"'].join('')); + // The part's own document, named by attempt (spelled without a `${` the linter would read as + // a template). + expect(summaryWrites('gate')[1]).toContain('verify-summary.ts "$doc"'); + expect(runsOf('gate')).toContain( + ['doc="$parts/$', '{PART}.attempt-$', '{GITHUB_RUN_ATTEMPT}.json"'].join(''), + ); }); // GitHub refuses a step summary over 1024 KiB, and the whole human verdict of a red run is @@ -72,9 +76,7 @@ describe('unit · ci.yml · the verdict`s summary page', () => { const run = runsOf('verify'); const summaryLines = run.split('\n').filter((line) => line.includes('GITHUB_STEP_SUMMARY')); expect(summaryLines.join('\n')).not.toContain('verdict.txt'); - expect(run).toContain( - `merge ${expr('runner.temp')}/parts/*.json --json > "$RUNNER_TEMP/verdict.json"`, - ); + expect(run).toContain(['merge "$', '{docs[@]}" --json > "$RUNNER_TEMP/verdict.json"'].join('')); expect(scriptCalls(run, 'scripts/verify-summary.ts')).toEqual([{ flags: [] }]); expect(run).toMatch( /scripts\/verify-summary\.ts "\$RUNNER_TEMP\/verdict\.json" \\\n\s+"[^"]*\{shown\}[^"]*\{total\}[^"]*"/, diff --git a/scripts/ci-workflow-shape.test.ts b/scripts/ci-workflow-shape.test.ts index 9db665673..fc80938fe 100644 --- a/scripts/ci-workflow-shape.test.ts +++ b/scripts/ci-workflow-shape.test.ts @@ -72,7 +72,13 @@ describe('unit · ci.yml · the gate in parts is still the gate', () => { text(step.uses).startsWith('actions/upload-artifact@'), ); expect(upload?.if).toBe(expr('!cancelled()')); - expect(upload?.with?.['name']).toBe(`verify-part-${expr('matrix.part')}`); + // Per attempt: a re-run's document must not share a name with the one it replaces. + expect(upload?.with?.['name']).toBe( + `verify-part-${expr('matrix.part')}-attempt-${expr('github.run_attempt')}`, + ); + expect(upload?.with?.['path']).toBe( + `${expr('runner.temp')}/parts/${expr('matrix.part')}.attempt-${expr('github.run_attempt')}.json`, + ); expect(upload?.with?.['if-no-files-found']).toBe('error'); expect(job('gate').strategy?.['fail-fast']).toBe(false); }); @@ -94,7 +100,12 @@ describe('unit · ci.yml · the gate in parts is still the gate', () => { { subcommand: 'merge', flags: [] }, { subcommand: 'merge', flags: ['json'] }, ]); - expect(runsOf(verify)).toContain(`merge ${text(download?.with?.['path'])}/*.json`); + // Only each part's newest attempt is merged, never the whole download directory. + expect(runsOf(verify)).not.toContain('/*.json'); + expect(runsOf(verify)).toContain( + `scripts/verify-parts.ts ${text(download?.with?.['path'])} --json | jq -r '.data[]'`, + ); + expect(runsOf(verify)).toContain(['merge "$', '{docs[@]}"'].join('')); }); test('every flag and subcommand ci.yml hands scripts/verify.ts is one that script accepts', () => { diff --git a/scripts/lib/ci-attempt-parts.test.ts b/scripts/lib/ci-attempt-parts.test.ts new file mode 100644 index 000000000..08df61577 --- /dev/null +++ b/scripts/lib/ci-attempt-parts.test.ts @@ -0,0 +1,55 @@ +// Which part documents the `verify` job merges after a "re-run failed jobs": each part's newest +// attempt, and never an earlier attempt's copy of the same part. Run 37918316075 attempt 2 merged +// attempt 1's red `unit-1` beside the green rerun, so a fixed run stayed red. + +import { describe, expect, test } from 'bun:test'; +import { currentAttemptParts } from './ci-attempt-parts'; + +describe('currentAttemptParts', () => { + test('one attempt: every part is current', () => { + const files = ['p/e2e.attempt-1.json', 'p/unit-1.attempt-1.json', 'p/unit-2.attempt-1.json']; + expect(currentAttemptParts(files)).toEqual({ current: files, superseded: [], unnamed: [] }); + }); + + test('a re-run of the failed job: its new document replaces the old one, the rest stay', () => { + const files = [ + 'p/unit-1.attempt-1.json', + 'p/unit-1.attempt-2.json', + 'p/unit-2.attempt-1.json', + 'p/live.attempt-1.json', + ]; + expect(currentAttemptParts(files)).toEqual({ + current: ['p/live.attempt-1.json', 'p/unit-1.attempt-2.json', 'p/unit-2.attempt-1.json'], + superseded: ['p/unit-1.attempt-1.json'], + unnamed: [], + }); + }); + + test('attempts compare as numbers, not text: 10 is newer than 9', () => { + const files = ['p/unit-1.attempt-9.json', 'p/unit-1.attempt-10.json']; + expect(currentAttemptParts(files).current).toEqual(['p/unit-1.attempt-10.json']); + }); + + test('a part whose name holds dots and dashes is still one part', () => { + const files = ['p/a.b-c.attempt-1.json', 'p/a.b-c.attempt-3.json', 'p/a.attempt-2.json']; + expect(currentAttemptParts(files)).toEqual({ + current: ['p/a.attempt-2.json', 'p/a.b-c.attempt-3.json'], + superseded: ['p/a.b-c.attempt-1.json'], + unnamed: [], + }); + }); + + test('a document that names no attempt cannot be ordered, and is reported, never merged', () => { + const files = ['p/unit-1.json', 'p/unit-1.attempt-0.json', 'p/unit-2.attempt-1.json']; + expect(currentAttemptParts(files)).toEqual({ + current: ['p/unit-2.attempt-1.json'], + superseded: [], + unnamed: ['p/unit-1.attempt-0.json', 'p/unit-1.json'], + }); + }); + + test('a Windows path separates the same way', () => { + const files = ['C:\\t\\unit-1.attempt-1.json', 'C:\\t\\unit-1.attempt-2.json']; + expect(currentAttemptParts(files).current).toEqual(['C:\\t\\unit-1.attempt-2.json']); + }); +}); diff --git a/scripts/lib/ci-attempt-parts.ts b/scripts/lib/ci-attempt-parts.ts new file mode 100644 index 000000000..32d10fe1f --- /dev/null +++ b/scripts/lib/ci-attempt-parts.ts @@ -0,0 +1,44 @@ +// Which gate-part documents one CI run's verdict merges: per part, the newest attempt's. A "re-run +// failed jobs" keeps every earlier attempt's artifacts in the run, so `verify` downloads the red +// part it replaced beside the green rerun. Pure; `scripts/verify-parts.ts` reads the directory. + +/** `.attempt-.json` — what each `gate` job writes, `n` being `github.run_attempt`. */ +const ATTEMPT_DOC = /^(.+)\.attempt-([1-9]\d*)\.json$/; + +export interface AttemptParts { + /** The newest document of each part, in path order: what `x verify merge` is handed. */ + readonly current: readonly string[]; + /** An earlier attempt's document of a part that has a newer one. */ + readonly superseded: readonly string[]; + /** A document that names no attempt: it cannot be ordered against another, so it is not merged. */ + readonly unnamed: readonly string[]; +} + +const baseName = (path: string): string => path.split(/[\\/]/).at(-1) ?? path; + +export function currentAttemptParts(files: readonly string[]): AttemptParts { + const newest = new Map(); + const superseded: string[] = []; + const unnamed: string[] = []; + for (const path of files) { + const match = ATTEMPT_DOC.exec(baseName(path)); + if (match === null) { + unnamed.push(path); + continue; + } + const part = match[1] ?? ''; + const attempt = Number(match[2]); + const held = newest.get(part); + if (held === undefined) newest.set(part, { path, attempt }); + else if (attempt > held.attempt) { + superseded.push(held.path); + newest.set(part, { path, attempt }); + } else superseded.push(path); + } + const sorted = (paths: readonly string[]): string[] => [...paths].sort(); + return { + current: sorted([...newest.values()].map((doc) => doc.path)), + superseded: sorted(superseded), + unnamed: sorted(unnamed), + }; +} diff --git a/scripts/verify-parts.ts b/scripts/verify-parts.ts new file mode 100644 index 000000000..2fe8fd490 --- /dev/null +++ b/scripts/verify-parts.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env bun +// The gate-part documents the `verify` CI job merges: each part's newest attempt, from the +// directory `download-artifact` filled. `data` is the list `x verify merge` is handed; an earlier +// attempt's copy of a part is named in the log and left out (`lib/ci-attempt-parts.ts`). +// +// bun run scripts/verify-parts.ts [--json] + +// why: host-separator paths into the download directory; Bun ships no path API. +import { join } from 'node:path'; +import { parseScriptArgs } from './lib/args'; +import { currentAttemptParts } from './lib/ci-attempt-parts'; +import type { Finding } from './lib/log'; +import { report } from './lib/log'; + +const SCRIPT = 'verify-parts'; + +if (import.meta.main) { + const args = parseScriptArgs(Bun.argv.slice(2)); + const dir = args.positionals[0]; + if (dir === undefined) { + report( + { + ok: false, + script: SCRIPT, + summary: 'no directory to read', + findings: [ + { + code: 'X_CLI_BAD_FLAG', + cause: + 'verify-parts was run without the directory the part documents were downloaded to', + fix: 'bun run scripts/verify-parts.ts "$RUNNER_TEMP/parts" --json', + }, + ], + }, + args.json, + ); + } + const files = [...new Bun.Glob('*.json').scanSync({ cwd: dir })].map((name) => join(dir, name)); + const parts = currentAttemptParts(files); + const findings: Finding[] = parts.unnamed.map((path) => ({ + code: 'X_VERIFY_PART_UNNAMED', + cause: `${path} names no run attempt, so it cannot be told from another attempt's copy of its part`, + fix: "grep -n 'attempt-' .github/workflows/ci.yml # the gate job writes each part as .attempt-.json", + at: path, + })); + if (parts.current.length === 0 && findings.length === 0) { + findings.push({ + code: 'X_VERIFY_PART_UNNAMED', + cause: `${dir} holds no part document at all`, + fix: "grep -n 'verify-part-' .github/workflows/ci.yml # the gate job's upload name and the verify job's download pattern must agree", + at: dir, + }); + } + report( + { + ok: findings.length === 0, + script: SCRIPT, + summary: `${parts.current.length} part document(s) to merge, ${parts.superseded.length} superseded by a re-run`, + findings, + data: parts.current, + lines: parts.superseded.map((path) => `superseded by a newer attempt: ${path}`), + }, + args.json, + ); +} diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index 662a6624c..0785f8f07 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -24,7 +24,7 @@ The HTTP body is the same content as an RFC 9457 problem document — `type`, `t | Registration | a code exists when its owning package calls `registerErrorCodes()`. That one call is what makes it explainable, unique and documented-or-fail — a code emitted as a `Finding` rather than thrown is registered the same way | | Enforcement | the `errors` step of `x verify` fails on an empty or advice-only `fix` (`X_ERROR_FIX_INVALID`), on a `fix` citing a file the repo does not have (`X_ERROR_FIX_PATH_MISSING`), on a declared code with no row on this page (`X_ERROR_CODE_UNDOCUMENTED`), on a row this page presents as live that no package registers (`X_ERROR_CODE_UNREGISTERED`), and on a `code:` reached through a name nothing in its own file declares (`X_ERROR_CODE_UNRESOLVED`) | -`As of 2026-08` every code above [Reserved codes](#reserved-codes) resolves through `x errors explain`, with one exception the gate knows about: this repository's own gate scripts (`X_BOUNDARY_VIOLATION`, `X_ROADMAP_*`, `X_REFERENCE_APP_*`, `X_TRUST_*`, `X_CATALOG_CARRIER_UNSCANNED`, `X_CATCH_RENDER_PIN_STALE`, `X_CATCH_RENDER_UNSAFE`, `X_CATCH_RENDER_UNSCANNED`, `X_CONFIG_KEY_UNREAD`, `X_CONFIG_READERS_UNSCANNED`, `X_CONFIG_READER_PIN_STALE`, `X_DECLARED_KEY_UNREAD`, `X_DECLARATION_READER_PIN_STALE`, `X_DECLARATION_READERS_UNSCANNED`, `X_DOC_COMMAND_PIN_EXCEEDED`, `X_DOC_CONFIG_KEY_UNKNOWN`, `X_DOC_CONFIG_ALLOWANCE_STALE`, `X_SCAFFOLD_VERIFY_UNREADABLE`, `X_TIER_FLOOR_STALE`, `X_TIER_FLOOR_UNDECLARED`, `X_SCAFFOLD_OVERRIDES_EMPTY`, `X_SCAFFOLD_GATE_RED`, `X_SCAFFOLD_FIRST_RUN_FAILED`, `X_SETUP_INSTALL_FAILED`, `X_ADMIN_FLATTENER_VIOLATION`, `X_CONFIG_IMPORT_OUTSIDE_LOADER`, `X_ERROR_RENDER_UNSAFE`, `X_ERROR_STATUS_MISSING`, `X_ERROR_STATUS_BACKLOG_STALE`, `X_ERROR_STATUS_UNKNOWN_CODE`, `X_CATALOG_KEY_UNREACHABLE`, `X_PUBLISH_LIST_INCOMPLETE`, `X_PUBLISH_LIST_UNKNOWN`, `X_BENCH_CLAIM_STALE`, `X_WIKI_TABLE_MALFORMED`, `X_WIKI_TABLE_UNSCANNED`, `X_FRAME_DOCS_STALE`, `X_CHART_VERSION_STALE`, `X_IMAGE_LIBC_MISMATCH`, `X_IMAGE_GUARD_MISSING`, `X_IMAGE_SECRET_UNIGNORED`, `X_DOC_FIX_UNRUNNABLE`, `X_DOC_FIX_UNSCANNED`, `X_DOC_CAUSE_ECHOES_TITLE`, `X_DOC_COMMAND_UNKNOWN`, `X_DOC_COMMAND_ALLOWANCE_STALE`, `X_DOC_COMMAND_UNSCANNED`, `X_DOC_COMMAND_PIN_STALE`, `X_DOC_GATE_STEPS_STALE`, `X_DOC_GATE_STEPS_UNSCANNED`, `X_DOC_SETUP_COMMAND_STALE`, `X_DOC_SETUP_COMMANDS_UNSCANNED`, `X_DOC_SETUP_COMMAND_STALE`, `X_DOC_SETUP_COMMANDS_UNSCANNED`, `X_README_EXAMPLE_UNCOMPILED`, `X_README_EXAMPLE_PIN_STALE`, `X_README_EXAMPLE_UNSCANNED`, `X_VERSION_STAMP_STALE`, `X_VERSION_STAMP_DUPLICATE`, `X_VERSION_LOCKSTEP_BROKEN`, `X_VERSION_STAMP_UNSCANNED`, `X_COVERAGE_BELOW`, `X_COVERAGE_PIN_STALE`, `X_COVERAGE_UNMEASURED`, `X_TEST_TYPECHECK_REGRESSED`, `X_TEST_TYPECHECK_PIN_STALE`, `X_TEST_TYPECHECK_UNSCANNED`, `X_DOC_FILE_COUNT_STALE`, `X_DOC_FILE_COUNT_UNSCANNED`, `X_DOC_RELEASE_FACT_STALE`, `X_DOC_RELEASE_FACT_UNSCANNED`, `X_DOC_PACKAGE_GRAPH_STALE`, `X_DOC_PACKAGE_GRAPH_UNSCANNED`, `X_TEST_FIX_UNRUNNABLE`, `X_TEST_FIX_PIN_STALE`, `X_TEST_FIX_UNSCANNED`, `X_TEST_THROW_NOT_THROWN`, `X_TEST_BARE_ERROR`, `X_TEST_BARE_ERROR_PIN_STALE`, `X_TEST_BARE_ERROR_UNSCANNED`, `X_RELEASE_VERSION_UNSTATED`, `X_RELEASE_FLAG_UNKNOWN`, `X_RELEASE_TREE_DIRTY`, `X_RELEASE_VERSION_INVALID`, `X_DOC_CHANGELOG_SECTION_INVALID`, `X_DOC_CHANGELOG_UNRELEASED_BREAKING`, `X_DOC_MIGRATION_COUNT_STALE`, `X_DOC_MIGRATION_SECTION_MISSING`, `X_DOC_MIGRATION_UNSCANNED`, `X_DOC_MIGRATION_UNPAIRED`, `X_DOC_MIGRATION_RANGE_STALE`, `X_RELEASE_UNRELEASED_MISSING`, `X_LOCKFILE_STALE`, `X_REGISTRY_BOOTSTRAP_OWED`, `X_REGISTRY_UNATTESTED`, `X_REGISTRY_UNREACHABLE`, `X_REGISTRY_VERSION_BEHIND`, `X_GATE_CODE_UNDOCUMENTED`, `X_GATE_CODE_BACKLOG_STALE`, `X_SIDE_EFFECTS_*`, `X_DEAD_DOCS_HOST`, `X_DEAD_DOCS_HOST_PIN_STALE`, `X_DEAD_DOCS_HOST_UNSCANNED`, `X_SECRET_COMPARED_UNSAFELY`, `X_SECRET_COMPARE_PIN_STALE`, `X_SECRET_COMPARE_UNSCANNED`, `X_VERSION_STAMP_PIN_STALE`, `X_WORKSPACE_MANIFEST_UNREADABLE`, `X_CONFIG_KEY_READER_AMBIGUOUS`, `X_PROTO_CHAIN_INDEX`, `X_PROTO_CHAIN_INDEX_PIN_STALE`, `X_PROTO_CHAIN_INDEX_UNSCANNED`, `X_NODE_IMPORT_UNEXPLAINED`, `X_NODE_IMPORT_PIN_STALE`, `X_NODE_IMPORT_UNSCANNED`, `X_FLIGHT_RANDOM_UNINJECTED`, `X_FLIGHT_SECOND_CURVE`, `X_HELPER_COPY`, `X_NOT_IMPLEMENTED_COPY`, `X_NOT_IMPLEMENTED_COPY_UNSCANNED`, `X_FRAMEWORK_TABLE_UNAPPLIED`, `X_FRAMEWORK_TABLE_UNSCANNED`, `X_SQL_LITERAL_COPY`, `X_SQL_LITERAL_UNSCANNED`, `X_INDEX_ORDER_UNGUARDED`, `X_INDEX_ORDER_PIN_STALE`, `X_INDEX_ORDER_PIN_UNEXPLAINED`, `X_INDEX_ORDER_UNSCANNED`, `X_SKIP_CLEANUP_UNREACHED`, `X_SKIP_CLEANUP_UNSCANNED`, `X_PROBE_DATABASE_FIXED`, `X_SPAWN_UNTIMED`, `X_SPAWN_TIMEOUT_PIN_STALE`, `X_SPAWN_TIMEOUT_PIN_UNEXPLAINED`, `X_FINITE_BOUND_UNCHECKED`, `X_FINITE_BOUND_PIN_STALE`, `X_FINITE_BOUND_UNSCANNED`, `X_VOCABULARY_REDECLARED`, `X_VOCABULARY_UNSCANNED`, `X_FROZEN_RECORD_INFERRED`, `X_FROZEN_RECORD_UNSCANNED`, `X_CONFIG_READER_PIN_UNEXPLAINED`, `X_SECRET_COMPARE_PIN_UNEXPLAINED`, `X_PROTO_CHAIN_INDEX_PIN_UNEXPLAINED`, `X_FIX_SHELL_ARG_UNSCREENED`, `X_FIX_SHELL_ARG_PIN_STALE`, `X_FIX_SHELL_ARG_PIN_UNEXPLAINED`, `X_FIX_SHELL_ARG_UNSCANNED`, `X_BROWSER_TRANSPORT_UNSCANNED`, `X_CHANNEL_LITERAL`, `X_CHANNEL_LITERAL_UNSCANNED`, `X_SEAL_CALL_OUTSIDE_CORE`, `X_SEAL_CALL_UNSCANNED`, `X_GUARD_LOAD_FAILED`, `X_BUDGET_RAISE_UNSTATED`, `X_NEW_ERROR_CODE_INVALID`, `X_NEW_ERROR_CODE_EXISTS`, `X_NEW_ERROR_CODE_PATTERN_UNKNOWN`, `X_BUDGET_BASE_MISSING`, `X_PIN_RAISE_UNSTATED`, `X_PIN_BASE_MISSING`, `X_CORPUS_UNSCANNED`, `X_DOC_UNRELEASED_STALE`, `X_DOC_UNRELEASED_PIN_STALE`, `X_LLMS_TXT_DRIFT`, `X_GUARDS_DOC_DRIFT`, `X_GUARDS_DOC_UNSCANNED`, `X_CLAUDE_MD_OVERSIZE`, `X_CLAUDE_MD_UNSCANNED`, `X_CONFIG_READER_APP_UNREAD`, `X_FIX_PROSE`, `X_FIX_PROSE_PIN_STALE`, `X_FIX_PROSE_PIN_UNEXPLAINED`, `X_FIX_PROSE_UNSCANNED`, `X_DOC_PATH_DEAD`, `X_ROOT_BIN_REINTRODUCED`, `X_PLAN_STATUS_INVALID`, `X_PLAN_STATUS_UNSCANNED`, `X_URL_PATHNAME_AS_PATH`, `X_RELATIVE_PATH_NOT_POSIX`, `X_SET_COOKIE_HAND_BUILT`, `X_SET_COOKIE_PIN_STALE`, `X_SET_COOKIE_UNSCANNED`, `X_FACTORY_NAME_SPELLING`, `X_CORE_THROWER_UNCALLED`, `X_CORE_THROWER_PIN_STALE`, `X_CORE_THROWERS_UNSCANNED`, `X_SQL_EXPORT_UNREAD`, `X_SQL_EXPORT_UNSCANNED`, `X_WIKI_EXAMPLE_UNCOMPILED`, `X_WIKI_EXAMPLE_PIN_STALE`, `X_WIKI_EXAMPLE_UNSCANNED`, `X_WIKI_ANCHOR_DEAD`, `X_WIKI_ANCHOR_UNSCANNED`, `X_KNOWN_GAP_LINK_MALFORMED`, `X_KNOWN_GAP_UNSCANNED`, `X_DOC_UID_STALE`, `X_DOC_UID_UNSCANNED`, `X_DOC_BOUNDARY_RULES_STALE`, `X_DOC_BOUNDARY_RULES_UNSCANNED`) never ship, so no package may own them. See [Troubleshooting](Troubleshooting) for symptom-first triage and [CLI reference](CLI-Reference) for the commands named in the fixes. +`As of 2026-08` every code above [Reserved codes](#reserved-codes) resolves through `x errors explain`, with one exception the gate knows about: this repository's own gate scripts (`X_BOUNDARY_VIOLATION`, `X_ROADMAP_*`, `X_REFERENCE_APP_*`, `X_TRUST_*`, `X_CATALOG_CARRIER_UNSCANNED`, `X_CATCH_RENDER_PIN_STALE`, `X_CATCH_RENDER_UNSAFE`, `X_CATCH_RENDER_UNSCANNED`, `X_CONFIG_KEY_UNREAD`, `X_CONFIG_READERS_UNSCANNED`, `X_CONFIG_READER_PIN_STALE`, `X_DECLARED_KEY_UNREAD`, `X_DECLARATION_READER_PIN_STALE`, `X_DECLARATION_READERS_UNSCANNED`, `X_DOC_COMMAND_PIN_EXCEEDED`, `X_DOC_CONFIG_KEY_UNKNOWN`, `X_DOC_CONFIG_ALLOWANCE_STALE`, `X_SCAFFOLD_VERIFY_UNREADABLE`, `X_TIER_FLOOR_STALE`, `X_TIER_FLOOR_UNDECLARED`, `X_SCAFFOLD_OVERRIDES_EMPTY`, `X_SCAFFOLD_GATE_RED`, `X_SCAFFOLD_FIRST_RUN_FAILED`, `X_SETUP_INSTALL_FAILED`, `X_ADMIN_FLATTENER_VIOLATION`, `X_CONFIG_IMPORT_OUTSIDE_LOADER`, `X_ERROR_RENDER_UNSAFE`, `X_ERROR_STATUS_MISSING`, `X_ERROR_STATUS_BACKLOG_STALE`, `X_ERROR_STATUS_UNKNOWN_CODE`, `X_CATALOG_KEY_UNREACHABLE`, `X_PUBLISH_LIST_INCOMPLETE`, `X_PUBLISH_LIST_UNKNOWN`, `X_BENCH_CLAIM_STALE`, `X_WIKI_TABLE_MALFORMED`, `X_WIKI_TABLE_UNSCANNED`, `X_FRAME_DOCS_STALE`, `X_CHART_VERSION_STALE`, `X_IMAGE_LIBC_MISMATCH`, `X_IMAGE_GUARD_MISSING`, `X_IMAGE_SECRET_UNIGNORED`, `X_DOC_FIX_UNRUNNABLE`, `X_DOC_FIX_UNSCANNED`, `X_DOC_CAUSE_ECHOES_TITLE`, `X_DOC_COMMAND_UNKNOWN`, `X_DOC_COMMAND_ALLOWANCE_STALE`, `X_DOC_COMMAND_UNSCANNED`, `X_DOC_COMMAND_PIN_STALE`, `X_DOC_GATE_STEPS_STALE`, `X_DOC_GATE_STEPS_UNSCANNED`, `X_DOC_SETUP_COMMAND_STALE`, `X_DOC_SETUP_COMMANDS_UNSCANNED`, `X_DOC_SETUP_COMMAND_STALE`, `X_DOC_SETUP_COMMANDS_UNSCANNED`, `X_README_EXAMPLE_UNCOMPILED`, `X_README_EXAMPLE_PIN_STALE`, `X_README_EXAMPLE_UNSCANNED`, `X_VERSION_STAMP_STALE`, `X_VERSION_STAMP_DUPLICATE`, `X_VERSION_LOCKSTEP_BROKEN`, `X_VERSION_STAMP_UNSCANNED`, `X_COVERAGE_BELOW`, `X_COVERAGE_PIN_STALE`, `X_COVERAGE_UNMEASURED`, `X_TEST_TYPECHECK_REGRESSED`, `X_TEST_TYPECHECK_PIN_STALE`, `X_TEST_TYPECHECK_UNSCANNED`, `X_DOC_FILE_COUNT_STALE`, `X_DOC_FILE_COUNT_UNSCANNED`, `X_DOC_RELEASE_FACT_STALE`, `X_DOC_RELEASE_FACT_UNSCANNED`, `X_DOC_PACKAGE_GRAPH_STALE`, `X_DOC_PACKAGE_GRAPH_UNSCANNED`, `X_TEST_FIX_UNRUNNABLE`, `X_TEST_FIX_PIN_STALE`, `X_TEST_FIX_UNSCANNED`, `X_TEST_THROW_NOT_THROWN`, `X_TEST_BARE_ERROR`, `X_TEST_BARE_ERROR_PIN_STALE`, `X_TEST_BARE_ERROR_UNSCANNED`, `X_RELEASE_VERSION_UNSTATED`, `X_RELEASE_FLAG_UNKNOWN`, `X_RELEASE_TREE_DIRTY`, `X_RELEASE_VERSION_INVALID`, `X_DOC_CHANGELOG_SECTION_INVALID`, `X_DOC_CHANGELOG_UNRELEASED_BREAKING`, `X_DOC_MIGRATION_COUNT_STALE`, `X_DOC_MIGRATION_SECTION_MISSING`, `X_DOC_MIGRATION_UNSCANNED`, `X_DOC_MIGRATION_UNPAIRED`, `X_DOC_MIGRATION_RANGE_STALE`, `X_RELEASE_UNRELEASED_MISSING`, `X_LOCKFILE_STALE`, `X_REGISTRY_BOOTSTRAP_OWED`, `X_REGISTRY_UNATTESTED`, `X_REGISTRY_UNREACHABLE`, `X_REGISTRY_VERSION_BEHIND`, `X_GATE_CODE_UNDOCUMENTED`, `X_GATE_CODE_BACKLOG_STALE`, `X_SIDE_EFFECTS_*`, `X_DEAD_DOCS_HOST`, `X_DEAD_DOCS_HOST_PIN_STALE`, `X_DEAD_DOCS_HOST_UNSCANNED`, `X_SECRET_COMPARED_UNSAFELY`, `X_SECRET_COMPARE_PIN_STALE`, `X_SECRET_COMPARE_UNSCANNED`, `X_VERSION_STAMP_PIN_STALE`, `X_WORKSPACE_MANIFEST_UNREADABLE`, `X_CONFIG_KEY_READER_AMBIGUOUS`, `X_PROTO_CHAIN_INDEX`, `X_PROTO_CHAIN_INDEX_PIN_STALE`, `X_PROTO_CHAIN_INDEX_UNSCANNED`, `X_NODE_IMPORT_UNEXPLAINED`, `X_NODE_IMPORT_PIN_STALE`, `X_NODE_IMPORT_UNSCANNED`, `X_FLIGHT_RANDOM_UNINJECTED`, `X_FLIGHT_SECOND_CURVE`, `X_HELPER_COPY`, `X_NOT_IMPLEMENTED_COPY`, `X_NOT_IMPLEMENTED_COPY_UNSCANNED`, `X_FRAMEWORK_TABLE_UNAPPLIED`, `X_FRAMEWORK_TABLE_UNSCANNED`, `X_SQL_LITERAL_COPY`, `X_SQL_LITERAL_UNSCANNED`, `X_INDEX_ORDER_UNGUARDED`, `X_INDEX_ORDER_PIN_STALE`, `X_INDEX_ORDER_PIN_UNEXPLAINED`, `X_INDEX_ORDER_UNSCANNED`, `X_SKIP_CLEANUP_UNREACHED`, `X_SKIP_CLEANUP_UNSCANNED`, `X_PROBE_DATABASE_FIXED`, `X_SPAWN_UNTIMED`, `X_SPAWN_TIMEOUT_PIN_STALE`, `X_SPAWN_TIMEOUT_PIN_UNEXPLAINED`, `X_FINITE_BOUND_UNCHECKED`, `X_FINITE_BOUND_PIN_STALE`, `X_FINITE_BOUND_UNSCANNED`, `X_VOCABULARY_REDECLARED`, `X_VOCABULARY_UNSCANNED`, `X_FROZEN_RECORD_INFERRED`, `X_FROZEN_RECORD_UNSCANNED`, `X_CONFIG_READER_PIN_UNEXPLAINED`, `X_SECRET_COMPARE_PIN_UNEXPLAINED`, `X_PROTO_CHAIN_INDEX_PIN_UNEXPLAINED`, `X_FIX_SHELL_ARG_UNSCREENED`, `X_FIX_SHELL_ARG_PIN_STALE`, `X_FIX_SHELL_ARG_PIN_UNEXPLAINED`, `X_FIX_SHELL_ARG_UNSCANNED`, `X_BROWSER_TRANSPORT_UNSCANNED`, `X_CHANNEL_LITERAL`, `X_CHANNEL_LITERAL_UNSCANNED`, `X_SEAL_CALL_OUTSIDE_CORE`, `X_SEAL_CALL_UNSCANNED`, `X_GUARD_LOAD_FAILED`, `X_BUDGET_RAISE_UNSTATED`, `X_NEW_ERROR_CODE_INVALID`, `X_NEW_ERROR_CODE_EXISTS`, `X_NEW_ERROR_CODE_PATTERN_UNKNOWN`, `X_BUDGET_BASE_MISSING`, `X_PIN_RAISE_UNSTATED`, `X_PIN_BASE_MISSING`, `X_CORPUS_UNSCANNED`, `X_DOC_UNRELEASED_STALE`, `X_DOC_UNRELEASED_PIN_STALE`, `X_LLMS_TXT_DRIFT`, `X_GUARDS_DOC_DRIFT`, `X_GUARDS_DOC_UNSCANNED`, `X_CLAUDE_MD_OVERSIZE`, `X_CLAUDE_MD_UNSCANNED`, `X_CONFIG_READER_APP_UNREAD`, `X_FIX_PROSE`, `X_FIX_PROSE_PIN_STALE`, `X_FIX_PROSE_PIN_UNEXPLAINED`, `X_FIX_PROSE_UNSCANNED`, `X_DOC_PATH_DEAD`, `X_ROOT_BIN_REINTRODUCED`, `X_VERIFY_PART_UNNAMED`, `X_PLAN_STATUS_INVALID`, `X_PLAN_STATUS_UNSCANNED`, `X_URL_PATHNAME_AS_PATH`, `X_RELATIVE_PATH_NOT_POSIX`, `X_SET_COOKIE_HAND_BUILT`, `X_SET_COOKIE_PIN_STALE`, `X_SET_COOKIE_UNSCANNED`, `X_FACTORY_NAME_SPELLING`, `X_CORE_THROWER_UNCALLED`, `X_CORE_THROWER_PIN_STALE`, `X_CORE_THROWERS_UNSCANNED`, `X_SQL_EXPORT_UNREAD`, `X_SQL_EXPORT_UNSCANNED`, `X_WIKI_EXAMPLE_UNCOMPILED`, `X_WIKI_EXAMPLE_PIN_STALE`, `X_WIKI_EXAMPLE_UNSCANNED`, `X_WIKI_ANCHOR_DEAD`, `X_WIKI_ANCHOR_UNSCANNED`, `X_KNOWN_GAP_LINK_MALFORMED`, `X_KNOWN_GAP_UNSCANNED`, `X_DOC_UID_STALE`, `X_DOC_UID_UNSCANNED`, `X_DOC_BOUNDARY_RULES_STALE`, `X_DOC_BOUNDARY_RULES_UNSCANNED`) never ship, so no package may own them. See [Troubleshooting](Troubleshooting) for symptom-first triage and [CLI reference](CLI-Reference) for the commands named in the fixes. ## Core and runtime @@ -995,6 +995,7 @@ Two sets override the table, in `failures.ts`: | `X_BOUNDARY_VIOLATION` | a framework package imports one it may not under `scripts/lib/tiers.ts` — this repository's own tier rule, distinct from an app's `X_BOUNDARY_*` codes | a sideways or upward import, an import outside a package's declared edges, a type-position `import('…')`, or a package missing from the tier table | move the shared code down a tier or pass it in; for an edge-only package, add the target to `SIDEWAYS_ALLOW` with the line that earns it; an unknown package goes in the tier table. Then `bun run boundaries` | | `X_DOC_PATH_DEAD` | a published page names, in backticks, a `packages/…`, `scripts/…` or `docs/…` path that exists neither in this repository nor in a tracked app | a file was renamed or deleted and the page kept the old name (22.0.0's `dev-*` → `runtime-*` boot modules), or the page quoted a file that never existed. Placeholders, globs, build outputs, `docs/plans/`, `docs/history/`, `CHANGELOG.md` and any section opened by a `**Historical:**` label are not read | `bun run doc-paths --json`, then edit the named line to the current path — or open the section with `**Historical:**` if the page records a tree that no longer exists | | `X_ROOT_BIN_REINTRODUCED` | the repository root has a `bin/` again (file, directory, or empty directory) | a wrapper (`bin/setup`, `bin/check`, `bin/dev`) restating a `package.json` script, which then drifts from it | `git rm -r bin/`, then use `bun run setup` / `bun run verify` / `bun run x -- ` | +| `X_VERIFY_PART_UNNAMED` | `bun run scripts/verify-parts.ts` found a gate-part document that names no run attempt, or none at all, in the `verify` job's download | a part written as `.json` cannot be told from an earlier attempt's copy of the same part, so a re-run would merge the red document it replaced | write each part as `"$parts/${PART}.attempt-${GITHUB_RUN_ATTEMPT}.json"` in `.github/workflows/ci.yml`, uploaded as `verify-part--attempt-` | | `X_URL_PATHNAME_AS_PATH` | a source reads `.pathname` off a URL built from `import.meta.url` (or destructures it, or builds a `file://` URL from a string) and uses it as a filesystem path | `new URL('..', import.meta.url).pathname` percent-encodes a space (`%20`) and answers `/C:/…` on Windows, so the path names nothing | `Bun.fileURLToPath(new URL('', import.meta.url))`, or `join(import.meta.dir, '')` | | `X_RELATIVE_PATH_NOT_POSIX` | a host `relative()` answer is used without `toPosix` | on Windows `relative()` answers with `\`, which the result then writes into an import specifier, a fix line or a key — a `x fix` edit that breaks every Linux teammate | wrap the call as `toPosix(relative(…))`, or use `posixRelative(…)` from `packages/cli/src/posix-path.ts`; a filesystem-only answer is marked `// native-path: `. Then `bun run posix-relative` | | `X_SET_COOKIE_HAND_BUILT` | a `Set-Cookie` attribute is spelled in a literal, or a `Set-Cookie` header is written by name, outside `packages/core/src/cookie.ts` and `packages/http/src/set-cookie.ts` | a header built by hand (`'Path=/; HttpOnly'`, `` `Max-Age=${s}` ``) — the bytes core's serializer would have refused or encoded go out as written, and two spellings of one header drift | `setCookie` / `deleteCookie` from `@ultimat3/http`, or `serializeSetCookie` below http; then `bun run set-cookie-literals --json` |