diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..c1c65622 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,41 @@ +# The one install every job runs: pnpm, Node and the dependencies, in that +# order. A local action is read from the workspace, so it cannot bring the +# checkout that puts it there: each job checks out first, with its own options +# (the catalog job's depth and credential-free checkout, host-drift's +# credential-free one, the e2e jobs' full history), and then uses this. +name: setup +description: pnpm from packageManager, Node from .node-version (or an input), and a frozen install. + +inputs: + node-version: + description: The Node major to run instead of .node-version's (the engines-floor job passes its floor). + required: false + default: "" + registry-url: + description: The registry setup-node writes an .npmrc for (the publish job's). + required: false + default: "" + +runs: + using: composite + steps: + # No `version:` input: pnpm/action-setup refuses to run when it is given + # both that and a `packageManager` field, and package.json pins the pnpm + # version exactly, in `packageManager`. One source of truth, and the + # action reads it. + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + # The Node every job runs is named once, in .node-version, which + # test/node-engine.test.ts holds at or above `engines.node`. The input is + # passed through the environment, never interpolated into the script. + - id: node + shell: bash + env: + NODE_VERSION_INPUT: ${{ inputs.node-version }} + run: echo "version=${NODE_VERSION_INPUT:-$(cat .node-version)}" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ steps.node.outputs.version }} + cache: pnpm + registry-url: ${{ inputs.registry-url }} + - shell: bash + run: pnpm install --frozen-lockfile diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a6caa0a7..0046cdfd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,8 +7,12 @@ version: 2 updates: # The release path. A stale action here is how a tag silently stops producing a # publish, and the workflow would still be green. + # Every directory that pins an action: the workflows (read from `/`) and the + # composite setup action every job runs, whose pins nothing else would see. - package-ecosystem: github-actions - directory: / + directories: + - / + - /.github/actions/setup schedule: interval: weekly day: monday @@ -54,8 +58,9 @@ updates: dependency-type: development dependencies: dependency-type: production - # A toolchain upgrade that fails `scripts/check.ts`'s toolchain guards is - # expected; so is one that rewrites the lockfile wholesale. + # A toolchain upgrade that fails the toolchain guards in + # `test/toolchain.test.ts` is expected; so is one that rewrites the + # lockfile wholesale. tooling: patterns: - vite-plus @@ -72,8 +77,9 @@ updates: # resolves, pinned per channel. A pull request from here would move one of # them, land red on `test/harness-channel.test.ts`, and take its whole # group with it. The nightly `host-drift` job reports when either channel - # moves, and P-T3 (a later phase) turns that report into the bump. Every - # update type is ignored; `test/harness-channel.test.ts` holds both rules. + # moves and proposes the bump itself, as a `chore/host-bump` pull + # request. Every update type is ignored; `test/harness-channel.test.ts` + # holds both rules. - dependency-name: "@deepseek-ai/*" - dependency-name: "@earendil-works/pi-ai" # React stays at 18: the bundles this plugin ships are host-bound, so a diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02841dbf..2a865db6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,16 @@ on: tags-ignore: ["v*.*.*"] pull_request: workflow_dispatch: + inputs: + # What the e2e job's keyed tier arms on this dispatch: `full` (a human + # dispatch, the default) or `none`, which the nightly bump proposal + # passes when it dispatches the floor cells on its branch, so that run + # spends no key on suites the bump does not touch. + keyed: + description: "keyed e2e tier: full, or none" + type: choice + options: [full, none] + default: full # Nightly, so a vendor-side change is caught here rather than by a user's # meter. The e2e suite is the only thing that talks to OpenCode, and the # bundled catalog is the only thing that tracks models.dev — both are exactly @@ -22,31 +32,28 @@ concurrency: jobs: check: + # The name is the ONE required status check on `main`, and this job is + # what it gates: the format, lint and types, the build, the bundle gate, + # and the default install's unit suite with the coverage ratchet. The + # `test` matrix below is not required, so a unit run that only happened + # there could fail without blocking a merge. Renaming the job would leave + # the required check unreported and block every merge. name: check · test · build · gate runs-on: ubuntu-latest - # A hung test otherwise burns the six-hour default. Everything here is + # A hung step otherwise burns the six-hour default. Everything here is # offline and fast; twenty minutes is generous, not tight. timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # No `version:` input: pnpm/action-setup refuses to run when it is given - # both that and a `packageManager` field, and package.json pins the pnpm - # version exactly, in `packageManager`. One source of truth, and the - # action reads it. - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 26 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup - run: pnpm run check - # The unit and mock suites run in the `test` job below, once per harness - # channel; coverage, further down, is the alpha (default) install's. - run: pnpm run build - run: node --experimental-strip-types scripts/check.ts - # Coverage as its own step, not folded into `test`: the ratchet only means - # something if it can fail a build. Uploaded so a PR can be diffed against - # `main` rather than only against the threshold. + # The default install's (alpha, newest pi) one unit run, with the + # coverage ratchet, which only means something if it can fail a build. + # The report is uploaded so a pull request can be diffed against `main` + # rather than only against the threshold. The `test` matrix's + # alpha·newest cell is the same install, so it runs no unit suite. - run: pnpm run test:coverage - name: upload coverage report if: always() @@ -92,15 +99,20 @@ jobs: DSH_PI: ${{ matrix.pi }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 26 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup - name: switch to the ${{ matrix.channel }} install run: node --experimental-strip-types scripts/use-channel.ts "$DSH_CHANNEL" ${{ matrix.pi == 'floor' && '--floor' || '' }} - - run: pnpm run test + # Explicit, not inherited: the client-bundle tests read lib/, which + # otherwise exists only because the `prepare` lifecycle fired during + # the install, and any `--ignore-scripts` hardening would turn that + # into an unrelated failure. + - run: pnpm run build + # One unit run per install. The default install (alpha, newest pi) runs + # it in the required `check` job, with the coverage ratchet; every other + # cell runs it here. + - name: unit suite (every install but the default) + if: matrix.channel != 'alpha' || matrix.pi != 'newest' + run: pnpm run test # The zen/go compatibility matrix, offline: `test/mock/` boots a Vite # server that answers the gateway's endpoints in their own wire formats # and refuses a request whose shape its endpoint does not accept. @@ -124,22 +136,171 @@ jobs: # tree, so a newer patch inside a range is not drift) and prints one warning # per field it compares that moved past `test/support/host-versions.ts`. It # REPORTS and never gates, so it exits 0 whatever it finds: a channel moving - # is the harness's release, not a regression. P-T3 turns the report into a - # proposed bump pull request; the bump follows CONTRIBUTING's release step 3. + # is the harness's release, not a regression. + # + # The nightly run then PROPOSES the bump (`scripts/propose-host-bump.ts`): + # the data CONTRIBUTING's release step 3 lists (the record, alpha's exact + # pins, both lockfiles and the README pair's "today" sentences), as a pull + # request from `chore/host-bump`, whose own CI is the run that checks it + # (both channels, and the e2e-host npm cells). A bump that moves `piFloor` + # or a cordis-family field also dispatches ci.yml on the branch, which adds + # the floor cells a pull request does not run. It never edits a measured + # table: a row the new host turns red is the reviewed part of that pull + # request, named in its body. It proposes nothing from a partial read, waits + # a night for a cut younger than pnpm's one-day release-age window rather + # than lifting the window, and opens a cut that starts a new prerelease tuple + # as a peer-range change for review, never as data. host-drift: - name: harness channels · drift report + name: harness channels · drift report and bump proposal if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 10 + # Two installs (the root and the rc relock) on the proposal path. + timeout-minutes: 30 + # Write access is used by the nightly proposal alone. `actions: write` is + # what `gh workflow run` needs to dispatch the floor cells on the branch. + permissions: + contents: write + pull-requests: write + actions: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 26 - cache: pnpm - - run: pnpm install --frozen-lockfile + # Keep no credential in git's config, for the reason the catalog + # job's checkout gives: the push below names its own token, and a + # persisted GITHUB_TOKEN would be sent ahead of it, landing the + # proposal's CI in action_required. + persist-credentials: false + - uses: ./.github/actions/setup - run: node --experimental-strip-types scripts/report-host-drift.ts + # Two steps, so no token is in this step's environment while the + # proposer resolves and installs a harness cut nobody has reviewed yet + # (its `pnpm install`, the relock, the `prepare` build and the + # formatter): this one writes the proposal, the next pushes it. Both + # run in one job, so this is not an isolation boundary on its own: it + # holds because the install runs none of the cut's code (no dependency + # build script runs unless something approves it: in the pinned pnpm, + # an `allowBuilds` entry, a `build` permission or the allow-all in + # pnpm-workspace.yaml, a PNPM_CONFIG_* variable, or a CLI flag in this + # step or the scripts it runs, the proposer and the rc relock; none + # does, and test/host-drift.test.ts names each of those and the older + # spellings pnpm 12 ignores), so any build approval is a change to this + # step. + - name: plan and write the bump (nightly only) + id: plan + if: github.event_name == 'schedule' + run: | + out="$RUNNER_TEMP/host-bump" + # Exits 0 whenever nothing can be proposed (current, waiting or a + # partial read, each printed), and 1 only when a step it ran + # failed, after putting back every tracked file it wrote. + node --experimental-strip-types scripts/propose-host-bump.ts --write --out "$out" + if [ ! -f "$out/title" ]; then + exit 0 + fi + # A read loop, not `mapfile`: bash 4 has `mapfile`, and the unit + # suite runs this step with whatever bash a contributor has + # (macOS ships 3.2). + files=() + while IFS= read -r file; do files+=("$file"); done < "$out/files" + # The sources the repository formats; the lockfiles are pnpm's. + formatted=() + for file in "${files[@]}"; do + case "$file" in + *.ts | package.json) formatted+=("$file") ;; + esac + done + if [ "${#formatted[@]}" -gt 0 ]; then + pnpm exec vp fmt --write "${formatted[@]}" + fi + echo "proposed=true" >> "$GITHUB_OUTPUT" + # An open proposal is the reviewer's: the re-measure, a README sentence + # both channels share and a range plan's peer clause are commits made + # on its branch, and the record on main stays behind until it merges, + # so every night plans the same bump again. The same target (the + # marker line the body ends with, every proposed record in full; the + # title names only each record's headline value) is therefore left + # alone; a newer target rebuilds the branch only while every commit on + # it is the nightly's own, and is otherwise named in a warning for the + # reviewer. + - name: push and propose the bump (nightly only) + if: github.event_name == 'schedule' && steps.plan.outputs.proposed == 'true' + env: + # PAT, not GITHUB_TOKEN, for the push and the pull request, for the + # reason the catalog refresh gives: a branch pushed as + # github-actions[bot] gets no CI until a human approves the run. + GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + # The dispatch is GITHUB_TOKEN's: workflow_dispatch is one of the + # events it may trigger, under this job's `actions: write`. + DISPATCH_TOKEN: ${{ github.token }} + BOT_NAME: github-actions[bot] + BOT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + run: | + out="$RUNNER_TEMP/host-bump" + title="$(cat "$out/title")" + target="$(cat "$out/target")" + # Whether a bump pull request is open is ASKED, never assumed, as + # in the catalog refresh: an API error must not pass for "none". + # Only this repository's own branch counts: `head=:` + # is the API's owner filter, which `gh pr list --head` lacks, so a + # fork's pull request from a branch of the same name is never read + # as the proposal. One line per pull request, ` `. + if ! open="$(gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&head=${GITHUB_REPOSITORY_OWNER}:chore/host-bump" --jq '.[] | "\(.number) \(.title)"')"; then + echo "::error::could not ask whether a bump pull request is open; nothing was pushed" + exit 1 + fi + if [ "$(grep -c . <<<"$open" || true)" -gt 1 ]; then + echo "::error::more than one open pull request proposes chore/host-bump; nothing was pushed" + exit 1 + fi + number="${open%% *}" + if [ -n "$open" ]; then + # The target is the body's marker line, read by number; a body + # edited on the web carries CRLF line ends. + if ! body="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${number}" --jq .body)"; then + echo "::error::could not read the open bump pull request's body; nothing was pushed" + exit 1 + fi + if tr -d '\r' <<<"$body" | grep -qxF -- "$target"; then + echo "the open bump pull request already proposes this target, left alone (no push, no edit, no dispatch): $title" + exit 0 + fi + # The commits on the branch that main does not have, by author + # AND committer: an amend, a rebase or a fixup squash keeps the + # nightly as the author and records the reviewer as committer, + # while the nightly's own commits carry the bot as both. + if ! identities="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${GITHUB_REF_NAME}...chore/host-bump" --jq '.commits[] | .commit.author.email, .commit.committer.email')"; then + echo "::error::could not read the open bump pull request's commits; nothing was pushed" + exit 1 + fi + foreign="$(grep -vxF "$BOT_EMAIL" <<<"$identities" || true)" + if [ -n "$foreign" ]; then + echo "::warning::the open bump pull request carries commits the nightly did not make, so it is left alone; the registry has moved on to: $title" + exit 0 + fi + fi + # A read loop, not `mapfile` (bash 4), as in the plan step. + files=() + while IFS= read -r file; do files+=("$file"); done < "$out/files" + git config user.name "$BOT_NAME" + git config user.email "$BOT_EMAIL" + git checkout -B chore/host-bump + git add -- "${files[@]}" + git commit -m "$title" + git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" chore/host-bump + if [ -n "$open" ]; then + # By number: a branch name could also name a fork's pull request. + gh pr edit "$number" --title "$title" --body-file "$out/body.md" + echo "the open bump pull request, which only the nightly had touched, now proposes the newer target: $title" + elif ! gh pr create --head chore/host-bump --title "$title" --body-file "$out/body.md"; then + echo "::error::could not open the bump pull request (gh's output is above): chore/host-bump is pushed and proposed by no pull request" + exit 1 + fi + # A pull request runs only the default installs; the floor cells run + # on dispatch, with the keyed tier off: the keyed suites test + # nothing a bump changes, and the nightly run arms them anyway. + if [ -f "$out/dispatch" ]; then + GH_TOKEN="$DISPATCH_TOKEN" gh workflow run ci.yml --ref chore/host-bump -f keyed=none + fi # `engines.node` promises >=24 and every other job runs 26, so the floor was a # claim nothing exercised. The suites that depend on the engine (node:sqlite, @@ -152,12 +313,12 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + - uses: ./.github/actions/setup with: node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile + # The client-bundle tests read lib/: built here, not inherited from the + # install's `prepare` lifecycle. + - run: pnpm run build # Shuffled here, and in order in the main job: the two runs together show a # case that passes only because of what an earlier case left behind. The # seed is printed, so a failure reproduces with `--sequence.seed=<n>`. @@ -239,12 +400,7 @@ jobs: # to approve it (the 2026-11-06 and 2026-11-12 refreshes did). No # other step here needs a git credential. persist-credentials: false - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 26 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup - name: which checks does this pull request gate? (pull requests) id: scope if: github.event_name == 'pull_request' @@ -466,7 +622,8 @@ jobs: # a turn and a probe share, so test/keyed-probes.test.ts holds the # probe's identity to the real fetch patch offline. # - `full`: adds the full keyed suites (protocol-routing, opencode-live, - # dispatch-levels). Nightly and on dispatch. + # dispatch-levels). Nightly and on a dispatch, unless the dispatch's + # `keyed` input is `none` (the bump proposal's floor-cell dispatch). # # The keys are armed only when the tier is not `none`, by the sign-in step: # GitHub mints a short-lived OIDC token for the Console's audience, the @@ -498,10 +655,16 @@ jobs: env: EVENT: ${{ github.event_name }} BASE_REF: ${{ github.base_ref }} + # The dispatch's `keyed` input: empty on every other event. + KEYED_INPUT: ${{ inputs.keyed }} run: | set -euo pipefail case "$EVENT" in - schedule|workflow_dispatch) tier=full ;; + schedule) tier=full ;; + # A human dispatch arms everything; the bump proposal's dispatch + # passes `none`. A value the input does not offer reaches the + # suite's tier reader, which fails the run rather than guess. + workflow_dispatch) tier="${KEYED_INPUT:-full}" ;; pull_request) # Its own command, so a diff that cannot be computed fails the # step rather than reading as "nothing changed". @@ -512,6 +675,7 @@ jobs: # version in the user agent the probes carry (identity.ts), the # anthropic-version every Messages probe sends # (request-shape.ts), and the probes' own inputs, CI arming + # (this workflow and the setup action it installs through) # and the environment reader that arms a key (e2e-env.ts) # included. Left out, and covered by the offline suite and the # nightly `full` run: the generated catalog (catalog-data.ts) @@ -523,7 +687,7 @@ jobs: # it. grep reads a here-string, not a pipe: under # pipefail, `grep -q` exiting on an early match could kill the # writer with SIGPIPE on a long path list and read as a miss. - if grep -Eq '^(src/host/|src/utils/protocols\.ts$|src/utils/plane-model\.ts$|src/identity\.ts$|src/utils/request-shape\.ts$|test/e2e/keyed-probes\.e2e\.ts$|test/support/keyed-probes\.ts$|test/support/keyless-matrix\.ts$|test/support/e2e-env\.ts$|\.github/workflows/ci\.yml$)' <<<"$changed"; then + if grep -Eq '^(src/host/|src/utils/protocols\.ts$|src/utils/plane-model\.ts$|src/identity\.ts$|src/utils/request-shape\.ts$|test/e2e/keyed-probes\.e2e\.ts$|test/support/keyed-probes\.ts$|test/support/keyless-matrix\.ts$|test/support/e2e-env\.ts$|\.github/workflows/ci\.yml$|\.github/actions/)' <<<"$changed"; then tier=probes else tier=none @@ -533,12 +697,7 @@ jobs: esac echo "keyed tier: $tier" echo "keyed=$tier" >> "$GITHUB_OUTPUT" - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 26 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup - name: Sign in to OpenCode Console if: steps.tier.outputs.keyed != 'none' env: @@ -658,7 +817,9 @@ jobs: changed="$(git diff --name-only "origin/${BASE_REF}...HEAD")" # The job's own definition and runner config are in scope: a pull # request that breaks the install, the override or the e2e config - # must run the job it breaks. That includes vite.config.ts: it holds + # must run the job it breaks. That includes the composite setup + # action (.github/actions/), which holds the install now, and + # vite.config.ts: it holds # the pack config that builds lib/index.mjs (the only test that # loads that bundle into a real host is this one, and its externals # keep cordis and schemastery the host's copies) and the test @@ -688,21 +849,14 @@ jobs: # reader its gate, prefix and cell come through (support/e2e-env.ts). # test/keyed-probes.test.ts compiles this pattern and checks it. # A here-string, not a pipe: see the e2e job's tier step. - if grep -Eq '^(src/host/|src/lifecycle\.ts$|src/identity\.ts$|src/cordis-context\.ts$|src/models-discovery\.ts$|src/fetch-patch\.ts$|src/stream-hook\.ts$|cordis\.patch\.yml$|test/e2e/host/|test/e2e/host-binary\.e2e\.ts$|test/support/host\.ts$|test/support/host-versions\.ts$|test/support/e2e-env\.ts$|scripts/host-drift\.ts$|\.github/workflows/ci\.yml$|vitest\.e2e\.config\.ts$|vite\.config\.ts$)' <<<"$changed"; then + if grep -Eq '^(src/host/|src/lifecycle\.ts$|src/identity\.ts$|src/cordis-context\.ts$|src/models-discovery\.ts$|src/fetch-patch\.ts$|src/stream-hook\.ts$|cordis\.patch\.yml$|test/e2e/host/|test/e2e/host-binary\.e2e\.ts$|test/support/host\.ts$|test/support/host-versions\.ts$|test/support/e2e-env\.ts$|scripts/host-drift\.ts$|\.github/workflows/ci\.yml$|\.github/actions/|vitest\.e2e\.config\.ts$|vite\.config\.ts$)' <<<"$changed"; then echo "run=true" >> "$GITHUB_OUTPUT" else echo "run=false" >> "$GITHUB_OUTPUT" echo "no host-seam path changed; the host binary e2e does not run for this pull request" fi - if: steps.scope.outputs.run == 'true' - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - if: steps.scope.outputs.run == 'true' - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 26 - cache: pnpm - - if: steps.scope.outputs.run == 'true' - run: pnpm install --frozen-lockfile + uses: ./.github/actions/setup - name: the ${{ matrix.channel }} channel's record id: versions if: steps.scope.outputs.run == 'true' diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 32010d92..0c6ebed8 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -11,6 +11,9 @@ permissions: jobs: release-please: runs-on: ubuntu-latest + # A run takes about a minute; bounded so a hung API call cannot hold a + # runner for GitHub's six hours. + timeout-minutes: 10 steps: # Scans conventional commits (feat:/fix:) since the last tag, opens a # version-bump + changelog PR; merging it creates the tag + GitHub diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c861630..25824bc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,15 +24,9 @@ jobs: timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # No `version:` input: pnpm/action-setup refuses to run when it is given - # both that and a `packageManager` field, and package.json pins 12.6.0 - # exactly. One source of truth, and the action reads it. - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 26 - cache: pnpm - - run: pnpm install --frozen-lockfile + # pnpm from `packageManager`, Node from .node-version, a frozen install: + # the one setup every CI job runs. + - uses: ./.github/actions/setup # Explicit, not inherited: `lib/` otherwise exists only because the # `prepare` lifecycle fired during install, so any `--ignore-scripts` # hardening would break the release with an unrelated error. @@ -54,16 +48,9 @@ jobs: packages: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # No `version:` input: pnpm/action-setup refuses to run when it is given - # both that and a `packageManager` field, and package.json pins 12.6.0 - # exactly. One source of truth, and the action reads it. - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + - uses: ./.github/actions/setup with: - node-version: 26 - cache: pnpm registry-url: https://registry.npmjs.org - - run: pnpm install --frozen-lockfile - name: tag matches package.json version run: | test "v$(node -p "require('./package.json').version")" = "${GITHUB_REF_NAME}" \ @@ -135,8 +122,8 @@ jobs: - name: verify every published target run: | VER=$(node -p "require('./package.json').version") - # One line: scripts/check.ts reads it to prove this list equals what - # publish-scoped.ts publishes (scripts/scoped-targets.ts). + # One line: test/package-checks.test.ts reads it to prove this list + # equals what publish-scoped.ts publishes (scripts/scoped-targets.ts). pending="dsh-opencode-patch@$VER @viztor/dsh-opencode-patch@$VER @viztor/dsh-opencode@$VER" # Polls, because a successful `npm publish` does not mean the version diff --git a/AGENTS.md b/AGENTS.md index fde343bc..4498c171 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,9 +53,10 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: - `test/support/endpoint-record.ts` — the dated markdown tables and lines both live probe suites write, which CI publishes and `docs/redirect-planes.md` §13 holds; `test/endpoint-record.test.ts` keeps that section in the formatter's columns and row order. - `test/support/live-walk.ts` · `test/support/live-probe.ts` — the live adapter blocks' candidate walk (pure: a protocol walks its candidates until a turn reaches the wire, and one no candidate reached is named, never dropped) and the raw protocol-shaped probe that confirms a withdrawn model before a walk moves past it (its tables are the ones `protocol-routing.e2e.ts` probes with). `live-probe.ts` also holds the rules that decide whether a live case skips, walks on or fails (formerly `test/e2e/verdict.ts`), exercised offline by `test/probe-verdict.test.ts` and `test/live-probe.test.ts`. - `test/e2e/host/` — the measurement bundle (`probe-plugin.mjs`, its manifest and patch) the `e2e-host` job links into a throwaway profile under a temporary HOME, through a relative `link:` path. `test/e2e/host-binary.e2e.ts` is its reader, gated on `DSH_HOST_E2E=1`; it installs no host itself and needs `DSH_HOST_PREFIX`, a prefix holding the installed one. -- `scripts/report-host-drift.ts` — the nightly harness-drift report (CI's `host-drift` job, schedule and dispatch only): reads the registry the way the records are defined (`dsh` as the newest build any dist-tag carries on the record's line, or a newer stable release under the tag the record names for its line graduating, `graduatesUnder`; pi's range, floor and newest, and every cordis-family member at the floor of the range the tagged cluster declares) and prints one `::warning::` per compared field that moved, exiting 0 whatever it finds, because a channel moving is the harness's release, not a regression. Its rules are `scripts/host-drift.ts` (`hostDrift`, pure), held by `test/host-drift.test.ts` with known answers and the job's evaluated condition, and its CLI by `test/mock/host-drift-cli.mock.ts` against a loopback registry (it binds a socket, so it is the mock suite's). P-T3 turns the report into a proposed bump pull request. +- `scripts/report-host-drift.ts` — the nightly harness-drift report (CI's `host-drift` job, schedule and dispatch only): reads the registry the way the records are defined (`dsh` as the newest build any dist-tag carries on the record's line, or a newer stable release under the tag the record names for its line graduating, `graduatesUnder`; pi's range, floor and newest, and every cordis-family member at the floor of the range the tagged cluster declares) and prints one `::warning::` per compared field that moved, exiting 0 whatever it finds, because a channel moving is the harness's release, not a regression. Its rules are `scripts/host-drift.ts` (`readChannel`, the one reading, and `hostDrift`, both pure), held by `test/host-drift.test.ts` with known answers and the job's evaluated condition, and its CLI by `test/mock/host-drift-cli.mock.ts` against a loopback registry (it binds a socket, so it is the mock suite's). The packument reads are `scripts/registry-view.ts`, shared with the proposal below. +- `scripts/propose-host-bump.ts` · `scripts/host-bump.ts` — the nightly bump proposal (the same job, schedule only): plans each channel from the report's own reading (`bumpPlan`: `current`; `blocked` on any unread answer; `waiting` while the new `dsh` or pi is inside pnpm's one-day release-age window, which it never lifts; `range` for a cut that opens a prerelease tuple the peer ranges do not admit, moves the bridge's pi range or sits under a dist-tag the record type does not admit; else `data`; a dist-tag moves only with `dsh`, so a plan is `current` exactly when the report has no line for it) and, with `--write`, moves CONTRIBUTING's release-step-3 data and nothing else — the record (`withRecord`), alpha's pins (`channelManifest`), the README pair's "today" sentences (`withReadmePolicy`, a value both channels share left for review unless both records move it to the same value), the root lockfile and the rc relock — for CI to push as `chore/host-bump`, dispatching the floor cells (with the keyed e2e tier off) when `piFloor` or a family field moved. Both lockfiles hold one pi, so a proposed pi move carries every record (`coupleSharedPi`: a channel waiting only on its own young cut takes a pi-only move when its recorded pi range admits it; one that cannot, a dsh-only proposal included, holds the pi move as `blocked`). It never edits a measured table, and runs no test: the proposed pull request's CI is the run. CI writes the plan in a step with no token in its environment (which holds because nothing approves a dependency build script, so the install runs none of the cut's code; `test/host-drift.test.ts` names an approval in every source the pinned pnpm reads, which are `pnpm-workspace.yaml`, a `PNPM_CONFIG_*` variable and a CLI flag in the step, the proposer or the rc relock, and in the older spellings pnpm 12 ignores), and a second step pushes it; **an open proposal is the reviewer's**: one with the same target (the marker line its body ends with, `bumpTarget`: every proposed record in full, never the title) is left alone, and a newer target rebuilds the branch only while every commit on it is the nightly's own, as author and as committer (only this repository's own branch is the proposal). `host-bump.ts` is pure (`test/host-bump.test.ts`), the text half of `--write` included (`applyBumpTexts`: every edit, each README plan checked against the other channel's record now and as proposed; the CLI only writes what it returns and runs the installs); the CLI runs against a loopback registry in `test/mock/host-drift-cli.mock.ts` (its `--out` files, the pi coupling, and on a scratch copy through `--root` a successful `--write`'s `files` list and a failed one's put-back); the two CI steps are executed with stub tools in `test/host-drift.test.ts`. - `scripts/use-channel.ts` — switches the dev install: `rc` or `alpha` installs that channel's committed lockfile (unpatched pi at the newest version the bridge's range admits, what an npm user runs), and `--floor` the same cluster with pi at the range's floor, never committed. It restores every tracked file it touched, so a switch leaves no diff. Its pure half is `scripts/channel-manifest.ts` (`channelManifest`, `pinFor`, `workspaceWithPi`, and `withoutHostPackages`, the lockfile `rc --relock` starts from), held by `test/use-channel.test.ts`. A relock keeps the cordis family at the record's floors because `package.json` pins every member the install resolves (include, group and cosmokit too, which arrive transitively and would otherwise resolve to the newest patch inside their `~` ranges) and `pinFor` moves each with the channel. `rc --relock` belongs in the same commit as ANY change to `package.json`'s dependencies or `pnpm-workspace.yaml`'s catalog or overrides, not only a channel bump: the rc lockfile carries every specifier the root one does and CI installs it frozen, and `test/harness-channel.test.ts` fails, naming the command, when they disagree. Dependabot ignores the cluster and pi, which move only as a channel bump. **This repository carries no pi patch** (D14''): an npm install runs pi unpatched, the harness's patch reaches users only through the desktop app and touches nothing the plugin reads, and a version-keyed patch would break every pi bump. `test/architecture.test.ts` keeps it that way by failing if `src` ever reads a streamed tool-call delta, partial arguments or a thought signature. -- `src/config.ts` · `src/config-values.ts` · `src/utils/guards.ts` — the schema and its defaults; dependency-free readers and type guards. The client bundle may import these, and it does — along with the contract and presentation modules (`usage-contract.ts`, `settings-copy.ts`, `settings-fields.ts`, `usage-ui.ts`, `go-limits.ts`, `opencode-store-contract.ts`). The rule is not a list of names: **the client may import a module that reaches nothing host-only** — no cordis context, no credential seam, no node built-in — and the bundle gate is what enforces it, by failing the build rather than by anyone remembering. An earlier version of this line named two files as the only ones allowed, which the tree had already outgrown. +- `src/config.ts` · `src/config-values.ts` · `src/utils/guards.ts` — the schema and its defaults; dependency-free readers and type guards. The client bundle may import these, and it does — along with the contract and presentation modules (`usage-contract.ts`, `settings-copy.ts`, `settings-fields.ts`, `usage-ui.ts`, `go-limits.ts`, `opencode-store-contract.ts`). The rule is not a list of names: **the client may import a module that reaches nothing host-only** — no cordis context, no credential seam, no node built-in — and `test/architecture.test.ts`'s client-bundle block ("the client bundle reaches nothing host-only") is what enforces it: it walks the entry's import graph, and the unit suite fails the build rather than anyone having to remember. An earlier version of this line named two files as the only ones allowed, which the tree had already outgrown. ## `src/utils/` — pure by construction, enforced by test @@ -64,7 +65,7 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: **The membership test for `utils/` is a property, not a vibe:** no `node:*`, no `@deepseek-ai/cordis`, no `process.env`, no `fetch`, no reference to `console` or a global timer (`setTimeout`/`setInterval`/`setImmediate` and their `clear*` counterparts, called or passed as a value, bare or through `globalThis.`/`window.`/`global.`/`self.`), no relative import that resolves outside the folder (`guards.ts` lives inside it). A utility that needs a clock or a logger takes it as a parameter, the way `utils/retry.ts` takes its `sleep` and `warn` and leaves the timer to the caller. `test/architecture.test.ts` walks the folder recursively, resolves each import from the importing file's own directory, and fails the build otherwise — verified by planting a violation of each kind and watching it fail. -It is the same trick `scripts/check.ts` already uses for the client bundle, at finer grain. +It is the same trick `test/architecture.test.ts` already uses for the client bundle's import graph, at finer grain. **Why `utils` and not `decisions`/`pure`/subject folders.** `utils` is the name a reader already has for these, and needs no glossary. What stops it becoming the usual dumping ground is the gate above: `fetch-patch.ts` is 498 lines and everything in it is _arguably_ a utility, and the gate says no because it reads a context. Subject folders (`gateway/`, `identity/`) describe what code is _about_, which is a real second axis — adopt it when a folder outgrows reading, not at 37 modules. @@ -106,6 +107,8 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `src/go-limits.ts` — the allowance lookup, the label normalization and the money formatting. **One lookup, and it is the tested one**: `attachAllowance` in `usage.ts` used to inline the same expression, and the copy nothing called was the tested one; it now goes through `allowanceFor`. The row's label is the model the allowance belongs to — the picked one, by the catalog's spelling — because a figure beside another model's name states something false about the money. - `src/go-limits-data.ts` — the Go plan allowances, per model and per tier, **generated** from the vendor's own page by `scripts/generate-go-limits.ts`; never hand-edit it, and regenerate with `pnpm run limits:shim -- --write`. The scheduled freshness job is what notices when the page moves, which it did: the docs went under `/v2/docs/console/` and serve HTML now, so the generator reads a table off that page rather than the markdown it used to. +- `scripts/generator-kit.ts` · `scripts/catalog-shim.ts` — what the two generators and `scripts/verify-catalog.ts` share: their arguments (`generatorArgs`), the source read (`readSource`), and the ONE staleness rule (`stale`: canonical data differs; key order and formatting never count), with the exit and action it leads to (`outcome`: a file current in data and in text is never rewritten; without `--write` a template change is still `current`, and with `--write` current data in text that is not the renderer's is `rewrite`, which only the limits generator, the one whole-file renderer, does). `catalog-shim.ts` is the catalog generator's pure half — the renderer and the splices, moved verbatim, and `shimStale` over every export the shim carries, the plane and family endpoints included. `test/generators.test.ts` holds both, spawns both generators with `--from` (a source that disagrees with the shipped data exits 1; the limits generator's doc made from the shipped table exits 0) and the limits generator's `--write` from a scratch copy of `scripts/` and `src/` (a template change rewritten, a moved figure written), round-trips each renderer through the formatter against the committed file (`scripts/go-limits-render.ts` is the limits generator's renderer), and spawns `verify-catalog` (offline only: `--from`, `--offline`) and makes it fail. +- `scripts/session-band.ts` — `verify-session-band.ts`'s pure half: the vendor's id construction, ported, the four band facts with the answer each must have (`bandMismatches`), and the verdict the script prints (`bandVerdict`): "CONFIRMED" only when there is no mismatch, otherwise one `MISMATCH:` line each and exit 1. The unit run makes the verdict fail on a planted derivation and spawns the script in its passing state. - `scripts/freshness-scope.ts` — which of CI's three freshness checks (`catalog:shim`, `limits:shim`, `cli:version`) a pull request is GATED on: the ones whose inputs (the check's script and the local modules it loads, walked from the source as Node loads it — types erased by Node's own stripper, specifiers read by `es-module-lexer` — plus `src/identity.ts` for the CLI version; never the generated files) its own diff touches. The rest only warn, because vendor drift is not the pull request's regression: for the two generated tables the nightly run regenerates them and proposes the refresh, and for the CLI version it warns and the bump is a human pull request, which then gates. The gates fail closed: each runs unless the scope step answered `report`. The CLI version's offline half — every copy in the tree agrees with the pin, `cli:version -- --copies` — gates every pull request, since it skips the generated tables and only the repository can break it. `test/freshness-scope.test.ts` walks the real generators, runs the scope step's own shell and the CLI it calls, and evaluates the workflow's step conditions. - `src/host/route-claim.ts` · `src/host/plane-provider.ts` · `src/host/describe-graft.ts` · `src/host/host-modules.ts` — the plugin owns the plane routes (D13). The route claim appends ONE `internal/config` listener that reads either cordis core's call shape (`src/utils/config-run.ts`: `function (this: Fiber, raw, next)` on the OLD core, `(fiber, next)` with `this` null on the NEW one), calls `next()` exactly once, re-appends itself whenever pi-ai reaches `internal/status` ACTIVE so it stays inner to pi-ai's own checking listener (the new registration first, the old one released only once the host accepted it, so a refusal while the plugin's fiber is going away throws nothing into pi-ai's emit), and hands `llm-pi-ai`'s fiber a copy of what `next()` answered without the `opencode`/`opencode-go` entries it TAKES — an entry another plugin owns (D27: pi-ai's row resolving `credentials` in a realm the plugin's context does not, compared by the key each context reads off cordis's realm map so a group's `isolate` counts, or an entry carrying the `x-opencode-plugin` header, `src/utils/plane-ownership.ts`) stays pi-ai's, is logged once, and is never counted by a restart, a handback or the reconcile; a commit run releases at once a plane the plugin registered that its candidate no longer lets it take, so pi-ai's registration of it meets no duplicate; and the claim's read-only `steppedAside()` accessor makes the stream hook run such a turn undecorated under a `foreign` turn state (`turn-store.ts`), which the fetch patch passes through before its URL claim, as it does any marked request, and keeps the 1.x discovery wrapper off its Fetch; a commit run (the fiber's raw config, the loader's stored row config, or any run while the fiber is not ACTIVE) never throws, and every fiber config is read through the feature-detected accessors, validates a Settings edit to a plane with pi-ai's own checks, and registers the plane adapter for exactly the declared planes through `registerAdapter`/`handle.replace`; on dispose it hands the planes back by restarting pi-ai (never on a reload). `plane-provider.ts` is `PlaneAdapter`, a subclass of the host's `PiAiAdapter` over pi's own `createProvider` and four protocol implementations, with the credential seams that mirror pi-ai's (D20). Its stand-in (`test/support/route-claim-stand-in.ts`, driving `test/route-claim*.test.ts`; the new core's call-shape cases are in `test/route-claim-listener.test.ts`) builds fibers of both cores — `piFiber` (the OLD core's `_config` and resolved `config`, driven through `oldRun`) and `newPiFiber` (the NEW core's raw `config`, `rawConfig` and `effectiveConfig`, no `_config`, driven through the stand-in's `waterfall`, whose per-listener `next` throws on a second call, and its `internal/status` emit) — and `test/host-planes.test.ts` runs the claim on the installed host, whichever core it is. `describe-graft.ts` is the one read-only `settings.describe` wrapper that keeps Settings showing the planes (D18). `host-modules.ts` is where the host's own modules are found — pi-ai's on its loader entry, pi, dsh-llm and the loader from the wrapper's own tree — behind the replaceable seam in `src/cordis-context.ts` (`replaceHostModuleSource`), which is how tier A runs the claim. What a protocol sends, Google's thinking control included, is the host's pi's own (D26): where a host's pi lacks something the gateway needs, the suite records a `[HOST_LIMITATION]` row and skips the cases that would exercise it by pi's own exported predicate, never a version compare. - `src/purge-service.ts` · `src/purge-contract.ts` — the card's "purge deprecated models" action, and the shape it answers with. It **rewrites the profile**, deleting the declared model entries the live catalog has retired, through `configEditor` (the same service the settings UI writes with). That is deliberate: a read-time filter would leave the declaration in place, and then the only way to explain a missing row is to find the filter. One source of truth, and it is the configuration. It never rewrites a plane entry another plugin owns (D27: the `x-opencode-plugin` marker, or a row whose credentials resolve in a realm the service's context does not), nor a reference to a route only such entries declare. @@ -138,7 +141,7 @@ pnpm run build # build lib/ (host bundle + client bundle) # `vp pack` alone is NOT enough: it emits lib/client.cjs for a type:module # package, and the web client loader serves `${package}/client.js` and requires # the .js extension. `pnpm run build` is `vp pack` plus the rename that fixes it. -node --experimental-strip-types scripts/check.ts # bundle gate +node --experimental-strip-types scripts/check.ts # bundle gate: lib freshness, consumer install (both halves), size ``` `lib/` is built output and is not committed; the host loads `lib/index.mjs`, so a source change is not deployed until `pnpm run build` runs — and, as above, not until the process restarts. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b2e0269..d6c16382 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,18 +13,22 @@ All tasks go through `pnpm` (which delegates to the Vite+ toolchain): ```sh pnpm install # install dependencies pnpm run check # format + lint + types; must be zero *errors* (warnings are reported, not gating) +pnpm run typecheck # tsc over src/, scripts/ and every test file, the .tsx ones included +pnpm run build # vp pack + client rename -> lib/index.mjs, lib/index.d.mts, lib/client.js (before the tests: the client-bundle tests read lib/) pnpm run test # Vitest suite, must be fully green and deterministic pnpm run test:coverage # the same run, with the coverage ratchet enforced pnpm run test:shuffle # the same run in a random order; reproduce a failure with --sequence.seed=<n> pnpm run test:mock # gateway mock suite: offline, loopback only, runs in CI -pnpm run build # vp pack + client rename -> lib/index.mjs, lib/index.d.mts, lib/client.js +node --experimental-strip-types scripts/check.ts # bundle gate: lib freshness, consumer install (both halves), size pnpm run test:e2e # opt-in end-to-end suite; talks to the real OpenCode gateway pnpm run test:e2e:host # opt-in: the published host binary with the probe (see "The e2e-host job") pnpm run catalog:shim # is src/catalog-data.ts current? (-- --write regenerates it from models.dev) pnpm run limits:shim # is src/go-limits-data.ts current? (-- --write regenerates it) -pnpm run cli:version # is the pinned OpenCode CLI version the published one? +pnpm run cli:version # is the pinned OpenCode CLI version the published one? (-- --copies: does every copy agree with it?) ``` +CI runs the same steps through one composite setup action (`.github/actions/setup`: pnpm from `packageManager`, Node from `.node-version`, a frozen install), after each job's own checkout, and builds explicitly before anything that reads `lib/`. The unit suite runs once per install: the default install (alpha, newest pi) runs it as `test:coverage`, which is the ratchet, in the `check · test · build · gate` job (the one status check `main` requires, so a red suite or a coverage drop blocks a merge), and every other cell of the `test` matrix runs `pnpm run test`. `pnpm run typecheck` is for local use only: CI type-checks the same tsconfig program through `pnpm run check` (`vp check`, whose lint config has `typeCheck` on), and runs no `typecheck` script. + > Note: in some shells `pnpm exec` stalls; invoke the binary directly if so: `node node_modules/.pnpm/vite-plus@*/node_modules/vite-plus/bin/vp <cmd>`. ## End-to-end suite @@ -38,7 +42,7 @@ OPENCODE_E2E=1 OPENCODE_API_KEY=… OPENCODE_GO_API_KEY=… pnpm run test:e2e - `OPENCODE_E2E=1` is required; without it every case skips, so a bare `pnpm run test:e2e` is a safe no-op. - `OPENCODE_API_KEY` (Zen) arms the live `/models` enrichment case and the **paid** protocol-routing cases; `OPENCODE_GO_API_KEY` (Go plan) arms the live `/usage` case. Both keys also arm the keyed probes (`keyed-probes.e2e.ts`, each plane with its own key) on the `probes` tier and above, while the full keyed suites need the `full` tier (`OPENCODE_E2E_KEYED`, below). Each block skips when its key is absent, which is why the CI `e2e` job stays green on fork PRs (secrets are not exposed to them) while still checking the endpoints answer. - The **free-tier and unmetered** routing cases run keyless on purpose: measured 2026-10-06, a paid model answers `401 AuthError` on every endpoint without a credential, so keyless probing cannot tell the right PROTOCOL endpoint from a wrong one. Keyless protocol discrimination is real only for the free classes. What a paid model's keyless answer does still tell is whether a PATH exists at all — a removed path answers 404 — which is the one thing the keyless matrix below reads. -- `OPENCODE_E2E_KEYED` is the tier of what may spend a key: `none` runs only the keyless cases, `probes` adds the minimal keyed probes (`keyed-probes.e2e.ts`), and `full` adds the full keyed suites (`protocol-routing`, `opencode-live`'s keyed blocks, `dispatch-levels`' live block). Unset means `full`, so a local run with keys arms everything; any other value fails the run. CI sets it per event — `probes` on a pull request that touches `src/host/**`, `src/utils/protocols.ts` (the legacy URLs the probes send and the protocol each probe model speaks), `src/utils/plane-model.ts` (the family the plugin dispatches to, `AUTO_ENDPOINT_FAMILY`, which the probes must keep matching), `src/identity.ts` (the CLI version in the user agent the probes carry), `src/utils/request-shape.ts` (the `anthropic-version` every Messages probe sends) or the probes' own inputs; `full` nightly and on dispatch; `none` otherwise — and passes the secrets only when it is not `none`. Nothing else arms them. Left out, and covered by the offline suite and the nightly run, which arms everything: the generated catalog (`src/catalog-data.ts`) and the live model choice (`src/models-catalog.ts`), which pick the probes' modern URLs and models and whose paths the keyless matrix checks on every pull request; and the `OPENCODE_UA` alias in `src/session.ts`, which a turn and a probe share, so `test/keyed-probes.test.ts` holds the probe's identity to the real fetch patch offline. +- `OPENCODE_E2E_KEYED` is the tier of what may spend a key: `none` runs only the keyless cases, `probes` adds the minimal keyed probes (`keyed-probes.e2e.ts`), and `full` adds the full keyed suites (`protocol-routing`, `opencode-live`'s keyed blocks, `dispatch-levels`' live block). Unset means `full`, so a local run with keys arms everything; any other value fails the run. CI sets it per event — `probes` on a pull request that touches `src/host/**`, `src/utils/protocols.ts` (the legacy URLs the probes send and the protocol each probe model speaks), `src/utils/plane-model.ts` (the family the plugin dispatches to, `AUTO_ENDPOINT_FAMILY`, which the probes must keep matching), `src/identity.ts` (the CLI version in the user agent the probes carry), `src/utils/request-shape.ts` (the `anthropic-version` every Messages probe sends) or the probes' own inputs (the composite setup action under `.github/actions/` among them); `full` nightly; on a dispatch, its `keyed` input (`full` when empty, and `none` from the nightly bump proposal's floor-cell dispatch); `none` otherwise — and passes the secrets only when it is not `none`. Nothing else arms them. Left out, and covered by the offline suite and the nightly run, which arms everything: the generated catalog (`src/catalog-data.ts`) and the live model choice (`src/models-catalog.ts`), which pick the probes' modern URLs and models and whose paths the keyless matrix checks on every pull request; and the `OPENCODE_UA` alias in `src/session.ts`, which a turn and a probe share, so `test/keyed-probes.test.ts` holds the probe's identity to the real fetch patch offline. - `OPENCODE_E2E_RECORD_DIR`, when set, is where the keyless matrix and the keyed probes write their dated tables (`keyless.md`, `keyed.md`); CI publishes them to the run summary and the `endpoint-families` artifact, and a run's tables are pasted into `docs/redirect-planes.md` §13. - `OPENCODE_ZEN_BASE_URL` / `OPENCODE_GO_BASE_URL` retarget `opencode-live` and `protocol-routing` at a mirror. The keyless matrix and the keyed probes measure the vendor's own paths, which a mirror's base cannot name, so both skip while either is set. @@ -61,7 +65,8 @@ The e2e files, one job each: - `protocol-compat.mock.ts` — plane × protocol × endpoint family, driven through pi's own protocol implementations. - `patched-fetch-headers.mock.ts` — a local `node:http` listener, so the _outgoing_ header set crosses a real socket. The live gateway cannot echo a request back; everywhere else in the suite the injected headers are asserted against a captured `fetch`. - `refusal-classification.mock.ts` — the refusal classifier's verdicts on the gateway's bytes over a real `node:http` socket. -- `host-drift-cli.mock.ts` — the nightly host-drift report's CLI against a loopback registry, as CI runs it; the rules it prints are `test/host-drift.test.ts`'s, on the registry views in `test/support/host-drift-fixture.ts`. +- `host-drift-cli.mock.ts` — the nightly host-drift report's CLI against a loopback registry, as CI runs it; the rules it prints are `test/host-drift.test.ts`'s, on the registry views in `test/support/host-drift-fixture.ts`. The bump proposal's CLI (`scripts/propose-host-bump.ts`) runs the same way: a data plan, a cut inside the release-age window, a refusing registry, the `--out` files CI's push step reads (with no tracked file touched without `--write`), and, on a scratch copy through `--root`, a `--write` whose pnpm step fails putting every file back. Its rules are `test/host-bump.test.ts`'s. +- `cli-version.mock.ts` — `scripts/check-vendor-version.ts` against a loopback registry through `--registry`: the dist-tags request, a pinned `latest`, a bump, a generation change (`latest` left the pinned major) and a refusing registry. ## The real-host harness @@ -95,7 +100,7 @@ What every case does: - **Cells, per channel, pi unpatched in both.** `npm` is the published host exactly as npm resolves it, cordis and pi included, what an npm user runs, and asserts the `newest` rows while its pi is the channel record's; a pi published since the record still has to sit in the bridge's range, but the per-model rows (a newer pi's own entry can still move a model's metadata where the catalog states none) are skipped with a warning, and the nightly `host-drift` job names the new pi. The listings before and after a turn, the save rows (the catalog-unknown id is accepted on the first try) and the row that the plane provider is built from the host prefix's own pi are asserted in every cell, since the plugin owns both planes there on either host core and none of them depends on which ids pi describes; a `[BASELINE]` row asserts that both planes are the plugin's `PlaneAdapter` whichever `internal/config` call shape the host dispatched (next bullet). `floor` is the tree the desktop app ships: npm `overrides` (`floorOverrides` in `host-versions.ts`) force the channel's whole cordis family (cordis, the loader, the include, group and timer plugins, cosmokit, schemastery) at the record's versions, which are the floors of the `~` ranges the published cluster declares (what the harness vendors at the tag), and pi with its telemetry package at the range's floor; the job and the e2e assert that npm applied each one, and the cell asserts every row of the channel's floor cell. In both cells the e2e also holds each cordis-family field to the floor of the range the installed published package declares for it, which no override rewrites, with one rule for cosmokit: its declarer is cordis, so it is checked only where the installed cordis is the record's, and elsewhere only the nightly drift report checks it (measured 2026-11-12, every cell's installed cordis is its record's, so cosmokit is checked in all four). It is cached by channel, `dsh` and a hash of those overrides, so a cache filled before the record or its overrides moved is never reused; the rest of the tree is what npm resolved when the cache was filled. The installed `dsh` must be the channel record's, so a prefix of the wrong channel fails by name. The save rows skip only where the host itself cannot save (next bullet). - **Settings' fetch.** The probe also records what Settings → Models → Fetch offers on each plane, through the gateway's Remote face. Every cell asserts the whole servable catalog, each id once, in the Remote's five fields, and the order: the list the prefix's own pi exports (`getBuiltinModels`, read from the prefix, not the checkout) first, then the catalog models that pi lacks. The wrapper complements a fetch only while the route claim has engaged (`RouteClaim.claiming()`), which it does on both host cores, so each row first asserts both planes on the plugin's `PlaneAdapter`. It reads engaged through a handover's window too, while the newly served row's restart is in flight: the fetch is complemented until the row unloads and the plane's catalog alone while it does, as in the mount's restart. Only a served row whose restart has settled still holding a plane (a call shape the claim's listener does not speak) leaves the fetch pi's own list. - **The host's own failures are recorded, not worked around.** Only the `npm` cell lets cordis float, as the published host's `~` ranges do for a user, and the job reports what it resolved. Where that leaves the host unable to run its own save path, the run records a `[HOST_LIMITATION]` row (titled with the channel and the `dsh`) and skips the save rows. It is detected from what the host answered, never from a version (D26): the plane edit and a control edit no plugin's listener reads (the probe's own entry) both fail with the same `TypeError` thrown by `@deepseek-ai/dsh-app-boot`; where the host saves, the row skips and a `[BASELINE]` row asserts the control was accepted. It was alpha's `0.2.2-alpha.2` as npm installed it: cordis `4.0.6-alpha.2` calls `internal/update` in a new shape while that `dsh-app-boot` registered its listener in the old one, so every config-editor save threw `next is not a function`. `0.2.2-alpha.3` and `0.2.2-rc.2` ship the cordis core their `dsh-app-boot` was written for: measured 2026-11-12, all four cells (both channels, `npm` and `floor`) save the control edit, so the row skips everywhere and the save rows run. The same core hands `internal/config` listeners the fiber and reads the config from `next()`, where the older one bound the fiber as `this` and passed the config; the route claim speaks both (`src/utils/config-run.ts`). The probe records the shape of every dispatch (`configHook`, the first; `configHooks`, a count per shape) with the claim's own fiber predicate, and a `[BASELINE]` row requires one known shape throughout and both planes on the plugin's `PlaneAdapter`. It was a `[KNOWN_DEFECT]` row asserting that pi-ai served both planes on the new core, with the rows that need the claim skipping by name, until the claim spoke that shape. -- **Triggers.** Dispatch and the nightly run take both cells of both channels. A pull request takes the `npm` cell of both channels, and only when it touches the host seam — `src/host/**` (the route claim, the plane adapter, the Settings graft, the host-module loaders) and the modules whose behaviour depends on the real loader and apply order beside it, `src/lifecycle.ts`, `src/identity.ts`, `src/cordis-context.ts` and `src/models-discovery.ts`, and the two modules that still install host-level patches until they move under `src/host/` (`src/fetch-patch.ts`, the global fetch wrapper whose order the probe reads, and `src/stream-hook.ts`) (every other row producer, such as the pure plane model and profile in `src/utils/`, `src/key-capture.ts`, `src/models-catalog.ts` or `src/config.ts`, is covered on each pull request by the in-process characterization of the same rows, and by the nightly run), `cordis.patch.yml`, `test/e2e/host/**`, `test/e2e/host-binary.e2e.ts`, `test/support/host.ts` (the rows the binary e2e asserts), `test/support/e2e-env.ts` (the reader the binary e2e's gate, prefix and cell come through; its channel is `host-versions.ts`'s `selectedHost`), `test/support/host-versions.ts` (what the job installs), `scripts/host-drift.ts` (the family table the binary e2e asserts against the installed manifests), `.github/workflows/ci.yml`, `vitest.e2e.config.ts` or `vite.config.ts` (the job's own steps and runner config; `vite.config.ts` also holds the pack config that builds `lib/index.mjs`, the bundle only this job loads into a real host, and the test settings the e2e config inherits) (scoped by a step: a workflow-level path filter would silence every other job; a diff the step cannot compute fails it rather than skipping the job). `package.json` is left out because every dependency bump touches it, so a change to its `test:e2e:host` script alone is exercised only by the nightly run. It needs no secret. +- **Triggers.** Dispatch and the nightly run take both cells of both channels. A pull request takes the `npm` cell of both channels, and only when it touches the host seam — `src/host/**` (the route claim, the plane adapter, the Settings graft, the host-module loaders) and the modules whose behaviour depends on the real loader and apply order beside it, `src/lifecycle.ts`, `src/identity.ts`, `src/cordis-context.ts` and `src/models-discovery.ts`, and the two modules that still install host-level patches until they move under `src/host/` (`src/fetch-patch.ts`, the global fetch wrapper whose order the probe reads, and `src/stream-hook.ts`) (every other row producer, such as the pure plane model and profile in `src/utils/`, `src/key-capture.ts`, `src/models-catalog.ts` or `src/config.ts`, is covered on each pull request by the in-process characterization of the same rows, and by the nightly run), `cordis.patch.yml`, `test/e2e/host/**`, `test/e2e/host-binary.e2e.ts`, `test/support/host.ts` (the rows the binary e2e asserts), `test/support/e2e-env.ts` (the reader the binary e2e's gate, prefix and cell come through; its channel is `host-versions.ts`'s `selectedHost`), `test/support/host-versions.ts` (what the job installs), `scripts/host-drift.ts` (the family table the binary e2e asserts against the installed manifests), `.github/workflows/ci.yml`, `.github/actions/` (the composite setup action, which holds the install), `vitest.e2e.config.ts` or `vite.config.ts` (the job's own steps and runner config; `vite.config.ts` also holds the pack config that builds `lib/index.mjs`, the bundle only this job loads into a real host, and the test settings the e2e config inherits) (scoped by a step: a workflow-level path filter would silence every other job; a diff the step cannot compute fails it rather than skipping the job). `package.json` is left out because every dependency bump touches it, so a change to its `test:e2e:host` script alone is exercised only by the nightly run. It needs no secret. To run it locally, install a channel's host into a prefix outside the checkout, then point the suite at it (placeholder paths; `pnpm` must be on `PATH`). The first line reads the channel's record from `test/support/host-versions.ts`, the same way the CI job does, so the snippet never names a version itself: @@ -159,7 +164,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t The `catalog freshness` job runs three checks against what the vendor publishes: `catalog:shim` (the bundled catalog, `src/catalog-data.ts`), `limits:shim` (the Go allowances, `src/go-limits-data.ts`) and `cli:version` (the pinned CLI version in `src/identity.ts`). The vendor moves all three most days, so what a run does with a mismatch depends on whether the change under test could have caused it. - **A pull request is gated on a check only when its own diff touches that check's inputs.** The inputs are the check's script and every local module it loads, transitively, plus `src/identity.ts` for `cli:version`, which the script reads as data. The edges are the ones Node loads: the source's types are erased by Node's own stripper and the specifiers read by a real lexer, so a whole-statement `import type` loads nothing and is no edge, and an import written in a string or template is not one either; anything else is, so the set reaches further than the generator's own folder: the catalog parser loads `src/config.ts`, so a settings change gates `catalog:shim`. `scripts/freshness-scope.ts` derives the inputs from the sources and answers `gate` or `report` for the diff (`git diff --name-only HEAD^1 HEAD` on the merge ref). Change `src/models-catalog.ts` and forget to regenerate, and the pull request fails; that omission is what the gate exists to catch. -- **One half of `cli:version` gates every pull request.** The script checks two things: that every copy of the CLI version in `src/`, `test/`, `docs/`, `scripts/` and `README.md` agrees with `src/identity.ts`, and that the pin is the published version. The first is offline and skips the two generated tables (they hold the vendor's data, not copies the repository maintains), so only the repository can break it, and `pnpm run cli:version -- --copies` runs on every pull request as a gate. Only the registry comparison is scoped as above, and when it only warns, the warning quotes the script's own `error:` line. +- **One half of `cli:version` gates every pull request.** The script checks two things: that every copy of the CLI version in `src/`, `test/`, `docs/`, `scripts/` and `README.md` agrees with `src/identity.ts`, and that the pin is the published version. The first is offline and skips the two generated tables (they hold the vendor's data, not copies the repository maintains), so only the repository can break it, and `pnpm run cli:version -- --copies` runs on every pull request as a gate. It also refuses any literal in the 2.x User-Agent form, `opencode/<channel>/<version>/<client>`: no constant pins that generation, so a concrete one is a claim nothing checks (write the placeholder; the client-generation table is P7's). The second reads the registry's dist-tags for `opencode-ai`: the pin must be `latest`; a `latest` on another version of the pinned major is a bump, and one that left the major is a generation change, named as such, since the User-Agent format moved with it; the snapshot builds under dist-tags (`0.0.0-<tag>-<stamp>`: the 2.x line's `dev`, `next` and `beta`, and many older 1.x feature branches) are listed as not claimed, with no generation or User-Agent format asserted for any of them. Only the registry comparison is scoped as above, and when it only warns, the warning quotes the script's own `error:` line. - **Otherwise the check reports.** A stale file is a `::warning::`, the job stays green, and the nightly run regenerates both files and opens the refresh as a pull request of its own. A stale CLI pin is only warned about, by every run including the nightly; bumping it is a human pull request to `src/identity.ts` and its copies, which is then gated, because the pin is an input. Vendor drift is not a regression your pull request introduced, so it is not a reason to fail it. - **The generated files are never inputs.** A pull request that carries only a refresh reports. One that changes `src/catalog-data.ts` or `src/go-limits-data.ts` without touching what generates it gets a warning naming the file, because those files are never edited by hand. - **A push to `main` reports.** The nightly run regenerates the two generated files and opens the refresh as a pull request; for the CLI version it warns. A manual dispatch only reports the CLI version. None of these paths gates, for the same reason. @@ -177,7 +182,7 @@ git diff --name-only main... \ 1. Use conventional commits (`feat:`, `fix:`) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. 2. Avoid manual releases: release-please anchors on the latest GitHub **Release**, not the latest tag, so a bare tag is invisible to it (`AGENTS.md`, "Releasing"). If one is unavoidable, bump `version` in `package.json`, commit, tag, push the tag, and create the GitHub Release in the same step (`gh release create vX.Y.Z --generate-notes`). Never touch `CHANGELOG.md`: release-please owns it. -3. On every release, after smoke-testing (registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds), check the **Supported: the latest release of both DeepSeek Harness channels** line and the **Support policy** paragraph under "Compatibility & Verification" in both READMEs. Their "today" versions, pi ranges and floors, and the default install named a paragraph below, are copied from the channel records in `test/support/host-versions.ts`, and `test/harness-channel.test.ts` holds them to the records. A channel bump **inside the prerelease tuples the peer ranges already admit** (a new `dsh` cut whose cluster changes no host hook, say) moves version data, and this is its whole set: for `rc`, the rc record (with the dist-tag that carries the build: `next` while the rc line publishes ahead of `latest`) and `channels/rc/pnpm-lock.yaml` (`scripts/use-channel.ts rc --relock`); for `alpha`, the alpha record, `package.json`'s exact `@deepseek-ai/*` pins (the cordis family's among them) and the root lockfile (`pnpm install`); for either, those README sentences in both languages, and `minimumReleaseAgeExclude` lines in `pnpm-workspace.yaml` for the exact versions when the cut is younger than pnpm's one-day release-age window. A pi move is the same data: `pnpm install` and `--relock` keep the pi a lockfile already satisfies, so move it with `pnpm update @earendil-works/pi-ai --no-save` (the root lockfile; `package.json` keeps the union range), then `scripts/use-channel.ts rc --relock`, which carries the root's pi into the rc lockfile, and set each record's `pi`. The cordis family (cordis, the loader, include, group, timer, cosmokit, schemastery) moves only with `dsh`, to the floors of the `~` ranges the new tag's cluster declares (the drift report prints them; they are what the harness vendors at that tag): a newer family patch inside the same ranges is what the `e2e-host` npm cell already runs and logs, never a record bump. A relock keeps the family at the record's floors, because `package.json` pins every member the install resolves and `pinFor` moves each with the channel (the timer plugin, resolved by nothing here, has no pin). The same update is what leaves one pi when a new bridge raises its floor above the locked one. The F10 characterization (`test/mount-protocols.test.ts`) is keyed by pi line, so a pi inside a line leaves it alone; a pi outside every line is a new major, and a reviewed change. **Data only while the host behaves the same.** A bump stays version data while tiers A and B and the binary e2e stay green on the new install. A pull request runs only the default installs, so a bump that moves `piFloor` or any cordis-family field is green only after a `workflow_dispatch` run on the bump branch, which adds both channels' floor cells to the `test` and `e2e-host` jobs. One that turns them red because the host behaves differently is a reviewed change made in the bump's own pull request and named in its body: a pi whose catalog or bases moved (a model added or dropped, a gap id learned) re-measures the `newest` cell's column of `WHOLE_CATALOG` (`test/host-shapes.test.ts`) and of `PI_GAPS_BY_CELL` (`test/support/host.ts`), and a moved `piFloor` re-measures that floor's columns of the same two; no S0 row follows pi, since the plugin places and routes every declared id itself (D26). A cluster whose bridge defaults moved updates `PI_AI_DEFAULTS` (`test/support/host.ts`), which `test/host-shapes.test.ts` pins against the installed pi-ai and which feeds `s0PluginFor`'s catalog-unknown rows; any other row change goes in `s0PluginFor`, the one table every cell takes its channel's copy of. A host event whose call shape or waterfall order moved is adopted by the plugin's own listener (the route claim's `internal/config` listener in `src/host/route-claim.ts`) and by the tier listeners in `test/support/host.ts`, each detected from what the host dispatches (`test/support/config-shape.ts` decodes the call in the tests), never from a version, and the contract tests re-measure the facts they pin (F8, F12, F14, F20) with one expectation per shape and are never loosened to pass on either shape. The `0.2.2-alpha.3` and `0.2.2-rc.2` bumps were that case: cordis `4.0.6-alpha.2` and `4.1.0` call `internal/config` as `(fiber, next)` with `this` null and `internal/update` as `(resolved, next)`, so the tier listeners decode both shapes, the stand-in fibers in `test/config-shape.test.ts` keep the older one covered, and the route claim speaks the same two shapes (`src/utils/config-run.ts`, with stand-in fibers of both cores in `test/support/route-claim-stand-in.ts` and the new core's call-shape cases in `test/route-claim-listener.test.ts`), which flipped the binary e2e's `[KNOWN_DEFECT]` row to a `[BASELINE]` row. A cut that **opens a new prerelease tuple** (dsh `0.2.3-*`, cordis `4.0.7-*`) is not data: semver admits a prerelease only through a comparator in its own tuple, so it is a reviewed range change that also adds the matching `>=X.Y.Z-0 <next` clause to the `peerDependencies` entry (`test/harness-channel.test.ts` requires every record inside its peer range) and the new line to the README pair's peer-range paragraph. The nightly drift report (`scripts/report-host-drift.ts`, CI's `host-drift` job) is what notices that a channel moved. +3. On every release, after smoke-testing (registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds), check the **Supported: the latest release of both DeepSeek Harness channels** line and the **Support policy** paragraph under "Compatibility & Verification" in both READMEs. Their "today" versions, pi ranges and floors, and the default install named a paragraph below, are copied from the channel records in `test/support/host-versions.ts`, and `test/harness-channel.test.ts` holds them to the records. A channel bump **inside the prerelease tuples the peer ranges already admit** (a new `dsh` cut whose cluster changes no host hook, say) moves version data, and this is its whole set: for `rc`, the rc record (with the dist-tag that carries the build: `next` while the rc line publishes ahead of `latest`) and `channels/rc/pnpm-lock.yaml` (`scripts/use-channel.ts rc --relock`); for `alpha`, the alpha record, `package.json`'s exact `@deepseek-ai/*` pins (the cordis family's among them) and the root lockfile (`pnpm install`); for either, those README sentences in both languages, and `minimumReleaseAgeExclude` lines in `pnpm-workspace.yaml` for the exact versions when the cut is younger than pnpm's one-day release-age window. A pi move is the same data: `pnpm install` and `--relock` keep the pi a lockfile already satisfies, so move it with `pnpm update @earendil-works/pi-ai --no-save` (the root lockfile; `package.json` keeps the union range), then `scripts/use-channel.ts rc --relock`, which carries the root's pi into the rc lockfile, and set each record's `pi` (both records: both lockfiles hold the one pi, so a pi move is every record's or none). The cordis family (cordis, the loader, include, group, timer, cosmokit, schemastery) moves only with `dsh`, to the floors of the `~` ranges the new tag's cluster declares (the drift report prints them; they are what the harness vendors at that tag): a newer family patch inside the same ranges is what the `e2e-host` npm cell already runs and logs, never a record bump. A relock keeps the family at the record's floors, because `package.json` pins every member the install resolves and `pinFor` moves each with the channel (the timer plugin, resolved by nothing here, has no pin). The same update is what leaves one pi when a new bridge raises its floor above the locked one. The F10 characterization (`test/mount-protocols.test.ts`) is keyed by pi line, so a pi inside a line leaves it alone; a pi outside every line is a new major, and a reviewed change. **Data only while the host behaves the same.** A bump stays version data while tiers A and B and the binary e2e stay green on the new install. A pull request runs only the default installs, so a bump that moves `piFloor` or any cordis-family field is green only after a `workflow_dispatch` run on the bump branch, which adds both channels' floor cells to the `test` and `e2e-host` jobs. One that turns them red because the host behaves differently is a reviewed change made in the bump's own pull request and named in its body: a pi whose catalog or bases moved (a model added or dropped, a gap id learned) re-measures the `newest` cell's column of `WHOLE_CATALOG` (`test/host-shapes.test.ts`) and of `PI_GAPS_BY_CELL` (`test/support/host.ts`), and a moved `piFloor` re-measures that floor's columns of the same two; no S0 row follows pi, since the plugin places and routes every declared id itself (D26). A cluster whose bridge defaults moved updates `PI_AI_DEFAULTS` (`test/support/host.ts`), which `test/host-shapes.test.ts` pins against the installed pi-ai and which feeds `s0PluginFor`'s catalog-unknown rows; any other row change goes in `s0PluginFor`, the one table every cell takes its channel's copy of. A host event whose call shape or waterfall order moved is adopted by the plugin's own listener (the route claim's `internal/config` listener in `src/host/route-claim.ts`) and by the tier listeners in `test/support/host.ts`, each detected from what the host dispatches (`test/support/config-shape.ts` decodes the call in the tests), never from a version, and the contract tests re-measure the facts they pin (F8, F12, F14, F20) with one expectation per shape and are never loosened to pass on either shape. The `0.2.2-alpha.3` and `0.2.2-rc.2` bumps were that case: cordis `4.0.6-alpha.2` and `4.1.0` call `internal/config` as `(fiber, next)` with `this` null and `internal/update` as `(resolved, next)`, so the tier listeners decode both shapes, the stand-in fibers in `test/config-shape.test.ts` keep the older one covered, and the route claim speaks the same two shapes (`src/utils/config-run.ts`, with stand-in fibers of both cores in `test/support/route-claim-stand-in.ts` and the new core's call-shape cases in `test/route-claim-listener.test.ts`), which flipped the binary e2e's `[KNOWN_DEFECT]` row to a `[BASELINE]` row. A cut that **opens a new prerelease tuple** (dsh `0.2.3-*`, cordis `4.0.7-*`) is not data: semver admits a prerelease only through a comparator in its own tuple, so it is a reviewed range change that also adds the matching `>=X.Y.Z-0 <next` clause to the `peerDependencies` entry (`test/harness-channel.test.ts` requires every record inside its peer range) and the new line to the README pair's peer-range paragraph. The nightly drift report (`scripts/report-host-drift.ts`, CI's `host-drift` job) is what notices that a channel moved, and the same job proposes the bump (`scripts/propose-host-bump.ts`): it moves exactly this data set (the record, alpha's pins, both lockfiles, the README pair's "today" sentences) on `chore/host-bump` and opens the pull request, whose own CI is the run. It waits a night for a cut younger than pnpm's release-age window rather than adding `minimumReleaseAgeExclude` lines, dispatches ci.yml on the branch for the floor cells when `piFloor` or a cordis-family field moved (with the dispatch's `keyed` input at `none`, so that run spends no key), never edits a measured table (the re-measure above is the reviewed part of that pull request, which its body names), leaves a README value both channels share (unless both records move it to the same value), and any dist-tag wording, for the reviewer, and opens a cut that starts a new prerelease tuple, or sits under a dist-tag `ChannelRecord.distTag` does not admit, as a range change for review rather than as data. A dist-tag never moves alone: it moves with `dsh`, so a tag retired or repointed at the same build is no proposal, as it is no drift. **An open proposal is the reviewer's.** The re-measure, a shared README sentence and a range plan's peer clause are commits made on `chore/host-bump`, and until the pull request merges the record on `main` is still behind, so the nightly plans the same bump every night. It therefore leaves an open proposal with the same target alone: no push, no edit, no dispatch. The target is the marker line the body ends with (`bumpTarget`: every proposed record in full), read from the open pull request's body, not its title, which names only each record's headline value (a pi that moves after a dsh cut was proposed leaves the title as it was). A newer target rebuilds the branch only while every commit on it is the nightly's own, with the bot as both its author and its committer (an amend, a rebase or a fixup squash keeps the bot as author and makes the reviewer the committer, so it counts as the reviewer's), and only this repository's own `chore/host-bump` counts as the proposal, never a fork's pull request from a branch of that name; once a reviewer has committed, the nightly leaves the branch alone and names the newer target in a warning, and the next proposal follows the merge. Since both lockfiles hold one pi, a proposed pi move carries every record: a channel waiting only on its own young cut takes a pi-only move the same night when its recorded pi range admits the pi, and a channel that cannot (a partial read, another pi, a recorded range that stops short, a proposal that keeps the old pi) holds the pi move for every channel (`coupleSharedPi`). The plan is written in a step with no token in its environment (the proposer installs a cut nobody has reviewed yet); a second step, which only commits, pushes and opens or edits the pull request, carries the tokens. Both steps run in one job, so the split holds only because the install runs none of the cut's code: pnpm runs a dependency's build script only when something approves it (in the pinned pnpm, an `allowBuilds` entry, a `build` permission or the allow-all in `pnpm-workspace.yaml`, a `PNPM_CONFIG_*` variable in the step's environment, or a CLI flag in the step or in a script it runs, the proposer and the rc relock), and nothing does, so any build approval is a change to this boundary (`test/host-drift.test.ts` names each of those, and the older spellings pnpm 12 ignores: package.json's `pnpm` field, `.npmrc`, the allow-list and the deny-list). ## Docs diff --git a/docs/credentials.md b/docs/credentials.md index a364e560..5affe898 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -268,7 +268,7 @@ Built, and the opt-in reader it prepared for: The opt-in reader was parked on a dependency question: OpenCode 2.0 keeps its credentials in a database rather than a JSON file, so reading it needs SQLite, and adding a driver to a plugin this small is a decision rather than a detail. -That decision is now measured rather than open. `node:sqlite` is a built-in on the Node this host runs (26.x), exporting `DatabaseSync`, `StatementSync` and `backup`, so the reader can open `~/.local/share/opencode/opencode.db` with no new dependency at all. The constraint it inherits is the bundle rule: `node:sqlite` is a host-only import, so the reader lives on the host side and the client never reaches it — which is what `scripts/check.ts` enforces rather than what anyone has to remember. +That decision is now measured rather than open. `node:sqlite` is a built-in on the Node this host runs (26.x), exporting `DatabaseSync`, `StatementSync` and `backup`, so the reader can open `~/.local/share/opencode/opencode.db` with no new dependency at all. The constraint it inherits is the bundle rule: `node:sqlite` is a host-only import, so the reader lives on the host side and the client never reaches it — which `test/architecture.test.ts`'s client-bundle block ("the client bundle reaches nothing host-only") enforces by walking the entry's import graph, so the unit suite fails the build rather than anyone having to remember. **And the path is not a constant, which is the part worth knowing before writing it.** Two files in `~/dev/opencode` decide it, and neither branches on the operating system: diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index a86f7f5f..59a77ae0 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -558,7 +558,7 @@ What the plugin does instead (D13, landing in P-R1 as `src/host/route-claim.ts`) ## A placeholder in a string has no spelling this linter accepts -`check.ts` asserts the refresh push carries the PAT by looking for the token URL as a literal, and the literal carries the workflow's own placeholders. Written plainly (`"…${GH_TOKEN}…"`), `no-template-curly-in-string` flags it; written with escapes (`"…\${GH_TOKEN}…"`), `no-useless-escape` flags every escape instead — a plain string has no spelling this oxlint accepts. A regex literal is not an escape hatch either: `prefer-includes` rejects a pattern that is a static string. The one spelling both rules accept is a template literal with `\${`: there the backslash is the documented way to write a literal placeholder, and neither rule applies. +`check.ts` asserted the refresh push carries the PAT (since P-T3 the rule is `test/ci-wiring.test.ts`, "the nightly refresh pushes as the token's owner") by looking for the token URL as a literal, and the literal carries the workflow's own placeholders. Written plainly (`"…${GH_TOKEN}…"`), `no-template-curly-in-string` flags it; written with escapes (`"…\${GH_TOKEN}…"`), `no-useless-escape` flags every escape instead — a plain string has no spelling this oxlint accepts. A regex literal is not an escape hatch either: `prefer-includes` rejects a pattern that is a static string. The one spelling both rules accept is a template literal with `\${`: there the backslash is the documented way to write a literal placeholder, and neither rule applies. The red run that followed was the verification's fault, not the lint's. The check was run as `pnpm run check | grep …`, and a pipeline reports its last command's exit status, so a failing check read as success and the commit went out red. `pnpm run check; echo exit=$?` — or `set -o pipefail` — is the form that cannot lie. diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 356a83de..9d269b4b 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -8,7 +8,7 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which - an adversarial review of the plan itself, then a nine-lens review (five reviewers, four investigators) in which two independent skeptics checked every finding: 78 of 82 were upheld and are folded in here; - the host's own source and data (`deepseek-ai/deepseek-harness`, and the pi 1.1.0 catalog its `0.2.1` rc tags pinned; the `0.2.2` tags of both channels declare pi `^1.3.0`, whose floor is what the desktop app ships, D14''). File references into the host are relative to that repository's `packages/`. -**Restacked onto 1.8.0 (November 2026).** The stack was cut from 1.4.1. Since then `main` moved to 1.8.0 and followed the harness onto its **alpha** track (the `0.2.2-alpha.2` cluster, cordis `4.0.6-alpha.1`, pi `1.4.0` exact), and it landed several things the plan had scheduled: the plane-aware price, free flag and refusal (#133), a working retired alias (#135), the synthetic catalogue fixtures (#138), the language-pair gate and the live-run verdicts (#140), and the scripts' pure halves (#143, #144). Since 1.9.1 the stack lands by rebase, not by merge: the owner rebased P0 to P-H onto `main` as one commit each (#105, #106, #155, #108, #109, #110, #112), and each open phase is rebased onto its rebased parent as its own change, followed by the adaptation commits the rebase needs, never re-adding or reverting what `main` already has. What changed in the decisions is under "Owner decisions" (D14'', D22'', D16 and D17 as rewritten by D26, D23, D24, D25, D26), what changed per phase is under "Restack adjustments", and what two community plugins built on the same host taught is under "Lessons from opencode2dsh". A new phase, P-R3, follows P-T3. +**Restacked onto 1.8.0 (November 2026).** The stack was cut from 1.4.1. Since then `main` moved to 1.8.0 and followed the harness onto its **alpha** track (the `0.2.2-alpha.2` cluster, cordis `4.0.6-alpha.1`, pi `1.4.0` exact), and it landed several things the plan had scheduled: the plane-aware price, free flag and refusal (#133), a working retired alias (#135), the synthetic catalogue fixtures (#138), the language-pair gate and the live-run verdicts (#140), and the scripts' pure halves (#143, #144). Since 1.9.1 the stack lands by rebase, not by merge: the owner rebased P0 to P-H onto `main` as one commit each (#105, #106, #155, #108, #109, #110, #112), and each open phase is rebased onto its rebased parent as its own change, followed by the adaptation commits the rebase needs, never re-adding or reverting what `main` already has. What changed in the decisions is under "Owner decisions" (D14'', D22'', D16 and D17 as rewritten by D26, D23, D24, D25, D26), what changed per phase is under "Restack adjustments", and what two community plugins built on the same host taught is under "Lessons from opencode2dsh", with what the vendor's own first-party plugin taught beside it under "Lessons from @opencode/console". A new phase, P-R3, follows P-T3. **Outcome:** a plugin that only uses host extension points the host supports; one source of truth per decision; a suite that pins behaviour before it moves; and the owner's product rules below. @@ -16,7 +16,7 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which ## Status -Phases land as **stacked pull requests**. The open ones are rebased, each onto its rebased parent as its own change, and the first open phase sits on `main` (P0 to P-H are merged). Every phase is a 1.x release: there is no major (D6). +Phases land as **stacked pull requests**. The open ones are rebased, each onto its rebased parent as its own change, and the first open phase sits on `main` (P0 to P-T2 are merged). Every phase is a 1.x release: there is no major (D6). | Phase | PR | Title | State | Restack / rebase | | :-- | :-- | :-- | :-- | :-- | @@ -27,12 +27,12 @@ Phases land as **stacked pull requests**. The open ones are rebased, each onto i | P-T1a | #109 | `build:` pin the host | merged (028749f) | **merged `main` 1.9.2**, rebased onto 1.9.1 as its own change; redone for both harness channels with no pi patch (D14'', D22''): one record per channel in `test/support/host-versions.ts`, the rc channel's committed lockfile and `scripts/use-channel.ts`, the unit and mock suites on both channels in CI, the nightly host-drift report, and the tripwire that keeps `src` off what the desktop's patched pi changes. Then both records moved to the latest builds, rc `0.2.2-rc.2` (`dsh@next`) and alpha `0.2.2-alpha.3`, both on the newer host core, and the drift report reads the newest build on each record's line under any dist-tag, or a newer stable release under the tag a record names for its line graduating (rc's `latest`; alpha names none) | | P-T1b | #110 | `test:` a harness that is the real host | merged (c4fca32) | **merged P-T1a**, rebased onto `main` 1.9.2 as its own change; re-measured on both channels, newest and floor: one row table per host cell (either channel's newest pi, and each floor), the binary e2e per channel (the `npm` cell on pull requests, the `floor` cell nightly), no pi patch anywhere | | P-H | #112 | `fix:` disabling or reloading the plugin restores the host methods it wrapped | merged (1b4ebf0) | **merged P-T1b**, rebased onto it as its own change; gains the Chinese "Changed in 1.x" bullet beside the English one (the pairing gate); the helper, its sites and its tripwires are unchanged; retitled from `refactor:`, because the restore on a disable or a reload is a defect fix the README announces and the changelog must carry (a `refactor` is hidden from it) | -| P-R1 | #113 | `feat:` the plugin owns the plane routes | in review | **rebased onto `main` 1.9.2** (P-H merged) as its own change; on both channels' cells, one row table every cell shares (the plugin places, describes and routes every declared id itself); deletes the Gemini level wrapper (pi's own rule, from pi 1.3, which both floors run), reads no base or protocol from pi's catalog, and sets no default window or max tokens, pi-ai applies its own (D16, D17, D26); the route claim speaks both cordis cores' `internal/config` call shapes, so it engages on both channels' newest builds (the binary e2e's `[KNOWN_DEFECT]` row is a `[BASELINE]` now), and the plane profiles carry pi-ai's image-count bounds where its `Config` fills them | -| P-R2 | #114 | `test:` the endpoint families, measured | in review | **rebased onto P-R1** as its own change; its dispatch set holds on every host cell, re-measured on the new host core where the claim now engages (both channels' newest builds and floors) and on the old one; gains the L3, L4, L5, L9, L10 and L13 records and the per-request form of `main`'s mock-coverage gate | +| P-R1 | #113 | `feat:` the plugin owns the plane routes | merged (c7820b4) | **rebased onto `main` 1.9.2** (P-H merged) as its own change; on both channels' cells, one row table every cell shares (the plugin places, describes and routes every declared id itself); deletes the Gemini level wrapper (pi's own rule, from pi 1.3, which both floors run), reads no base or protocol from pi's catalog, and sets no default window or max tokens, pi-ai applies its own (D16, D17, D26); the route claim speaks both cordis cores' `internal/config` call shapes, so it engages on both channels' newest builds (the binary e2e's `[KNOWN_DEFECT]` row is a `[BASELINE]` now), and the plane profiles carry pi-ai's image-count bounds where its `Config` fills them | +| P-R2 | #114 | `test:` the endpoint families, measured | merged (08088aa) | **rebased onto P-R1** as its own change; its dispatch set holds on every host cell, re-measured on the new host core where the claim now engages (both channels' newest builds and floors) and on the old one; gains the L3, L4, L5, L9, L10 and L13 records and the per-request form of `main`'s mock-coverage gate | | P-R2b | | `fix:` flip the default on §13's evidence | if needed | P-R2's first CI run decides it | -| P3 | #115 | `feat:` Settings complements pi's list | in review | **rebased onto P-R2** as its own change; the complement is gated on a claim that has engaged (`RouteClaim.claiming()` reads false once the mount settles with the served pi-ai row still resolving a plane and no restart of it in flight, which on either host core is only a call shape the listener does not speak; it reads true through a handover's window, while the newly served row's restart is in flight, so the fetch is complemented until the row unloads and the catalog alone while it does; a throwing restart leaves pi-ai FAILED and the claim engaged), so Settings' fetch is pi's own list only there; the binary e2e's Settings fetch rows run in every cell of both channels, the order read against the list the prefix's own pi exports | -| P-T2 | #116 | `test:` suite structure | in review | **rebased onto P3** as its own change; every test `main` and the lower restack brought is in this layout (the split files, the one door, and no socket in the unit run, which `test/architecture.test.ts` now enforces); the catalog tests assert invariants, and the cases this phase and `main` brought, `tool-fallback`'s free model and `models-discovery`'s catalog rows pick rows by shape or seed them, while earlier cases that name a vendor id, as a declared profile entry, a fixture string or a lookup in the shipped tables (`go-limits`' allowance coverage, `thinking-levels`' Gemini rows, `plane-model`'s `specOf`), keep it (converting those is a follow-up); `test/e2e/verdict.ts` folds into `test/support/live-probe.ts`, `main`'s mock-coverage cases join `protocol-endpoints.mock.ts`, the host-drift CLI case moves to the mock suite, `main`'s plane-lookup fallback gains its first assertions, and `docs/testing.md` describes this layout | -| P-T3 | | `ci:` CI and scripts | planned | shrinks: `main` extracted the scripts' pure halves; gains the bump pull request proposed from P-T1a's nightly host-drift report | +| P3 | #115 | `feat:` Settings complements pi's list | merged (2f474b2) | **rebased onto P-R2** as its own change; the complement is gated on a claim that has engaged (`RouteClaim.claiming()` reads false once the mount settles with the served pi-ai row still resolving a plane and no restart of it in flight, which on either host core is only a call shape the listener does not speak; it reads true through a handover's window, while the newly served row's restart is in flight, so the fetch is complemented until the row unloads and the catalog alone while it does; a throwing restart leaves pi-ai FAILED and the claim engaged), so Settings' fetch is pi's own list only there; the binary e2e's Settings fetch rows run in every cell of both channels, the order read against the list the prefix's own pi exports | +| P-T2 | #116 | `test:` suite structure | merged (9be88d6) | **rebased onto P3** as its own change; every test `main` and the lower restack brought is in this layout (the split files, the one door, and no socket in the unit run, which `test/architecture.test.ts` now enforces); the catalog tests assert invariants, and the cases this phase and `main` brought, `tool-fallback`'s free model and `models-discovery`'s catalog rows pick rows by shape or seed them, while earlier cases that name a vendor id, as a declared profile entry, a fixture string or a lookup in the shipped tables (`go-limits`' allowance coverage, `thinking-levels`' Gemini rows, `plane-model`'s `specOf`), keep it (converting those is a follow-up); `test/e2e/verdict.ts` folds into `test/support/live-probe.ts`, `main`'s mock-coverage cases join `protocol-endpoints.mock.ts`, the host-drift CLI case moves to the mock suite, `main`'s plane-lookup fallback gains its first assertions, and `docs/testing.md` describes this layout | +| P-T3 | | `ci:` CI and scripts | in review | **on `main` (9be88d6)**, which P-T2's merge left with this phase's base tree; shrank to what `main` had not done (one unit run, the build before `lib/`, tsx type-checked, one setup action, `check.ts` trimmed, `verify-*` exit codes, `.github/` and `*.sh` scanned) and gained the dist-tags and 2.x User-Agent read (L6) and the nightly bump proposal (an open proposal left to its reviewer); the checkout stays in each job, since a local action cannot bring its own, and the `check` job keeps its required-check name and the default install's coverage run | | P-R3 | | `fix:` honest refusals | planned | new | | P5 | | `refactor:` installers | planned | | | P6 | | `refactor:` the fetch patch's credential fill | planned | | @@ -69,7 +69,7 @@ Phases land as **stacked pull requests**. The open ones are rebased, each onto i | D19 | **Plugin settings.** _Stands after the restack: `main`'s copy says `auto` "follows the catalog", but its code agrees that `auto` is `modern`; P1a's copy wins, pinned by `endpoint-copy.test.ts`._ Delete `modelOverrides`, `maxContextWindow`, `modelRoutes` and `internalRoutes`, and the README's nonexistent `hiddenModels` (removed in P0). Keep `endpoint` (`legacy` or `modern`, with `auto` an alias of `modern`). Keep `providers`, redefined as the route ids whose turns the plugin decorates, with both planes always included. | | D20 | **The plane adapter resolves keys by pi-ai's rule.** A named ref resolves through the credentials service, then the launch environment, then fails with `MISSING_CREDENTIAL` and sends no request. With no ref, pi's ambient key applies, plus the `readOpencodeStore` opt-in. Captured and literal keys never feed the adapter; they stay in the fetch patch's fill for an empty or placeholder wire key. | | D21 | The keyless paid-model filter on listings is dropped (D3, D8). Without a key, dispatch fails loudly. | -| D22'' | **Two channel records, named once** in `test/support/host-versions.ts`: `rc` = the newest build on the rc line, which the harness publishes under `next` ahead of `latest` (`0.2.2-rc.2` on `next` as of 2026-11-12, while `latest` is `0.2.2-rc.1`; cordis `4.1.0`, loader `1.1.0`, schemastery `3.19.0`, bridge pi `^1.3.0`) and `alpha` = `dsh@alpha` (`0.2.2-alpha.3`, cordis `4.0.6-alpha.2`, loader `1.0.7-alpha.2`, schemastery `3.18.6-alpha.2`, bridge pi `^1.3.0`), each with the newest pi its range resolves (the lockfile's, `1.5.2` on both) and the range's floor (the harness's pin, `1.3.0` on both), and the whole cordis family (cordis, the loader, include, group, timer, cosmokit, schemastery) at the floors of the `~` ranges the published cluster declares, which are what the harness vendors at the tag and the desktop app ships; the family moves only with `dsh`, and a newer patch inside those ranges is what the `e2e-host` npm cell runs, never a record bump. Both records run the same host core, the one cordis `4.0.6-alpha.2` and `4.1.0` share; the first records (`0.2.1-rc.5` on cordis `4.0.5`, `0.2.2-alpha.2` on `4.0.6-alpha.1`) ran the older one, which no supported channel ships any more. The committed `package.json` is `alpha`'s, which is what `main` follows; the devDependencies gain `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader` for the real-host tiers, and `cordis-plugin-include`, `cordis-plugin-group` and `cosmokit` so a relock holds the family at the record's floors (each arrives transitively, which a fresh resolve would take at the newest patch its `~` range admits). `test/host-versions.test.ts` checks the installed tree against the SELECTED record (`DSH_CHANNEL`, else the switch's marker, else `alpha`); `test/harness-channel.test.ts` keeps `main`'s both-channel peer-range rule and holds each committed lockfile to its record. A bump inside the prerelease tuples the peer ranges admit moves version data only while tiers A and B and the binary e2e stay green on the new install (for a bump that moves `piFloor` or a family field, on a `workflow_dispatch` run of the bump branch, since a pull request runs only the default installs); CONTRIBUTING's release step 3 is the one list of what it moves (for `rc`, the record and `channels/rc/pnpm-lock.yaml`; for `alpha`, the record, `package.json`'s exact pins and the root lockfile; for either, the README pair's "today" sentences, which `test/harness-channel.test.ts` holds to the records, and `minimumReleaseAgeExclude` lines for a cut under a day old). pi moves the same way, through `pnpm update @earendil-works/pi-ai --no-save` and then `rc --relock`, since neither `pnpm install` nor a relock moves a pi the lockfile already satisfies; F10 is keyed by pi line, so a pi inside its line leaves it alone. A bump that turns the tiers or the binary e2e red because the host behaves differently is a reviewed change in the bump's own pull request, named in its body: a pi whose catalog or bases moved re-measures the `newest` cell's column of `WHOLE_CATALOG` (`test/host-shapes.test.ts`) and of `PI_GAPS_BY_CELL` (`test/support/host.ts`), a moved `piFloor` the floor's columns of the same two, and no S0 row follows pi (D26); a cluster whose bridge defaults moved updates `PI_AI_DEFAULTS`, which `test/host-shapes.test.ts` pins and which feeds `s0PluginFor`'s catalog-unknown rows, and any other row change goes in `s0PluginFor`, the one table every cell shares; a host event whose call shape or waterfall order moved is adopted by the route claim's own listener (`src/host/route-claim.ts`) and the tier listeners, detected from what the host dispatches, and the contracts they pin (F8, F12, F14, F20) are re-measured with one expectation per shape. The `0.2.2-alpha.3` and `0.2.2-rc.2` bumps were the first: their cordis (`4.0.6-alpha.2`, `4.1.0`) changed the call shape of `internal/config` and `internal/update`, P-T1b's contracts decode both shapes from the call, and P-R1's route claim speaks the same two shapes (`src/utils/config-run.ts`), which flipped the binary e2e's `[KNOWN_DEFECT]` row to a `[BASELINE]` row. A cut that opens a new prerelease tuple (dsh `0.2.3-*`, cordis `4.0.7-*`) is a reviewed range change: it adds the matching peer clause and the README pair's peer-range line. Any change to `package.json`'s dependencies or the workspace's catalog or overrides relocks rc in the same commit, and Dependabot ignores the cluster and pi. A nightly step reports drift against the records (P-T1a: CI's `host-drift` job runs `scripts/report-host-drift.ts`, which reads `dsh` as the newest build any dist-tag carries on each record's line (or a newer stable release under the tag the record names for its line graduating, `graduatesUnder`: `latest` for rc, none for alpha, so a stable `0.2.2` neither reads as alpha drift nor hides a later `0.2.2-alpha.N`), pi at the newest version the bridge's range admits and that range's floor, and each cordis-family member at the floor of the range that cluster declares for it, and prints a warning per field it compares, never a gate); P-T3 turns the report into a proposed bump pull request. _Replaces D22' (the alpha cluster alone, with `rc` only as a peer range) and D22 (the 0.2.1-rc.2 cluster)._ | +| D22'' | **Two channel records, named once** in `test/support/host-versions.ts`: `rc` = the newest build on the rc line, which the harness publishes under `next` ahead of `latest` (`0.2.2-rc.2` on `next` as of 2026-11-12, while `latest` is `0.2.2-rc.1`; cordis `4.1.0`, loader `1.1.0`, schemastery `3.19.0`, bridge pi `^1.3.0`) and `alpha` = `dsh@alpha` (`0.2.2-alpha.3`, cordis `4.0.6-alpha.2`, loader `1.0.7-alpha.2`, schemastery `3.18.6-alpha.2`, bridge pi `^1.3.0`), each with the newest pi its range resolves (the lockfile's, `1.5.2` on both) and the range's floor (the harness's pin, `1.3.0` on both), and the whole cordis family (cordis, the loader, include, group, timer, cosmokit, schemastery) at the floors of the `~` ranges the published cluster declares, which are what the harness vendors at the tag and the desktop app ships; the family moves only with `dsh`, and a newer patch inside those ranges is what the `e2e-host` npm cell runs, never a record bump. Both records run the same host core, the one cordis `4.0.6-alpha.2` and `4.1.0` share; the first records (`0.2.1-rc.5` on cordis `4.0.5`, `0.2.2-alpha.2` on `4.0.6-alpha.1`) ran the older one, which no supported channel ships any more. The committed `package.json` is `alpha`'s, which is what `main` follows; the devDependencies gain `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader` for the real-host tiers, and `cordis-plugin-include`, `cordis-plugin-group` and `cosmokit` so a relock holds the family at the record's floors (each arrives transitively, which a fresh resolve would take at the newest patch its `~` range admits). `test/host-versions.test.ts` checks the installed tree against the SELECTED record (`DSH_CHANNEL`, else the switch's marker, else `alpha`); `test/harness-channel.test.ts` keeps `main`'s both-channel peer-range rule and holds each committed lockfile to its record. A bump inside the prerelease tuples the peer ranges admit moves version data only while tiers A and B and the binary e2e stay green on the new install (for a bump that moves `piFloor` or a family field, on a `workflow_dispatch` run of the bump branch, since a pull request runs only the default installs); CONTRIBUTING's release step 3 is the one list of what it moves (for `rc`, the record and `channels/rc/pnpm-lock.yaml`; for `alpha`, the record, `package.json`'s exact pins and the root lockfile; for either, the README pair's "today" sentences, which `test/harness-channel.test.ts` holds to the records, and `minimumReleaseAgeExclude` lines for a cut under a day old). pi moves the same way, through `pnpm update @earendil-works/pi-ai --no-save` and then `rc --relock`, since neither `pnpm install` nor a relock moves a pi the lockfile already satisfies; F10 is keyed by pi line, so a pi inside its line leaves it alone. A bump that turns the tiers or the binary e2e red because the host behaves differently is a reviewed change in the bump's own pull request, named in its body: a pi whose catalog or bases moved re-measures the `newest` cell's column of `WHOLE_CATALOG` (`test/host-shapes.test.ts`) and of `PI_GAPS_BY_CELL` (`test/support/host.ts`), a moved `piFloor` the floor's columns of the same two, and no S0 row follows pi (D26); a cluster whose bridge defaults moved updates `PI_AI_DEFAULTS`, which `test/host-shapes.test.ts` pins and which feeds `s0PluginFor`'s catalog-unknown rows, and any other row change goes in `s0PluginFor`, the one table every cell shares; a host event whose call shape or waterfall order moved is adopted by the route claim's own listener (`src/host/route-claim.ts`) and the tier listeners, detected from what the host dispatches, and the contracts they pin (F8, F12, F14, F20) are re-measured with one expectation per shape. The `0.2.2-alpha.3` and `0.2.2-rc.2` bumps were the first: their cordis (`4.0.6-alpha.2`, `4.1.0`) changed the call shape of `internal/config` and `internal/update`, P-T1b's contracts decode both shapes from the call, and P-R1's route claim speaks the same two shapes (`src/utils/config-run.ts`), which flipped the binary e2e's `[KNOWN_DEFECT]` row to a `[BASELINE]` row. A cut that opens a new prerelease tuple (dsh `0.2.3-*`, cordis `4.0.7-*`) is a reviewed range change: it adds the matching peer clause and the README pair's peer-range line. Any change to `package.json`'s dependencies or the workspace's catalog or overrides relocks rc in the same commit, and Dependabot ignores the cluster and pi. A nightly step reports drift against the records (P-T1a: CI's `host-drift` job runs `scripts/report-host-drift.ts`, which reads `dsh` as the newest build any dist-tag carries on each record's line (or a newer stable release under the tag the record names for its line graduating, `graduatesUnder`: `latest` for rc, none for alpha, so a stable `0.2.2` neither reads as alpha drift nor hides a later `0.2.2-alpha.N`), pi at the newest version the bridge's range admits and that range's floor, and each cordis-family member at the floor of the range that cluster declares for it, and prints a warning per field it compares, never a gate); since P-T3 the same job proposes the bump as a `chore/host-bump` pull request (`scripts/propose-host-bump.ts`), moving release step 3's data, waiting a night for a cut inside pnpm's release-age window, and proposing a new prerelease tuple as a range change for review. _Replaces D22' (the alpha cluster alone, with `rc` only as a peer range) and D22 (the 0.2.1-rc.2 cluster)._ | | D23 | **CI's freshness checks gate a pull request only on its own inputs.** A check's inputs are its script and every local module it loads, plus `src/identity.ts` for the CLI version; the generated tables never are. Vendor drift warns, and the nightly run proposes the refresh. The strict input lists in `test/freshness-scope.test.ts` are the record: `cli:version`'s grew by `scripts/vendor-version.ts` when `main` gave the check a pure half (#144), and the copy scan's generated-table skip moved into that half. | | D24 | **The debug gate is `main`'s rule:** naming `debugFile` writes both halves of the log; `debug` governs console output only. `main` chose it (#133), and its README example (`debug: false` with a `debugFile`) relies on it. P1a adopts it, and turns its "writes nothing while debug is off" test into one that asserts the rule. | | D25 | **The tool fallback's plane is `main`'s rule:** the request URL decides the plane, with a plane-blind lookup as the fallback (#138, `coreToolDecision`). P1b drops its route-mapped variant with no fallback. | @@ -365,7 +365,7 @@ What stays private is pinned by contract tests against the real host packages: - On the restack: the tool fallback keeps `main`'s plane rule (D25), so only the unreachable Anthropic schemas go; the catalogue fixture this phase wrote and `main`'s `test/fixtures/catalogs.ts` become one module under `test/support/`, which reaches `src` through the door. 3. **P2 `feat:` a smaller public API.** It ships as a 1.x minor. - `index.ts` exports `apply`, `Config`, `inject`, `name` and the config types. - - `./src/*` goes from `exports`, and `check.ts` asserts that every exports target is inside `files`. + - `./src/*` goes from `exports`, and `check.ts` asserts that every exports target is inside `files` (since P-T3: `test/package-checks.test.ts`). - Delete `resolveRoutedKey` and `internalRoutes`. - ~~Stop publishing the wrapper.~~ Reversed by the owner in #157 (D9, superseded): the deprecated alias keeps publishing, with its forwarder. `publish-scoped.ts`'s `SCOPED_TARGETS` and `release.yml`'s verify list still change together, both name the alias, and `test/package-checks.test.ts` asserts that they agree. 4. **P-T1a `build:` pin the host.** @@ -444,13 +444,12 @@ What stays private is pinned by contract tests against the real host packages: - **Restacked onto 1.8.0.** Merged P3, so every case `main` and the lower restack added lands in this layout rather than in the shape it arrived in. The files this phase split took their new cases by subject (`main`'s allowance-figure and Go-prose checks in `catalog-docs`, the refusal passthrough on the modern base in `catalog-enrichment`, the account-scoped usage read in `usage-service-readings`, the never-engaging claim in `route-claim`, the D25 URL-plane case in `fetch-patch-headers`), with a row picked by shape wherever the case is about a rule (the allowance's display name, the per-plane price, which is now seeded rather than read off the vendor's prices). The deletions stand: `client-headers-affinity`, `testing-kit.ts` and `gateway-contract.mock.ts`, whose two coverage cases from `main` (#142) moved to `protocol-endpoints.mock.ts`. `main`'s fixture module and this phase's merged into `test/support/catalogs.ts`; `test/e2e/verdict.ts` folded into `test/support/live-probe.ts` (one name per rule: `retiredAtTheGateway` is `modelGone`); the host-drift CLI case, which binds a loopback port, moved to `test/mock/host-drift-cli.mock.ts`; `main`'s debug-gate case is driven through `mountPlugin`; and the binary e2e reads its cell (`DSH_HOST_PI_CELL`: `npm`, `floor`) through `test/support/e2e-env.ts`, while the harness channel itself (`DSH_CHANNEL`, `DSH_PI`) is `test/support/host-versions.ts`'s `selectedHost`, shared by the unit, real-host and binary suites. `main`'s plane-lookup fallback (`findModelSpecOn(plane, id) ?? findModelSpec(id)`) had no assertion, so removing it left the suite green: two seeded cases now fail without it. The lifecycle branch floor moved to 91.5, because D24's gate removed reachable branches and left the same unreachable ones. `docs/testing.md` (`main`'s) is rewritten for this layout. - **Rebased onto P3 (`main` 1.9.2) as its own change.** The phase's diff from its last restack applies as one commit on the rebased P3, and what conflicted was the parent's new host core, carried into this layout rather than reverted: P-R1's stand-in for both cores (`piFiber` and `oldRun` for the OLD one, `newPiFiber` and the stand-in's own `waterfall` and `emit` for the NEW) lives in `test/support/route-claim-stand-in.ts`, the new core's call-shape cases sit with the listener's in `route-claim-listener`, and `route-claim` keeps standing down and Settings' fetch following the claim's engagement, which replaced the never-engaging case. The host-drift fixtures the parent rewrote to read off the records (`NEXT_ALPHA`, `withCut`, `PATCHED`, `ALPHA_NEXT`) moved to `test/support/host-drift-fixture.ts`, which the mock CLI case reads too; `host-planes`' two new lifecycle cases (a row disposed during the mount's restart, pi-ai's row removed by the loader) stay in `host-planes.test.ts`; the catalog counts the refresh restated went with the invariants that replace them; and `host-harness`' `effectiveConfigOf` read went with the file. One host adaptation followed the rebase: the refusal oracle's first case (`test/mock/refusal-classification.mock.ts`) now reads the code the INSTALLED pi-ai finishes a turn with instead of matching one bridge bundle's source, which the `0.2.2-rc.2` bridge broke (its classifier reads a leading HTTP status first) while keeping AUTH-first order: the probe's words are INVALID_REQUEST at 422, a status no classifier branch names, so only the words can make it one, and AUTH at 403. - **D27 merged from P3 (with `main` #170/#159).** Every case the step-aside added sits in this layout: its route-claim cases with the shared stand-in, the listener case in `route-claim-listener`'s listener describe and the four second-row cases (the commit-run release with its `Neighbours` table, the served row passing over a stepped-aside one by realm or marker, `wouldTake` per call, a stepped-aside plane once its row is gone) in that file's second-row describe, while `route-claim` keeps the standing-down assertions; the fetch patch's "another plugin's request" in `fetch-patch` (the credentials part, since what it pins is that no key is filled or captured); go-discovery's step-aside describe in `go-discovery`, on `loader-rows`' real contexts; and the new `plane-ownership` and `host-planes-foreign` files through the door, the latter booting tier B with the plugin row's config as `pluginRow: { config }` beside tier B's new `realm` option. #159's restated counts and renamed display name went with the invariants and the rows picked by shape that already replace them. -11. **P-T3 `ci:` CI and scripts.** - - **The test run:** one unit run; build before the tests that read `lib/`; type-check `test/**/*.tsx`. - - **Setup:** one composite setup action. - - **`check.ts`** keeps the lib freshness check, the consumer install and the size tripwire. - - **Scripts:** the generators share helpers, and the `verify-*` scripts exit non-zero. - - **`no-local-paths`** also covers `.github/` and `*.sh`. - - **Harness bumps:** turn the nightly host-drift report (P-T1a's `host-drift` job) into a proposed bump pull request that moves the version data CONTRIBUTING's release step 3 lists and runs the tiers and the binary e2e on it (D22''). It proposes the data and reports the rows that go red; it never hand-edits them: re-measuring a `newest` table or adapting a tier listener is the reviewed part of that pull request. A cut that opens a new prerelease tuple goes up for review as a range change, not as data. +11. **P-T3 `ci:` CI and scripts.** What landed: + - **One unit run per install, the build first:** the `check` job, whose name `check · test · build · gate` is the one required status check on `main`, runs the default install's (alpha, newest pi) unit suite as `test:coverage` and uploads the report, so a red suite or a coverage drop blocks a merge; the `test` matrix, which no branch protection requires, runs `pnpm run test` in every other cell and none in its alpha·newest cell (the same install), and every job builds before a step that reads `lib/`. `test/**/*.tsx` is in the type-checked program `vp check` reads. + - **One setup action** (`.github/actions/setup`: pnpm from `packageManager`, Node from `.node-version` or an input, a frozen install) used by every job in `ci.yml` and `release.yml`. The checkout stays in each job: a local action is read from the workspace, so it cannot bring the checkout that puts it there, and some jobs need their own checkout options (the catalog job's depth and credential-free checkout, host-drift's credential-free one, the e2e jobs' full history). Dependabot watches the action's directory too, and both path-scoped jobs (the keyed tier, the binary e2e) count `.github/actions/` as an input. + - **`check.ts`** keeps the `lib/` freshness check (once, `libFreshness`), the consumer install (loading the client half too, through `exports["./client"]`) and the size tripwire. Its file checks are unit tests: `ci-wiring` (the workflows, parsed), `package-checks`, `toolchain`, `no-local-paths` (now over `.github/` and `*.sh`, with Homebrew's prefixes and the credential scan). + - **Scripts:** the generators share `generator-kit.ts` (arguments, source read, the one staleness rule over data) and the catalog's pure half (`catalog-shim.ts`), so a moved plane or family endpoint is stale; `verify-catalog` fails on a registry it could not read unless `--offline`, and `verify-session-band` exits 1 when a fact disagrees (the verdict is `bandVerdict`, its pure half's). The limits renderer is a pure module (`go-limits-render.ts`) whose text is the committed file, so `--write` also applies a template change on current data. `cli:version` reads the registry's dist-tags and refuses a 2.x-form User-Agent no constant pins (L6). + - **Harness bumps:** the nightly `host-drift` job proposes the bump (`scripts/propose-host-bump.ts`) as `chore/host-bump`, moving the version data CONTRIBUTING's release step 3 lists and nothing else; the pull request's own CI runs the tiers and the binary e2e, and it dispatches ci.yml for the floor cells when `piFloor` or a family field moved (D22''). It proposes nothing from a partial read, **waits a night** for a cut inside pnpm's one-day release-age window rather than lifting the window, never edits a measured table (the red rows are the reviewed re-measure, named in the body), and opens a cut that starts a new prerelease tuple, or sits under a dist-tag the record type does not admit, as a range change for review. A dist-tag moves only with `dsh`, so the plan is current exactly when the report prints nothing for the channel. **An open proposal with the same target is left alone** (no push, no edit, no dispatch; the target is the marker line the body ends with, every proposed record in full, since the title names only each record's headline value), and a newer target rebuilds the branch only while every commit on it is the nightly's own (the bot as author and as committer, so a reviewer's amend or rebase counts as the reviewer's; only this repository's own branch is the proposal, never a fork's of the same name), since the re-measure, a shared README sentence and a range plan's peer clause are a reviewer's commits there. Both lockfiles hold one pi, so a proposed pi move carries every record: a channel waiting only on its own young cut takes a pi-only move when its recorded pi range admits the pi, and one that cannot holds the pi move. The plan is written in a step with no token in its environment, which holds because the install runs none of the cut's code (nothing approves a dependency build script: no `allowBuilds` entry, `build` permission or allow-all in `pnpm-workspace.yaml`, no `PNPM_CONFIG_*` variable and no CLI flag in the step, the proposer or the rc relock, and none of the older spellings pnpm 12 ignores); a second step pushes it, and the floor-cell dispatch passes the `keyed` input as `none`, so it spends no key. 12. **P-R3 `fix:` honest refusals.** After P-R2 has recorded the shapes (lessons L2, L7, L9, L10 below). - `PlaneAdapter.prepareCall` classifies the gateway's refusals as non-retryable and keeps their status and body: `FreeTierError` (403) and `FreeUsageLimitError` (429) as the free tier's refusal; `RegionError`, `ModelDeprecated` (410) and `ModelProtocolUnsupported` (400) as "model unavailable". First verify whether an `AUTH` code makes the conversation UI replace the gateway's message, which decides where the codes land. - Set `tool_choice` when the gate tools are injected (`none` on chat completions with no original tools; never on Responses, which accepts only `auto`), and treat a `read`/`bash` tool with empty `properties` as missing. @@ -506,10 +505,10 @@ How each phase changes now that it sits on `main` 1.8.0 and on both harness chan | P-R2 | Splits the free probe's 429: `FreeUsageLimitError` is its own non-retryable verdict, not a rate limit; `ModelDeprecated` reads as gone in the probe classifiers (P-R1 brought it to the turn pre-filter); adds the probe rows of lessons L3, L4, L5, L9, L10 and L13; ports `main`'s mock-coverage gate (#142) over the dispatch tables, each dispatched cell required to reach its own protocol's endpoint. | | P3 | The complement is gated on a claim that has engaged, so once a served row's restart has settled still holding a plane the fetch is pi's own list; since P-R1 speaks both host cores that is only a call shape the listener does not speak, not a channel's cell. A handover's window reads engaged while the row's restart is in flight (complemented before the unload, the catalog alone during it, as in the mount's restart), a throwing restart leaves pi-ai FAILED and the claim engaged, and its Settings fetch rows run in every `npm`/`floor` cell of both channels. | | P-T2 | Absorbs `main`'s tests and owns `docs/testing.md`; folds `test/e2e/verdict.ts` into the live-probe module; tests `main`'s plane lookup fallback. | -| P-T3 | **Shrinks.** `main` already extracted the scripts' pure halves and wrote `docs/testing.md`. Remaining: one unit run with the build before the `lib/` readers, `test/**/*.tsx` type-checked, the composite setup action, `check.ts` trimmed to freshness, consumer install and size, `verify-*` exit codes, and `no-local-paths` over `.github/` and `*.sh`. **New:** the CLI-version script reads the registry's dist-tags and the 2.x User-Agent format (L6); the nightly host-drift report P-T1a added becomes a proposed bump pull request that moves CONTRIBUTING's release-step-3 data, runs the tiers and the binary e2e (dispatching the floor cells when the bump moves `piFloor` or a family field), and reports the rows that go red for the reviewed re-measure rather than editing them (D22''), and a cut opening a new prerelease tuple goes up for review as a range change. (L13's newest-pi cell is no longer P-T3's: under D14'' each channel's default install already is the newest pi.) | -| P-R3 | **New**, after P-R2's re-measure: L2's error mapping in `prepareCall`, L7's `tool_choice` and schema check, L9's `providerRetryAfterMs`, and L10's Off through `none` if measured. | +| P-T3 | **Shrank, as landed.** `main` had extracted the scripts' pure halves and written `docs/testing.md`; P-T3 did the rest: one unit run per install (the default install's is the coverage run, in the required `check` job) with an explicit build before every `lib/` reader, `test/**/*.tsx` type-checked, one composite setup action after each job's own checkout (the checkout cannot move into a local action), `check.ts` trimmed to freshness, the consumer install of both halves and the size tripwire (its file checks moved to `ci-wiring`, `package-checks`, `toolchain` and `no-local-paths`), the generators' one staleness rule, `verify-*` exit codes, and `no-local-paths` over `.github/` and `*.sh`. **New, as landed:** the CLI-version script reads the registry's dist-tags and refuses an unpinned 2.x User-Agent literal (L6; the client-generation table stays P7's); the nightly host-drift report proposes the bump pull request, moving CONTRIBUTING's release-step-3 data, dispatching the floor cells (keyed tier off) when `piFloor` or a family field moved, waiting a night for a cut inside pnpm's release-age window, never editing a measured table, proposing a new prerelease tuple as a range change, and leaving an open proposal with the same target (its body's marker, every proposed record) to its reviewer, with pi moved on every record or none (D22''). (L13's newest-pi cell is no longer P-T3's: under D14'' each channel's default install already is the newest pi.) | +| P-R3 | **New**, after P-R2's re-measure: L2's error mapping in `prepareCall`, L7's `tool_choice` and schema check, L9's `providerRetryAfterMs`, and L10's Off through `none` if measured. Gains the console study's probe rows: OC9 (a raw, non-`ses_` session id, per plane, keyed and free), OC6 (the harness User-Agent against the CLI one, per plane, free and paid) and, beside OC9's, OC8's recorded session-only header set. Also OC10's paired README section ("With OpenCode Console installed") on what D27 ships and tests: the routes the plugin serves beside a Console-owned entry, the workspace's policies gating every turn, and an exported `OPENCODE_API_KEY` becoming the Console's credential. | | P5 | Unchanged, plus L1's warn-once when `globalThis.fetch` is no longer the patched function. | -| P6 | L8: verify before evicting a key; L4's outcome (drop `x-opencode-request`, or send it only where it is accepted). | +| P6 | L8: verify before evicting a key; L4's outcome (drop `x-opencode-request`, or send it only where it is accepted). From the console study: OC3 (capture a key only from a request the plugin decorated, and close the fill path that could reuse a captured token; the marker guard, never capturing from a request carrying `x-opencode-plugin`, landed with D27), OC4 (`prepareHeaders` on the plane adapter, so the host's `llm-pi-ai/request-headers` waterfall runs on the planes), OC5 (the plane's headers set per call through that option, not the global fetch patch), OC11 (the peer range says what the header seam needs), OC2's remainder (the marker pass-through, which landed with D27 and reads the header's presence today, moves into `src/utils/request-shape.ts`; its second rule is not adopted) and OC10's last sentence (the README's coexistence section gains that the meter and headers stay off Console traffic, once OC3's discriminator lands). | | P7 | L6's client-generation table and L11's context-window overrides table; the `planeForRoute` copies now include `main`'s `isGoPlaneUrl` and `catalogPlaneForRoute` pair; the places that justify `@ai-sdk/mistral` → completions by "the plugin follows pi" (`docs/redirect-planes.md` §2, `docs/protocol-routing-and-merge.md`'s Mistral paragraph and its SDK table row, this plan's protocol row in the P-R1 design, the SDK table row in `README.md` and `README.zh-CN.md`, `test/protocols.test.ts`'s SDK-table case title and its `piKnows` assertion, which at most stays an informational pi characterization, and `test/catalog.test.ts`'s note that pi's data files the SDK as completions) are reworded to the plugin's own routing rule, since pi's catalog no longer decides a protocol (D26). | | P8 | The health snapshot shows the catalog's `lastError` on the card. | | P9 | A loopback triple guard, only if a `webServer` route appears. | @@ -538,7 +537,27 @@ Two community plugins solve the same problem on the same host: [`FishBottle7/ope | L13 | **Test against the host's newer pi**, not only the pin: unit and tier A on the newest pi the host's range resolves, not just the binary e2e; the `/zen/v1/systemone` family belongs in P-R2's families. | FB's hoisted-pi test and loader hook. | P-T1a (unit and mock on each channel's newest pi), P-T1b (tiers A and B), P-R2 (the family) | Adopt | | L14 | Smaller: a triple loopback guard if a `webServer` route is ever added (the remotes seam remains the better one); the host's `volatileForm()` as an alternative to hand-written card fields; the catalog's `lastError` on the card rather than in a file. | FB `packages/plugin/src/ip-pool-settings/bridge.ts`, `packages/plugin/src/config.ts`. | P9, P8 or P11, P8 | Adopt where the phase reaches it | -**Do not adopt:** SF's Bun transport and its `<env>` prompt injection (which also leaks the host server's working directory); FB's IP pool, sibling-host replay and credential-custody delegation; automatic retry with thinking turned off; FB's own stream watchdogs (the host has one). These circumvent the vendor's stated gate ("Free models work in OpenCode"); the vendor answers with IP blocks, and these features would expose our users' keys. Classify the refusals honestly instead (L2, L3). Also avoid their session-id scheme, which is derived from the first user message and re-minted per process: ours is stable per DSH session (`src/utils/session-id.ts`). +**Do not adopt:** SF's Bun transport and its `<env>` prompt injection (which also leaks the host server's working directory); FB's IP pool, sibling-host replay and credential-custody delegation; automatic retry with thinking turned off; FB's own stream watchdogs (the host has one). These circumvent the vendor's stated gate ("Free models work in OpenCode"); the vendor answers with IP blocks, and these features would expose our users' keys. Classify the refusals honestly instead (L2, L3). Also avoid their session-id scheme, which is derived from the first user message and re-minted per process: ours is stable per DSH session (`src/utils/session-id.ts`). From the console study (OC13): writing the user's patch file (D8: the plugin never writes the user's profile on its own), providing a `credentials` service (a second one in the default realm throws, and the plugin needs none), and reading the Console's sign-in (credential custody that would bypass the workspace's own policy gate). + +## Lessons from @opencode/console + +The vendor's own first-party DSH plugin, `@opencode/console` 0.1.10 (`anomalyco/opencode-console`), read in its published form: its `dsh-plugin.js` and its `setup deepseek-harness` CLI. The shipped files are minified, so evidence cites a file and a quoted identifier, never a line. Each lesson was checked by two skeptics independently, and the verdict is what they recorded: upheld by both, split, or not upheld as written, with the correction that survives. Where the study's phase differs from the plan's, the phase column is the plan's. + +| # | Lesson | Evidence | Phase | Verdict | +| :-- | :-- | :-- | :-- | :-- | +| OC1 | **The route claim steps aside for a plane entry the Console owns.** `setup deepseek-harness` writes an `llm-pi-ai` patch row whose `providers` are the workspace's routes, under our two plane keys (`opencode` first, `opencode-go` second), each with `apiKeyEnv: OPENCODE_CONSOLE_TOKEN` and the marker header `x-opencode-plugin: opencode-console/<ver>`, and isolates pi-ai into the Console's `credentials` realm, the only one that resolves the token. Claimed by us, every Console turn on those keys failed with `MISSING_CREDENTIAL` before any I/O. | The setup CLI's bundle (`chunk-xs6kvyat.js`): the row `{ id: "llm-pi-ai", isolate: { credentials: … }, config: { providers: e.routes } }` and each route's `apiKeyEnv` and `headers`; its route order (`chunk-a2ds5vde.js`, `e === "opencode" ? 0 : e === "opencode-go" ? 1 : 2`); `dsh-plugin.js`'s credentials service answering `OPENCODE_CONSOLE_TOKEN` only; dsh-app-boot's patch apply (`target[key] = value`), which replaces the row's config. | Landed in P-R1 as D27 | Upheld by both skeptics, with details corrected: the setup skips the row when the patch file already configures `llm-pi-ai`, and ownership is detected from the row and the entry, never from a vendor name or a version. | +| OC2 | **The fetch patch must not touch traffic that carries another plugin's marker.** The global fetch patch claims every gateway URL, so a Console request that reaches it gains our headers and has its token captured. | `dsh-plugin.js`'s `requestHeaders` (`x-opencode-session` set only when the request carries `x-opencode-plugin`); the marker on every route the setup CLI builds; ours, `src/fetch-patch.ts` (`gatewayUrls`, the session fill). | Rule 1's pass-through landed in P-R1 as D27 (on the marker's presence); P6 moves the decision into `src/utils/request-shape.ts` | Not upheld as written. Survives: rule 1, a request carrying `x-opencode-plugin` reaches the original `fetch` untouched (no header writes, no credential fill, no key capture), decided in `src/utils/request-shape.ts` from the header's value, with red tests that perform the request. Dropped: rule 2 (never replace an existing `x-opencode-session`), which reverses the measured `401 Invalid API key` on a malformed id (L8), and the header list the lesson gave, which was wrong in two places. | +| OC3 | **Never capture the Console's workspace token.** Key capture records every non-placeholder key on a claimed request, and the Go meter's `auto` policy reads a captured key early, so the Console's policy-bound token would be replayed against the Go plane's `/usage`. | `dsh-plugin.js`'s credentials service (`source: "OpenCode Console"`); ours, `src/fetch-patch.ts` (`recordCapturedApiKey`), `src/go-discovery.ts` (`auto` reads `captured` second), `src/usage.ts` (`/usage`). | P6 | Upheld by both skeptics, with the discriminator corrected: capture only from a request the plugin decorated, the marker a second guard, and the fill path that could reuse a captured token closed in the same change. | +| OC4 | **Run the host's `llm-pi-ai/request-headers` waterfall on our planes.** pi-ai wires that waterfall only through `PiAiAdapter`'s `prepareHeaders` option, and `PlaneAdapter` is built without it, so no contributor runs on the routes the plugin owns. | dsh-llm-pi-ai's own `apply()` (`prepareHeaders: (request, headers) => ctx.waterfall("llm-pi-ai/request-headers", …)`) and its `requestHeaders(…, this.config.prepareHeaders)`; ours, `src/host/plane-provider.ts` (`new PlaneAdapter({ … })` with no `prepareHeaders`). | P6 | Upheld by both skeptics, with scope, rationale and priority corrected: after D27 the Console's routes are not ours, so the reason is any other contributor; where an adapter has no such option it is simply unused, detected by feature. | +| OC5 | **Set the planes' per-call headers through the host seam, not the global fetch patch.** The Console shapes its inference headers with no fetch patch, from a frozen request context that carries `sessionId` and `provider`. | `dsh-plugin.js` (`e.on("llm-pi-ai/request-headers", …)`); dsh-llm-pi-ai's frozen request context, after which attribution wins and a throw fails the call with `REQUEST_HEADERS`. | P6 | Not upheld as written. Survives: set the plane headers per call through OC4's `prepareHeaders` on our own `PlaneAdapter` (a `ctx.on` listener never fires on routes the plugin claimed), with today's derivation (`headerValueFor(request.sessionId, createdAt)`; a bare digest would re-key every session), never throwing, while the fetch patch keeps URL-claimed relays, listing enrichment, the credential fill, the tool-fallback body and any User-Agent override, so the `globalThis.fetch` exemption stays while any of that remains. Both skeptics placed this with L1 in P10a; the plan files it in P6, beside OC4. | +| OC6 | **User-Agent: the host contract says attribution wins, so decide rather than fight it.** pi-ai applies the harness User-Agent after every contributor, so the Console's traffic reaches the gateway with it; the plugin overrides it only through the global fetch patch. Whether the free tier needs the CLI's User-Agent has not been measured. | dsh-llm-pi-ai (`attributionHeaders()` applied after `prepare`); dsh-llm's attribution (`'user-agent': userAgent(identity)`); the Console's own User-Agent only on its Console API calls; ours, `src/fetch-patch.ts` (`headers.set("User-Agent", …)`). | P-R3 probe rows | Split. One skeptic upheld "measure, then let the owner decide"; the other held three facts overstated and the phase wrong. Survives: a keyed and a keyless row per plane, the harness User-Agent against the CLI's, free and paid. If the gateway accepts the harness one, dropping the override is an owner decision (a D-row and a `fix:` in a 1.x minor); if not, the override stays the one named exemption, with the reason written down. | +| OC7 | **Characterize coexistence on the real host with a Console-shaped fixture.** Tier B boots the loader, which applies `isolate:` realms on every supported build, so the Console's shape can be reproduced with this repository's own code: a pi-ai row isolated into a realm, marked plane routes, and a local plugin in that realm providing `credentials`. | The loader's `isolate` (`GlobalRealm`, `'@' + this.label`); `ctx.get` following the realm on both cordis cores; `dsh-plugin.js`'s realm credentials. | Landed in P-R1 as D27 | Upheld by both skeptics, with the fixture amended: our own code, never the vendor's files, and a known answer beside it (an unmarked plane still claimed). It is `test/host-planes-foreign.test.ts`, on tier B's `realm` option. | +| OC8 | **The vendor's minimal inference header set is evidence for L4 and L5.** On inference the Console sends two OpenCode headers, the raw session id and its marker, and its org header only on Console API calls. | `dsh-plugin.js`'s `requestHeaders`; the marker the setup CLI puts on each route; `x-org-id` only on the Console API client. | P-R3 probe row, beside OC9's | Not upheld as written. Survives: one recorded, never-asserted session-only row per plane (`x-opencode-session` and the User-Agent; no client, project or session-id header, and never the vendor's marker), which needs an omit list on `KeyedVariant`, read beside the free probe; a pass shows acceptance only. Dropped: the org-header half (a different header on a different endpoint), and deciding L4 and L5 from it, which P-R2's `KEYED_VARIANTS` already measure. | +| OC9 | **Measure whether the gateway accepts a raw, non-`ses_` session id.** The Console and the vendor's Pi extension send the host's raw session id; the plugin derives `ses_<12hex><14base62>`, and L8 records a malformed id answering `401 Invalid API key`. | `dsh-plugin.js` (`p.set("x-opencode-session", a.sessionId)`, behind the marker); the Pi extension's `"x-opencode-session": d.sessionId`; the host's `sessionId: this.session.id`; ours, `src/utils/session-id.ts`. | P-R3 probe rows | Upheld by both skeptics, narrowed: the vendor's raw id travels only on workspace routes with the workspace token, so the row is per plane, keyed and free; the derivation stays unless the row passes on both planes and the free tier, and L8 must not read a well-formed foreign id as malformed. | +| OC10 | **Document coexistence and the shared `OPENCODE_API_KEY`.** With the Console set up, pi-ai's providers are the workspace's routes until `--remove`, and the Console reads `OPENCODE_API_KEY` first, as its own key. | The package's README ("the workspace's models replace the model providers you added"); the setup CLI's credential read (`e.env.OPENCODE_API_KEY` ahead of the stored sign-in). | A docs follow-up on D27 (the shipped, tested half), first in P-R3; the meter-and-headers sentence with P6 | Not upheld as written. Survives: a paired README section ("With OpenCode Console installed") stating only shipped, tested behaviour: which routes the plugin serves under D27, that the workspace's policies gate every turn and tool call (ours included), and that an exported `OPENCODE_API_KEY` becomes the Console's credential, so a user of both gives the planes their own reference. Not until code makes it true: that the meter and headers stay off Console traffic (OC2, OC3). | +| OC11 | **Make the peer range say what the header seam needs.** OC4 and OC5 rely on `llm-pi-ai/request-headers`, which no 0.2.1 bridge has, while the `@deepseek-ai/dsh-typert-protocol` peer range still admits 0.2.1. | `package.json`'s `peerDependencies`; the 0.2.1 bridge carries no `request-headers`, the 0.2.2 ones do. | P6 | Split. One skeptic upheld it (a declaration, not a runtime version check, so D26 allows it); the other held that the proposed `>=0.2.2-0 <0.3.0` still admits a published build without the seam. Survives: narrow the declaration in the change that lands OC5, to a floor that has the seam, pinned beside the declaration checks in `test/harness-channel.test.ts`. | +| OC12 | **Cross-check the endpoint families and the served-catalog filter against the vendor's vocabulary.** The Console derives its gateway root from a `<root>/(go/)?(anthropic/v1, openai/v1, google/v1beta or v1)` base and serves only chat models with tools and text output. | The setup CLI's root pattern and model filter (`chunk-a2ds5vde.js`); its api map and completions compat (`chunk-xs6kvyat.js`). | None | Not upheld as written. Survives: nothing to adopt; the vendor's shapes are all among P-R2's families, and pi already agrees with it on developer role and store for our planes. At most an optional, recorded `max_completion_tokens` completions row (a gap it finds is pi's, reported upstream as a `HOST_LIMITATION`, never patched here), and a P10b filter only as an owner decision on D3. | +| OC13 | **Do not adopt: writing the user's patch file, providing `credentials`, reading the Console's sign-in.** The Console rewrites the user's patch file on setup and from its sync, provides a private `credentials` service in a loader realm, and holds an OAuth sign-in in its own data directory. | The setup CLI's bundle (the `# opencode-console:start/end` block it writes); `dsh-plugin.js` (`e.provide("credentials", …)`, its sync interval and its policy hooks). | None; under "Do not adopt" | Upheld by both skeptics, with one detail corrected: the sync rewrites the block only when its content changed, not on every tick. | ## Test matrix @@ -685,6 +704,18 @@ Two community plugins solve the same problem on the same host: [`FishBottle7/ope - the two moved listener files (`patched-fetch-headers`, `refusal-classification`); - the host-drift CLI against a loopback registry (`host-drift-cli`), moved from P-T1a's unit file because it binds a socket. +**P-T3** + +- **Unit:** + - `ci-wiring`: every YAML file under `.github/` parses. Rules shown to bite on a fixture first: one setup action after each job's own checkout; a build before every `lib/` reader; no comment naming a pnpm version `packageManager` does not pin; Dependabot over every directory that pins an action; the catalog job's fork rule; every job in every workflow bounded (release-please's included). Executed: the setup action's Node choice (`.node-version`, or the input), with its steps in order. Pins on the real files, with no fixture: one unit run per install, evaluated for each of the four events and every matrix cell; the type-check (the `check` job's `pnpm run check`, and the tsx tests in the program `tsc` lists; `toolchain` holds `typeCheck` on); the required-check name, on the job that runs the default install's `test:coverage` unconditionally (no `if:` on the step or the job); the release wiring `check.ts` used to hold; + - `package-checks` (`libFreshness`, `peerRangeProblems`, the bundle fields, the harness surfaces, locale parity; `check.ts` importing only `libFreshness` and, in the consumer's child, `clientBundleProblems`, and naming no other rule), `toolchain` (the lint config `vp` loads, every toolchain script through `vp`), `no-local-paths` (`.github/`, `*.sh`, Homebrew's prefixes, the credential scan), `node-engine` (`.node-version`, read by the setup action; only the engines-floor job passes its own Node); + - `generators` (the one staleness rule over data; both renderers' round trips through the formatter; the limits generator spawned with `--from` current and stale, and with `--write` from a scratch copy of `scripts/` and `src/` (a template change rewritten, a moved figure written), the catalog generator spawned stale and on an unreadable source (no payload reproduces the shipped shim); `outcome`'s stale × `--write` × text combinations, the limits generator's template rewrite among them; `verify-catalog` spawned and made to fail; `verify-session-band`'s verdict made to fail on a planted derivation, the script spawned passing), `package-checks`' `clientBundleProblems` (on `lib/client.js`, a wrong id, another `NS`, a bundle that throws), `vendor-version` (dist-tags, the snapshot builds listed and never classified, the 2.x form) and `freshness-scope` (the kit modules as generator inputs, `--copies` refusing a 2.x literal); + - `host-drift` (`readChannel` reading every bump field once; the two proposal steps nightly only, the planning one with no token in the environment it inherits from the workflow, the job and itself, and nothing approving a build script (an `allowBuilds` entry, a `build` permission or the allow-all in `pnpm-workspace.yaml`, a `PNPM_CONFIG_*` variable in the step's composed environment, a CLI flag or `PNPM_CONFIG_` setting in the step's shell, the proposer or the rc relock (`scripts/use-channel.ts`), and the older spellings pnpm 12.16.1 was measured to ignore: package.json's `pnpm` field, `.npmrc`, `npm_config_*`, the allow-list and the deny-list, an empty one included; each a known answer), the environment composed once (`stepEnv`) for the check and its job-level-token known answer; executed with stub `node`, `pnpm`, `git` and `gh` under a bash 3.2 stand-in that shadows `mapfile` and `readarray` with functions (its known answers: both refused, the steps' read loop running): no title, formatting, the same target (the body's marker, CRLF line ends included) left alone, the same title over a different target rebuilt, a fresh proposal with the dispatch at `keyed=none`, a newer target over the nightly's own commits rebuilt and over a reviewer's (as author or as committer) left alone, a fork's pull request from a branch of the same name never read as the proposal (the stand-in's per-owner answer run first as its known answer), and a `gh` failure, the body read included, pushing nothing) and `host-bump` (the plan as data, range, wait or nothing, never from a partial read; a pi patch on both channels, a moved pi range, a cordis cut outside its peer; a dist-tag that moves only with `dsh`, an unknown tag as a range change; the plan current exactly when the report has no line; pi as one value (`coupleSharedPi`: a channel waiting on its own cut takes the pi move, in both directions; a partial read, a channel current at the old pi, a waiting channel whose recorded pi range stops short (its admitting range the known answer), a dsh-only proposal beside a pi mover (itself returned unchanged) or two movers at different pis holds it, and a waiting plan converted beside a third channel's hold goes back to its wait; with no mover nothing changes); the record, manifest and README edits, `applyBumpTexts` on both plans, on each alone, on both moving a shared value apart (left for review in both, in either order) and on one moving TO the other's old value (left for review, the same text in either order, with moves elsewhere the known answer), with `README_HELD_FIELDS` the list `harness-channel` loops over; the title, the body and its target marker, which a pi move under an unchanged title changes). + - `keyed-probes` (the e2e job's tier step executed per event: schedule `full`, a dispatch its `keyed` input, `full` when empty, a push `none`, a pull request `probes` on a match and `none` on a miss, and a diff that cannot be computed failing the step with nothing armed; the e2e-host scope step executed the same way, `run` on anything but a pull request, on a match and not on a miss, and failing on a diff it cannot compute; both scope patterns counting `.github/actions/`). +- **Mock:** + - `cli-version` (the dist-tags request, a pinned `latest`, a bump on the pinned major, a generation change, a refusing registry); + - `host-drift-cli`'s proposal cases (a data plan, a cut inside the release-age window, a refusing registry, the `--out` files with no tracked file touched, a channel waiting on its own cut moved to the pi the other proposes, and on a scratch copy through `--root` a successful `--write` whose `files` list is exactly what changed on disk, with both records at the one pi, the installs it promises and the body's target, and one whose pnpm step fails putting every file back). + **P6** - **Unit:** apply A, apply B, dispose A, dispose B ends at the native `fetch`, in both orders (red on the base). @@ -693,7 +724,7 @@ Two community plugins solve the same problem on the same host: [`FishBottle7/ope ### The `e2e-host` job (`ci.yml`) -**When it runs:** on dispatch, nightly, and on PRs that touch the host seam — `src/host/**` and, beside it, `src/lifecycle.ts`, `src/identity.ts`, `src/cordis-context.ts` and `src/models-discovery.ts`, whose behaviour depends on the real loader and apply order, and the two modules that still install host-level patches until P6/P10a move them under `src/host/` (`src/fetch-patch.ts`, the global fetch wrapper whose order the probe reads, and `src/stream-hook.ts`) (every other row producer, such as the pure plane model and profile in `src/utils/`, `src/key-capture.ts`, `src/models-catalog.ts` or `src/config.ts`, is covered on each PR by the in-process characterization of the same rows, and by the nightly run) — `cordis.patch.yml`, `test/e2e/host/**`, `test/e2e/host-binary.e2e.ts`, `test/support/host.ts`, `test/support/e2e-env.ts` (the reader the binary e2e's gate, prefix and cell come through; its channel is `host-versions.ts`'s `selectedHost`), `test/support/host-versions.ts`, `scripts/host-drift.ts` (the family table the binary e2e asserts against the installed manifests), `.github/workflows/ci.yml`, `vitest.e2e.config.ts` or `vite.config.ts` (the pack config that builds `lib/index.mjs`, which only this job loads into a real host, and the test settings the e2e config inherits) (scoped by a step, since a workflow-level path filter would silence every job). +**When it runs:** on dispatch, nightly, and on PRs that touch the host seam — `src/host/**` and, beside it, `src/lifecycle.ts`, `src/identity.ts`, `src/cordis-context.ts` and `src/models-discovery.ts`, whose behaviour depends on the real loader and apply order, and the two modules that still install host-level patches until P6/P10a move them under `src/host/` (`src/fetch-patch.ts`, the global fetch wrapper whose order the probe reads, and `src/stream-hook.ts`) (every other row producer, such as the pure plane model and profile in `src/utils/`, `src/key-capture.ts`, `src/models-catalog.ts` or `src/config.ts`, is covered on each PR by the in-process characterization of the same rows, and by the nightly run) — `cordis.patch.yml`, `test/e2e/host/**`, `test/e2e/host-binary.e2e.ts`, `test/support/host.ts`, `test/support/e2e-env.ts` (the reader the binary e2e's gate, prefix and cell come through; its channel is `host-versions.ts`'s `selectedHost`), `test/support/host-versions.ts`, `scripts/host-drift.ts` (the family table the binary e2e asserts against the installed manifests), `.github/workflows/ci.yml`, `.github/actions/` (the composite setup action, which holds the install), `vitest.e2e.config.ts` or `vite.config.ts` (the pack config that builds `lib/index.mjs`, which only this job loads into a real host, and the test settings the e2e config inherits) (scoped by a step, since a workflow-level path filter would silence every job). **What it does:** @@ -720,8 +751,9 @@ What spends a key is a tier, `OPENCODE_E2E_KEYED`, which a step of the `e2e` job | Run | Tier | What runs | | :-- | :-- | :-- | | a PR | `none` | the keyless cases only, no key spent: the matrix, the free probe, the System One family's three shapes (recorded), and the keyless cases of protocol-routing and opencode-live | -| a PR touching `src/host/**`, `src/utils/protocols.ts`, `src/utils/plane-model.ts`, `src/identity.ts`, `src/utils/request-shape.ts` or the probes' own inputs | `probes` | adds the keyed probes: one request of a few tokens per plane × protocol × URL shape, plus six recorded variants of each plane's default completions cell (request id, project, reasoning effort) and every 429's `Retry-After` | -| nightly, dispatch | `full` | adds the full keyed suites | +| a PR touching `src/host/**`, `src/utils/protocols.ts`, `src/utils/plane-model.ts`, `src/identity.ts`, `src/utils/request-shape.ts` or the probes' own inputs (the composite setup action under `.github/actions/` among them) | `probes` | adds the keyed probes: one request of a few tokens per plane × protocol × URL shape, plus six recorded variants of each plane's default completions cell (request id, project, reasoning effort) and every 429's `Retry-After` | +| nightly | `full` | adds the full keyed suites | +| dispatch | its `keyed` input (`full` when empty; `none` from the nightly bump proposal's floor-cell dispatch) | as that tier | | a push to main | `none` | keyless only | - **The keyless matrix** (every run): 3 shapes × 4 protocols × 2 planes. A 404 means the path is gone; a 400 "Model is unavailable", a 410 or a refusal of type `ModelDeprecated` at any status means the model is gone. It asserts that every cell has a model and was sent, that every legacy and modern answer is readable (a redirect, a rate limit or no answer is not), and that no cell the default dispatches to (`TODAY_DISPATCH`: all four protocols on the modern family, on every host cell) answers 404. The free probe asserts that a free completions model is served or free-tier-gated on `/inference/openai/v1/chat/completions`; a retired model (a `ModelDeprecated` refusal included) or a busy upstream moves on to the next candidate (`runFreeProbe`, which records the deciding candidate's own model and status), and it skips on a rate limit, a busy upstream no candidate got past, or the free tier's usage cap (`429 FreeUsageLimitError`, `free-usage-limited`), never retried and never read as served. diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index e4816f66..8e5ee01b 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -96,7 +96,7 @@ The gap was structural, not a missing branch: **the plane was not part of a rout - The §5 guards. The plane × SDK case was written first and **failed against the code as it was** - `opencode-go + @ai-sdk/openai` and `+ @ai-sdk/anthropic` - then passed once the Go routes landed, which is what makes it a check rather than a restatement. - `pnpm run test:mock` — the offline gateway mock (`test/mock/`): a Vite dev server that answers every endpoint in its own wire format and refuses a request whose shape its endpoint does not accept — an unknown path, a model sent to an endpoint that cannot speak its format, a missing session header, a placeholder credential. It drives the real path (pi's protocol implementation → `patchFetch` → the mock) across both planes, all four protocols and both endpoint families, so §9's table is exercised rather than quoted. No key and no network, so it gates every push. -- `pnpm run test:e2e` with keys present. The probe now takes the plane, and one case sends a real request to the Go base with the Go credential, asserting only that the gateway PARSED the model - any answer but 404 or 500 - so the case does not bet on an entitlement. What spends a key is tiered by `OPENCODE_E2E_KEYED`: `none` spends no key, so only the keyless cases run (the keyless matrix, the free probe, and the keyless cases of protocol-routing and opencode-live), `probes` adds the minimal keyed probes (`keyed-probes.e2e.ts`), and `full` — the default when it is unset, and what CI runs nightly and on dispatch — adds the full keyed suites. CI runs `probes` on a pull request that touches `src/host/**`, `src/utils/protocols.ts` (the legacy URLs the probes send and the protocol each probe model speaks), `src/utils/plane-model.ts` (the family the plugin dispatches to, `AUTO_ENDPOINT_FAMILY`, which the probes must keep matching), `src/identity.ts` (the CLI version in the user agent the probes carry), `src/utils/request-shape.ts` (the `anthropic-version` every Messages probe sends) or the probes' own inputs, `full` nightly and on dispatch, and `none` otherwise. Nothing else arms them. Left out, and covered by the offline suite and the nightly run, which arms everything: the generated catalog (`src/catalog-data.ts`) and the live model choice (`src/models-catalog.ts`), which pick the probes' modern URLs and models and whose paths the keyless matrix checks on every pull request; and the `OPENCODE_UA` alias in `src/session.ts`, which a turn and a probe share, so `test/keyed-probes.test.ts` holds the probe's identity to the real fetch patch offline. +- `pnpm run test:e2e` with keys present. The probe now takes the plane, and one case sends a real request to the Go base with the Go credential, asserting only that the gateway PARSED the model - any answer but 404 or 500 - so the case does not bet on an entitlement. What spends a key is tiered by `OPENCODE_E2E_KEYED`: `none` spends no key, so only the keyless cases run (the keyless matrix, the free probe, and the keyless cases of protocol-routing and opencode-live), `probes` adds the minimal keyed probes (`keyed-probes.e2e.ts`), and `full` — the default when it is unset, what CI runs nightly, and what a dispatch runs unless its `keyed` input says otherwise (the nightly bump proposal's floor-cell dispatch passes `none`) — adds the full keyed suites. CI runs `probes` on a pull request that touches `src/host/**`, `src/utils/protocols.ts` (the legacy URLs the probes send and the protocol each probe model speaks), `src/utils/plane-model.ts` (the family the plugin dispatches to, `AUTO_ENDPOINT_FAMILY`, which the probes must keep matching), `src/identity.ts` (the CLI version in the user agent the probes carry), `src/utils/request-shape.ts` (the `anthropic-version` every Messages probe sends) or the probes' own inputs (the composite setup action under `.github/actions/` among them), `full` nightly, a dispatch's `keyed` input (`full` when empty) on a dispatch, and `none` otherwise. Nothing else arms them. Left out, and covered by the offline suite and the nightly run, which arms everything: the generated catalog (`src/catalog-data.ts`) and the live model choice (`src/models-catalog.ts`), which pick the probes' modern URLs and models and whose paths the keyless matrix checks on every pull request; and the `OPENCODE_UA` alias in `src/session.ts`, which a turn and a probe share, so `test/keyed-probes.test.ts` holds the probe's identity to the real fetch patch offline. - The Zen plane compared before and after: route ids, mounted model ids and base URLs must be byte-identical. ## 8. The full path, front to back diff --git a/docs/testing.md b/docs/testing.md index 4d0fafaf..cec94ec2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,9 +12,9 @@ Seven layers, each answering a question the others cannot. What follows is the m | `pnpm run test:e2e` | live gateway | the plugin against **opencode.ai itself**: keyless on every pull request, keyed by tier (`OPENCODE_E2E_KEYED`: `none`, `probes`, `full`) | yes | | `pnpm run test:e2e:host` | published host | the built bundle inside the published `dsh` binary, per channel and cell (`DSH_HOST_E2E=1`, `DSH_HOST_PREFIX`) | install only | | `vp test run --coverage` | ratchet | the floors the suite must stay above, overall and per load-bearing file | no | -| `node --experimental-strip-types scripts/check.ts` | bundle gate | the shipped client bundle and the consumer install | install only | +| `node --experimental-strip-types scripts/check.ts` | bundle gate | `lib/` is the current build, the packed tarball installs for a consumer and both halves load from that install (the host entry applies; the client bundle resolves through `exports["./client"]`, registers its ids and answers its factory), and the client stays under its size tripwire | install only | -Beside them, CI's vendor gates (`catalog freshness`, `pnpm run cli:version`) hold the generated tables and the claimed CLI version to the vendor, and the nightly `host-drift` job holds `test/support/host-versions.ts` to the registry. The first five layers are the suite, the published host included (its cases assert the same tagged characterization rows as the in-process tier); the coverage ratchet and the bundle gate are gates, as are the vendor gates and `host-drift` beside them. **A gate is not a test**: it fails the build, it does not explain a behaviour. +Beside them, CI's vendor gates (`catalog freshness`, `pnpm run cli:version`) hold the generated tables and the claimed CLI version to the vendor, and the nightly `host-drift` job holds `test/support/host-versions.ts` to the registry. The first five layers are the suite, the published host included (its cases assert the same tagged characterization rows as the in-process tier); the coverage ratchet and the bundle gate are gates, as are the vendor gates and `host-drift` beside them. **A gate is not a test**: it fails the build, it does not explain a behaviour. The file checks the bundle gate used to run (the workflows' wiring, the manifest, the toolchain, the credential scan) are unit tests now: `test/ci-wiring.test.ts` reads the workflows parsed (`test/support/workflows.ts`), `test/package-checks.test.ts` the manifest and the locales, `test/toolchain.test.ts` the lint config `vp` actually loads, and `test/no-local-paths.test.ts` the tree, `.github/` and the shell scripts included. **Both harness channels.** The unit, real-host and mock layers run on the install of the channel `test/support/host-versions.ts` selects: `alpha` (`dsh@alpha`) is the root lockfile, `rc` (its newest build, `dsh@next` ahead of `dsh@latest`) is `channels/rc/pnpm-lock.yaml`, each with pi unpatched at the newest version its bridge's range admits. `node --experimental-strip-types scripts/use-channel.ts <rc|alpha> [--floor]` switches the local install (`--floor`: pi at the range's floor, the desktop app's pi, never committed); CI runs both channels' default installs on every pull request and the floors nightly. @@ -72,7 +72,7 @@ Properties the unit and mock suites keep by construction rather than by care: **A model with no price in a fixture is PAID at a nominal rate, not free.** The parser derives `is_free` from a zero cost, so a "no price" default would silently turn every fixture model into a free tier. -The others, each named for its subject: `composition.ts` and `cordis-wait.ts` (the plugin mounted on a real `Context`, and waiting on one), `loader-rows.ts` and `usage-fixture.ts` (loader rows, credentials and the usage service's endpoint), `host.ts`, `host-planes.ts` and `host-versions.ts` (the real host, its plane-ownership helpers, and the channel records), `route-claim-stand-in.ts`, `card-tree.ts` and `ui-primitives.ts` (the settings card's tree and the host's UI primitives, for the jsdom files), `e2e-env.ts`, `keyless-matrix.ts`, `keyed-probes.ts`, `live-walk.ts`, `live-probe.ts` and `endpoint-record.ts` (the live suites' pure halves), `thinking-levels.ts` and `served-model.ts` (which models think in Google levels, and the model object the plane adapter serves for a row, shared by the offline and live dispatch-levels files), `host-drift-fixture.ts` (registry views for the host-drift rules), and `strip-comments.ts` (the comment blanking the source-reading gates in `test/architecture.test.ts` read through, held by `test/strip-comments.test.ts`). +The others, each named for its subject: `composition.ts` and `cordis-wait.ts` (the plugin mounted on a real `Context`, and waiting on one), `loader-rows.ts` and `usage-fixture.ts` (loader rows, credentials and the usage service's endpoint), `host.ts`, `host-planes.ts` and `host-versions.ts` (the real host, its plane-ownership helpers, and the channel records), `route-claim-stand-in.ts`, `card-tree.ts` and `ui-primitives.ts` (the settings card's tree and the host's UI primitives, for the jsdom files), `e2e-env.ts`, `keyless-matrix.ts`, `keyed-probes.ts`, `live-walk.ts`, `live-probe.ts` and `endpoint-record.ts` (the live suites' pure halves), `thinking-levels.ts` and `served-model.ts` (which models think in Google levels, and the model object the plane adapter serves for a row, shared by the offline and live dispatch-levels files), `host-drift-fixture.ts` (registry views for the host-drift rules and the bump proposal), `workflows.ts` (the workflows under `.github/` parsed, a step's `if:` evaluated per event and matrix cell, and a `run:` reduced to the commands it invokes), and `strip-comments.ts` (the comment blanking the source-reading gates in `test/architecture.test.ts` read through, held by `test/strip-comments.test.ts`). ## The scripts' pure halves @@ -80,12 +80,17 @@ A gate is a script, and a script that runs at import time cannot be tested — s | script | the decision, extracted | what the test holds | | :-- | :-- | :-- | -| `generate-go-limits.ts` | `scripts/go-limits-html.ts` | which table is an allowance (by header, not position), the refusal when the page carries none, and the round trip through the real parser | -| `check-vendor-version.ts` | `scripts/vendor-version.ts` | the constant is found, "read nothing" is distinguishable from "passed", and the scan reads every file a copy can live in but never the generated tables | -| `report-host-drift.ts` | `scripts/host-drift.ts` | what each channel installs today, read the way the record's fields are defined (`dsh` as the newest build any dist-tag carries on the record's line, so `next` and `latest` both count for rc, or a newer stable release, the line graduating, under the tag the record names for that (`graduatesUnder`: `latest` for rc, none for alpha); pi as the bridge's range, that range's floor and the newest version it admits, a prerelease only on its own tuple; each of the seven cordis-family members, cordis, the loader, include, group, timer, cosmokit and schemastery, at the floor of the `~` range its declarer (`FAMILY_DECLARERS`) declares in the dist-tagged cluster, so a newer patch inside a range is not drift), one line per field that moved, and "the registry answered nothing" never read as current; known answers: today's registry prints nothing, the next cut prints one line per moved field, and a record whose timer is not the declared floor prints that one line; the CLI runs against a loopback registry in the mock suite (`test/mock/host-drift-cli.mock.ts`) and the CI job's condition is evaluated (nightly and dispatch only) | +| `regenerate-catalog-shim.ts` | `scripts/generator-kit.ts`, `scripts/catalog-shim.ts` | the one staleness rule both generators and `verify-catalog.ts` share (`stale`: data differs; key order and formatting never count), over every export the shim carries, the plane and family endpoints included; the arguments (`--from` with no path refused); the exit and action (`outcome`: a file current in data and in text is never rewritten; without `--write` a template change is still `current`, and with `--write` current data in text that is not the renderer's is `rewrite`, which only the limits generator, the one whole-file renderer, does); and the renderer reproducing the shipped shim byte for byte once formatted | +| `generate-go-limits.ts` | `scripts/go-limits-html.ts`, `scripts/go-limits-render.ts`, `scripts/generator-kit.ts` | which table is an allowance (by header, not position), the refusal when the page carries none, and the round trip through the real parser; staleness is the shared rule over `GO_MODEL_LIMITS`, not the file's bytes; the renderer's text is the committed file byte for byte, before and after the formatter, so `--write` also applies a template change on current data; spawned with `--from` on a doc made from the shipped table (current, exit 0) and with one figure moved (stale, exit 1), and with `--write` from a scratch copy of `scripts/` and `src/` (current data in reflowed text left alone without `--write` and rewritten with it, a moved figure written) | +| `verify-catalog.ts` | `scripts/catalog-shim.ts` | the shim against the registry by the generator's own rule; a registry it could not read fails (exit 1) unless `--offline` says so; spawned in the unit run with `--from` and `--offline` only | +| `verify-session-band.ts` | `scripts/session-band.ts` | the four band facts (ascending never in band, descending always, 63 of 80 inside one millisecond, every derived id), and the verdict the script prints (`bandVerdict`): a derivation that leaves the band yields its `MISMATCH:` line and exit 1, never "CONFIRMED"; the script is spawned in its passing state and prints that verdict | +| `check-vendor-version.ts` | `scripts/vendor-version.ts` | the constant is found, "read nothing" is distinguishable from "passed", and the scan reads every file a copy can live in but never the generated tables; the registry is read as dist-tags (`publishedCli`: `latest`, and the `0.0.0-<tag>-<stamp>` snapshot builds, listed as not claimed and never classified, since most are 1.x feature branches), a `latest` that left the pinned major is a generation change and not a bump (`pinVerdict`), and a literal in the 2.x User-Agent form (`opencode/<channel>/<version>/<client>`) is refused as a claim no constant pins; the registry half runs against a loopback registry in the mock suite (`test/mock/cli-version.mock.ts`) | +| `check.ts` | `scripts/package-checks.ts` | `libFreshness`, the one rule for a missing, half-built or stale `lib/`; `clientBundleProblems`, the client half the consumer install loads, run on `lib/client.js` and on bundles that register a wrong id or answer another `NS`; the pure manifest, toolchain and credential rules the unit tests now hold | +| `propose-host-bump.ts` | `scripts/host-bump.ts` | the plan per channel (`current`, `blocked` on any unread answer, `waiting` inside pnpm's one-day release-age window, `range` for a new prerelease tuple or a moved pi range, else `data`), the edits that move release step 3's data (the record, one line per field; the README pair's sentences, a value both channels share left for review unless both plans move it to the same value, two plans moving it apart included, a value the other channel is moving TO left the same way in either order, and any dist-tag wording) and the title, the body and its target marker (`bumpTarget`, every proposed record in full, which the push step compares instead of the title); pi as one value in both lockfiles (`coupleSharedPi`: a channel waiting on its own cut takes the pi move when its recorded pi range admits it, one that cannot holds it, a dsh-only proposal and two movers at different pis included); a dist-tag that moves only with `dsh`, so the plan is `current` exactly when the drift report has no line for the channel; a pi patch on both channels, a moved pi range and a cordis cut outside its peer; the CLI against a loopback registry in the mock suite, its `--out` files, the pi coupling, and on a scratch copy (`--root`) a successful `--write`'s `files` list and a failed one's put-back; CI's two proposal steps executed with stub `node`, `pnpm`, `git` and `gh` (`test/host-drift.test.ts`) | +| `report-host-drift.ts` | `scripts/host-drift.ts` | what each channel installs today, read the way the record's fields are defined (`dsh` as the newest build any dist-tag carries on the record's line, so `next` and `latest` both count for rc, or a newer stable release, the line graduating, under the tag the record names for that (`graduatesUnder`: `latest` for rc, none for alpha); pi as the bridge's range, that range's floor and the newest version it admits, a prerelease only on its own tuple; each of the seven cordis-family members, cordis, the loader, include, group, timer, cosmokit and schemastery, at the floor of the `~` range its declarer (`FAMILY_DECLARERS`) declares in the dist-tagged cluster, so a newer patch inside a range is not drift), one line per field that moved, and "the registry answered nothing" never read as current; known answers: today's registry prints nothing, the next cut prints one line per moved field, and a record whose timer is not the declared floor prints that one line; the CLI runs against a loopback registry in the mock suite (`test/mock/host-drift-cli.mock.ts`) and the CI job's condition is evaluated (nightly and dispatch only); `readChannel` is the one reading the report and the bump proposal share, held to print exactly what `hostDrift` prints | | `use-channel.ts` | `scripts/channel-manifest.ts` | which packages a channel repins (every `@deepseek-ai` pin moved, pi's range untouched, a harness package with no rule refused rather than guessed), the identity on the committed channel, the one-line floor override, and the host-free lockfile a `--relock` starts from (every host key gone from the importer, `packages:` and `snapshots:`, everything else kept) | -The pattern is the same one the `utils/` gate enforces inside `src/`, applied to the one place it had not reached: **a decision that lives in a top-level-await script is exercised only by the vendor's schedule** (or, for the channel switch, by the next harness bump). The first two had already passed while wrong — the version gate twice, the Go-limits reader when the docs moved to HTML. +The pattern is the same one the `utils/` gate enforces inside `src/`, applied to the one place it had not reached: **a decision that lives in a top-level-await script is exercised only by the vendor's schedule** (or, for the channel switch, by the next harness bump). Several had already passed while wrong — the version gate twice, the Go-limits reader when the docs moved to HTML, the catalog generator while a moved endpoint read as current, and two `verify-*` scripts that could not fail at all. Every `verify-*` script now exits non-zero on a mismatch, and the unit run proves it: `verify-catalog` is spawned and made to fail; `verify-session-band`'s verdict is its pure half's, made to fail on a planted derivation, with the script spawned in its passing state (it has no seam for a derivation of its own). ## Two habits @@ -110,10 +115,10 @@ Two rows below are parts of the suite listed for the drift they also hold: the m | :-- | :-- | :-- | | catalog freshness | the generated shim drifting from the vendor | a wrong decision made from correct data | | limits freshness | the generated allowance table drifting | the same | -| `cli:version` | the claimed User-Agent version drifting | a wrong header value at the right version | -| bundle gate | the client bundle crossing its ceiling, or a dependency that should have stayed external | a bundle that is small and wrong | +| `cli:version` | the claimed User-Agent version drifting, read off the registry's dist-tags; a 2.x-form User-Agent literal no constant pins | a wrong header value at the right version | +| bundle gate | a stale `lib/`, a tarball a consumer cannot install or load (either half), the client bundle crossing its ceiling, or a dependency that should have stayed external | a bundle that is small and wrong | | coverage ratchet | a large uncovered surface | an assertion that cannot fail | | mock suite | a wrong wire shape | a wrong decision | -| `host-versions` · `harness-channel` | an install, or a lockfile, that is not the channel record's | a record that is itself behind the registry (the nightly `host-drift` job's) | +| `host-versions` · `harness-channel` | an install, or a lockfile, that is not the channel record's | a record that is itself behind the registry (the nightly `host-drift` job's, which reports it and proposes the bump as a `chore/host-bump` pull request) | The pattern: gates catch **drift**, tests catch **logic**. A change that only adds a gate has not covered the behaviour, and a change that only adds a test has not covered the data. diff --git a/docs/upstream-findings.md b/docs/upstream-findings.md index 057c3b97..a5f545a2 100644 --- a/docs/upstream-findings.md +++ b/docs/upstream-findings.md @@ -421,13 +421,14 @@ The row's `providers` list REPLACES the code default, so the two drifting apart Most of this document exists because four separate bugs had the same invisible shape: a model that is dropped is not filtered, not deprecated and not erroring — it is simply absent. Each was found with a hand-written script that was then thrown away. That script is now kept: ```sh -pnpm run catalog:verify # offline: shim, declared list, routing, free-tier tools -pnpm run catalog:verify:probe # adds a live probe of every offered model +pnpm run catalog:verify # shim against the registry, declared list, routing, free-tier tools +pnpm run catalog:verify -- --offline # the same without the registry: the shim check is skipped, out loud +pnpm run catalog:verify:probe # adds a live probe of every offered model ``` It checks, and exits non-zero on any failure: -1. **Shim freshness** — the bundled `catalog-data.ts` equals the live registry exactly (both come from the same parser over the same payload). What the registry should have flagged but did not is the probe's business, not this check's. +1. **Shim freshness** — the bundled `catalog-data.ts` equals the live registry exactly (both come from the same parser over the same payload), by the generator's own rule over every export, the plane and family endpoints included. A registry that could not be read fails the check: it is not the same as current. What the registry should have flagged but did not is the probe's business, not this check's. 2. **Dead rows** (`--probe`) — no offered model answers `400 Model is unavailable` or `410 ModelDeprecated`. 3. **The declared list** — the composer's source (`planeProfileFor`) publishes exactly the ids a plane declares: nothing added and nothing missing. Settings' fetch offers the catalog; the composer never widens. 4. **Routing** — every servable model resolves to a route this plugin mounts. diff --git a/scripts/capture-ui.sh b/scripts/capture-ui.sh index 8477202f..5ff7c77e 100755 --- a/scripts/capture-ui.sh +++ b/scripts/capture-ui.sh @@ -8,6 +8,8 @@ # scripts/capture-ui.sh 'http://127.0.0.1:3080/?token=...' # # Requires `agent-browser` (npm i -g agent-browser && agent-browser install). +# `agent-browser` and the Node it spawns must be on PATH, for the machine's own +# architecture; this script sets no PATH of its own. set -euo pipefail URL="${1:-}" @@ -19,10 +21,6 @@ fi OUT="$(cd "$(dirname "$0")/.." && pwd)/docs" mkdir -p "$OUT" -# The managed Node is x64 while this machine is arm64; agent-browser spawns Node -# internally, so the arm64 one from Homebrew has to win on PATH. -export PATH="/opt/homebrew/bin:$PATH" - echo "opening DSH…" agent-browser open "$URL" agent-browser wait --load load || true diff --git a/scripts/catalog-shim.ts b/scripts/catalog-shim.ts new file mode 100644 index 00000000..d473671d --- /dev/null +++ b/scripts/catalog-shim.ts @@ -0,0 +1,187 @@ +/** + * The pure half of `regenerate-catalog-shim.ts`: what a parsed models.dev + * payload makes of `src/catalog-data.ts`, rendered as the file writes it, and + * whether the shipped shim is stale against it. + * + * Every export the generator writes counts: the two catalogs, the plane + * endpoints (`OPENCODE_PLANE_API`) and the family endpoints + * (`OPENCODE_FAMILY_API`). The generator used to compare only the catalogs, + * so a vendor that moved an endpoint left the shim reporting "current" while + * the mount read the old address. `verify-catalog.ts` asks the same question + * through `shimStale`, so the two can never disagree about it. + * + * @module scripts/catalog-shim + */ +import type { CatalogModelSpec } from "../src/catalog-data.ts"; +import { stale } from "./generator-kit.ts"; + +/** One plane's endpoint per SDK package (`""` is the provider's default). */ +type FamilyApi = Readonly<Record<string, string>>; + +/** The four values the shim carries, in the shape `shimStale` compares. */ +export interface ShimData { + readonly familyApi: Readonly<Record<"go" | "zen", FamilyApi>>; + readonly go: readonly CatalogModelSpec[]; + readonly planeApi: Readonly<Record<"go" | "zen", string>>; + readonly zen: readonly CatalogModelSpec[]; +} + +/** What `parseModelsDevCatalog` answers, read-only. */ +export interface ParsedShim { + readonly api: Readonly<Record<"go" | "zen", string>>; + readonly familyApi: Readonly<Record<"go" | "zen", FamilyApi>>; + readonly go: readonly CatalogModelSpec[]; + readonly zen: readonly CatalogModelSpec[]; +} + +/** The file the shim lives in, for the messages. */ +const TARGET = "catalog-data.ts"; + +/** Rows sorted deterministically by id, which is how the shim lists them. */ +const byId = (specs: readonly CatalogModelSpec[]): CatalogModelSpec[] => + specs.toSorted((a, b) => a.id.localeCompare(b.id)); + +/** + * What a parsed payload makes of the shim. + * + * @param parsed - `parseModelsDevCatalog`'s answer. + * @returns the four values, each catalog sorted by id. + */ +export const shimData = (parsed: ParsedShim): ShimData => ({ + familyApi: parsed.familyApi, + go: byId(parsed.go), + planeApi: parsed.api, + zen: byId(parsed.zen), +}); + +/** + * Whether the shipped shim is stale against `next`: the one rule + * (`generator-kit.ts`'s `stale`) over all four values. Row order is the + * renderer's, so both sides are sorted by id first. + * + * @param shipped - what `src/catalog-data.ts` exports. + * @param next - what the source makes of it today (`shimData`). + * @returns whether any of the four differs in data. + */ +export const shimStale = (shipped: ShimData, next: ShimData): boolean => + stale( + { ...shipped, go: byId(shipped.go), zen: byId(shipped.zen) }, + { ...next, go: byId(next.go), zen: byId(next.zen) } + ); + +/** Thousands separators, matching how the file already reads. */ +const numeral = (value: number): string => + String(value).replaceAll(/\B(?=(\d{3})+(?!\d))/gu, "_"); + +/** + * One catalog entry, keys sorted so a regeneration produces a minimal diff. + * + * Sorting is the whole reason this is a generator and not a hand-edit: the file + * is formatted by `vp fmt`, which does not sort object keys, so a hand-added key + * lands wherever it was typed and the next run moves it. + */ +export const literal = (spec: CatalogModelSpec): string => { + const lines = Object.keys(spec) + .toSorted() + .map((key) => { + const value: unknown = (spec as unknown as Record<string, unknown>)[key]; + if (key === "cost" && value !== undefined && value !== null) { + const rate = value as Record<string, number>; + const inner = Object.keys(rate) + .toSorted() + .map((name) => ` ${name}: ${rate[name]}`) + .join(",\n"); + return `cost: {\n${inner},\n },`; + } + if (Array.isArray(value)) { + return `${key}: [${value.map((v) => JSON.stringify(v)).join(", ")}],`; + } + if (typeof value === "number") { + return `${key}: ${numeral(value)},`; + } + return `${key}: ${JSON.stringify(value)},`; + }); + return ` {\n${lines.map((line) => ` ${line}`).join("\n")}\n }`; +}; + +/** The array literal for one provider, ready to splice into the file. */ +export const render = (specs: readonly CatalogModelSpec[]): string => + `[\n${specs.map((spec) => `${literal(spec)},`).join("\n")}\n]`; + +/** + * Replace one exported object literal, by its `export const NAME` anchor. + * + * `splice` is array-shaped: it looks for `[` and ends at `];`. The plane export + * is an object, so it needs its own opener and terminator - and using the array + * one on it would have thrown rather than half-written the file. + */ +export const spliceObject = ( + source: string, + name: string, + body: string, + target = TARGET +): string => { + const anchorName = `export const ${name}: `; + const start = source.indexOf(anchorName); + if (start === -1) { + throw new Error(`could not find ${name} in ${target}`); + } + const open = source.indexOf("{", start + anchorName.length); + const close = source.indexOf("\n};", open); + if (open === -1 || close === -1) { + throw new Error(`${name} is not an object literal`); + } + // `close` is the newline before `};`, so the literal ends two characters on. + return `${source.slice(0, open)}${body}${source.slice(close + 2)}`; +}; + +/** Replace one exported array literal, by its `export const NAME` anchor. */ +export const splice = ( + source: string, + name: string, + body: string, + target = TARGET +): string => { + const anchor = `export const ${name}: readonly CatalogModelSpec[] = `; + const start = source.indexOf(anchor); + if (start === -1) { + throw new Error(`could not find ${name} in ${target}`); + } + const open = source.indexOf("[", start + anchor.length); + const close = source.indexOf("\n];", open); + if (open === -1 || close === -1) { + throw new Error(`${name} is not an array literal`); + } + // `close` is the newline before `];`, so the literal ends two characters on. + return `${source.slice(0, open)}${body}${source.slice(close + 2)}`; +}; + +/** + * The plane-level export: the endpoint each plane's provider declares. + * + * The client consumes exactly this field (`models-dev.ts:107` lifts `item.api` + * into `settings.baseURL`), so the shim carries the same value and the mount + * reads it instead of hard-coding a family. + */ +export const renderPlaneApi = ( + planeApi: Readonly<Record<"go" | "zen", string>> +): string => + `{ + go: ${JSON.stringify(planeApi.go)}, + zen: ${JSON.stringify(planeApi.zen)}, +}`; + +/** + * The endpoint each wire family declares, per plane, keyed by its SDK package. + * + * The provider-level `api` is only the default family's address: anthropic and + * google are served at their own paths and the catalog records that per model. + * Emitting it keeps the mount reading DATA, so a vendor move is a regeneration. + */ +export const renderFamilyApi = ( + familyApi: Readonly<Record<"go" | "zen", FamilyApi>> +): string => + `{ + go: ${JSON.stringify(familyApi.go)}, + zen: ${JSON.stringify(familyApi.zen)}, +}`; diff --git a/scripts/check-vendor-version.ts b/scripts/check-vendor-version.ts index 86fbc4e5..1f17c030 100644 --- a/scripts/check-vendor-version.ts +++ b/scripts/check-vendor-version.ts @@ -9,9 +9,17 @@ * Two things are checked, and they fail for different reasons: * * 1. the constant, read from `src/identity.ts`, against the published CLI - - * `latest`, which is the 1.x line this plugin claims; + * `latest` among the registry's dist-tags, which is the 1.x line this + * plugin claims. A `latest` that left the pinned major is named as a + * generation change, not a bump: the 2.x line sends another User-Agent + * format (`opencode/<channel>/<version>/<client>`). The snapshot builds + * under dist-tags (`0.0.0-<tag>-<stamp>`: the 2.x line's `dev`, `next` + * and `beta`, and many older 1.x feature branches) are listed as not + * claimed, with no format asserted for any of them; * 2. every other copy in the tree, against the constant - a copy that drifts - * from the constant is stale even when the constant is current. + * from the constant is stale even when the constant is current - and no + * literal in the 2.x form at all, since no constant pins that generation + * (a concrete one is a claim nothing checks; write the placeholder). * * The copy scan reads two forms, because a copy is not always prefixed: the * `opencode/<version>` literal, and a bare (or `v`-prefixed) literal of the @@ -33,16 +41,23 @@ * * A failure prints one `error: <cause>` line first (the detail after it), so a * CI step that only warns can quote the cause rather than guess it. + * + * `--registry <url>` reads another registry (default the public one), which + * is how the mock suite serves it a known answer. */ import { readdir, readFile } from "node:fs/promises"; import { bareLiteral, + CLI_GENERATION_LITERAL, CLI_LITERAL as LITERAL, disagreements, pinnedVersion, + pinVerdict, + publishedCli, scansFile, + unpinnedGenerationClaims, } from "./vendor-version.ts"; /** Where a copy may appear; `scansFile` decides which files under them are read. */ @@ -50,12 +65,23 @@ const ROOTS = ["src", "test", "docs", "scripts", "README.md"]; const CONSTANT_FILE = "src/identity.ts"; -/** Every literal version found, and the files that say it. */ -const literals = async (pattern: RegExp): Promise<Map<string, string[]>> => { +const flag = process.argv.indexOf("--registry"); +const REGISTRY = ( + flag === -1 ? "https://registry.npmjs.org" : (process.argv[flag + 1] ?? "") +).replace(/\/+$/u, ""); + +/** + * Every literal found, keyed by `key` (the version, by default), and the + * files that say it. + */ +const literals = async ( + pattern: RegExp, + key: (match: RegExpMatchArray) => string = (match) => match[1] ?? "" +): Promise<Map<string, string[]>> => { const found = new Map<string, string[]>(); const record = (path: string, text: string): void => { for (const match of text.matchAll(pattern)) { - const version = match[1] ?? ""; + const version = key(match); found.set(version, [...(found.get(version) ?? []), path]); } }; @@ -97,6 +123,14 @@ const main = async (): Promise<void> => { `these copies do not say ${pinned}, which is what ${CONSTANT_FILE} declares:\n${detail}` ); } + const claims = unpinnedGenerationClaims( + await literals(CLI_GENERATION_LITERAL, (match) => match[0]) + ); + if (claims.length > 0) { + throw new Error( + `these User-Agent literals use the 2.x form, which no constant pins:\n${claims.map((claim) => ` ${claim}`).join("\n")}` + ); + } if (process.argv.includes("--copies")) { process.stdout.write( `ok ${[...copies.values()].flat().length} copy/copies and ${[...mentions.values()].flat().length} bare mention/mentions say ${pinned}, as ${CONSTANT_FILE} does (registry not asked)\n` @@ -104,25 +138,30 @@ const main = async (): Promise<void> => { return; } - const response = await fetch("https://registry.npmjs.org/opencode-ai/latest"); + const response = await fetch(`${REGISTRY}/-/package/opencode-ai/dist-tags`); if (!response.ok) { throw new Error(`the registry answered ${response.status} for opencode-ai`); } - const published = ((await response.json()) as { version?: string }).version; + const published = publishedCli(await response.json()); if (published === undefined) { - throw new Error("the registry response carried no version"); - } - if (pinned !== published) { - const files = [ - ...new Set([...copies.values(), ...mentions.values()].flat()), - ]; throw new Error( - `pinned opencode/${pinned}, published opencode/${published} - bump OPENCODE_CLI_VERSION, and the ${files.length > 0 ? files.join(", ") : "docs"}` + "the registry's dist-tags carried no x.y.z `latest` for opencode-ai" ); } + const files = [...new Set([...copies.values(), ...mentions.values()].flat())]; + const verdict = pinVerdict(pinned, published, files); + if (verdict !== undefined) { + throw new Error(verdict); + } process.stdout.write( `ok opencode/${pinned} is the published version, and ${[...copies.values()].flat().length} copy/copies and ${[...mentions.values()].flat().length} bare mention/mentions agree\n` ); + const snapshots = Object.entries(published.snapshots); + process.stdout.write( + snapshots.length === 0 + ? "note no snapshot build is published under a dist-tag\n" + : `note snapshot builds under dist-tags, their generation and User-Agent format not read from the tag (not claimed): ${snapshots.map(([tag, version]) => `${tag} ${version}`).join(", ")}\n` + ); }; try { diff --git a/scripts/check.ts b/scripts/check.ts index 25d8139b..f0d6ec4c 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -1,15 +1,25 @@ /** - * Package checks — one script, run once, in CI and before a release. + * The bundle gate: what needs a build or a registry, run once, in CI and + * before a release. * - * Verifies that the packed artifact installs cleanly for a consumer, that the - * host plugin and client bundle initialize, that peer ranges are resolvable by - * plain npm, that release workflows are wired correctly, and that no credentials - * or stale builds ship. + * Three checks, each one only this script can make: + * + * 1. `lib/` carries the current build (`libFreshness`): no artifact missing + * or older than `src/`, which is how a failed build passes for a good one. + * 2. The packed tarball installs for a consumer with plain npm, and both + * halves load from that install: the host entry applies, and the client + * bundle resolves through `exports["./client"]`, registers its ids with + * the web client's module loader and answers its factory. + * 3. The client bundle stays under its size tripwire. + * + * Every pure file check (the workflows, the manifest, the toolchain, the + * credential scan, locale parity) is a unit test: `test/ci-wiring.test.ts`, + * `test/package-checks.test.ts`, `test/toolchain.test.ts` and + * `test/no-local-paths.test.ts`. * * node --experimental-strip-types scripts/check.ts */ -import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync, @@ -21,15 +31,9 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; -import { en as cardCopy } from "../src/settings-copy.ts"; -import { - exportsOutsideFiles, - publishListMismatch, - releasePendingTargets, -} from "./package-checks.ts"; -import { SCOPED_TARGETS } from "./scoped-targets.ts"; +import { libFreshness } from "./package-checks.ts"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const failures: string[] = []; @@ -46,221 +50,49 @@ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); /* ------------------------------------------------------- 1. lib/ is current */ -function newestMtime(dir: string): number { +/** + * `vp pack` CLEANS `lib/` and emits `lib/client.cjs`; the served `lib/client.js` + * comes from `scripts/name-client-bundle.ts`. A build that failed, or one whose + * rename step was skipped, therefore leaves a `lib/` missing a file the host + * loads, or older than the source it should have been built from — + * indistinguishable from success to everything downstream. + */ +const LIB = join(ROOT, "lib"); + +const newestSource = (dir: string): number => { let newest = 0; - for (const name of readdirSync(dir, { recursive: true })) { - const full = join(dir, String(name)); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); try { - if (statSync(full).isFile()) - newest = Math.max(newest, statSync(full).mtimeMs); + newest = Math.max( + newest, + entry.isDirectory() ? newestSource(full) : statSync(full).mtimeMs + ); } catch { - // Racing a build is not a failure; skip it. + // Racing an editor's save is not a failure; skip the file. } } return newest; -} - -const lib = join(ROOT, "lib"); -if (!existsSync(lib)) { - fail("lib/ is missing — run `pnpm run build`"); -} else if (newestMtime(join(ROOT, "src")) > newestMtime(lib)) { - fail( - "src/ is newer than lib/ — run `pnpm run build`, or DSH will load the old one" - ); -} else { - ok("lib/ is newer than src/"); -} - -/* ------------------------------------------- 2. peers npm must be able to read */ - -for (const [name, range] of Object.entries( - (pkg.peerDependencies ?? {}) as Record<string, unknown> -)) { - if (typeof range !== "string" || range.trim() === "") { - fail(`peer ${name} has no usable range`); - continue; - } - if (/^(workspace|link|file|catalog):/.test(range)) { - fail( - `peer ${name} uses "${range.split(":")[0]}:" — npm cannot parse it, so install fails` - ); - continue; - } - if (!/^[\^~><=|*]/.test(range.trim())) { - fail( - `peer ${name} is pinned to "${range}" — a DSH patch release would orphan the plugin` - ); - } -} -if (!failures.length) { - ok( - `${Object.keys(pkg.peerDependencies ?? {}).length} peer ranges npm can resolve` - ); -} - -/* ------------------------------------------- 3. it is a bundle, and it loads */ - -const patch = pkg.dsh?.bundle?.patch; -if (!patch) { - fail("no dsh.bundle.patch — DSH would install this and then ignore it"); -} else if (existsSync(join(ROOT, patch))) { - ok(`dsh.bundle.patch -> ${patch}`); -} else { - fail(`dsh.bundle.patch points at "${patch}", which does not exist`); -} -for (const field of ["main", "types"]) { - if (!pkg[field]) fail(`no ${field} declared`); -} - -/* --------------------------------------- 4. the harness surfaces are still there */ - -const SURFACES = ["@deepseek-ai/dsh-typert-protocol"]; - -const REQUIRED: Record<string, [RegExp, string][] | undefined> = { - "@deepseek-ai/dsh-typert-protocol": [ - [ - /(?:export declare class RemoteError|export \{[^}]*\bRemoteError\b)/, - "RemoteError", - ], - [ - /export declare (?:abstract )?class TypertRemoteService/, - "TypertRemoteService", - ], - [/interface TypertRemoteContribution\b/, "TypertRemoteContribution"], - ], }; -for (const name of SURFACES) { - let dir; - try { - dir = dirname( - execFileSync( - process.execPath, - [ - "-e", - `process.stdout.write(require.resolve(${JSON.stringify(`${name}/package.json`)}))`, - ], - { cwd: ROOT, encoding: "utf8" } - ) - ); - } catch { - notes.push( - `skip ${name} is not installed, so its surfaces were not checked` - ); - continue; - } - - const typesDir = join(dir, "lib/types"); - const files = existsSync(typesDir) - ? readdirSync(typesDir) - .filter((f) => f.endsWith(".d.ts")) - .map((f) => join(typesDir, f)) - : [join(dir, "lib/index.d.ts")].filter(existsSync); - - if (files.length === 0) { - fail(`${name} ships no readable type declarations — the contract moved`); - continue; - } - - const dts = files.map((f) => readFileSync(f, "utf8")).join("\n"); - const required = REQUIRED[name]; - assert.ok(required, `no surface contract for ${name}`); - for (const [pattern, what] of required) { - if (!pattern.test(dts)) fail(`${name} no longer exposes ${what}`); - } -} -if (!failures.some((f) => f.includes("no longer exposes"))) { - ok("every harness surface this package depends on is present"); -} - -/* --------------------------------- 5. the lint and format configs are actually loaded */ - -try { - const printed = execFileSync( - "pnpm", - ["exec", "vp", "lint", "--print-config"], - { - cwd: ROOT, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - } - ); - const effective = JSON.parse(printed.slice(printed.indexOf("{"))); - const ruleCount = Object.keys(effective.rules ?? {}).length; - - if (ruleCount < 300) { - fail( - `the effective lint config has ${ruleCount} rules — the \`lint\` block in vite.config.ts is not being loaded` - ); - } else if (effective.options?.typeAware !== true) { - fail("typeAware is off, so every typescript/* gate is listed but inert"); - } else if (effective.rules["typescript/no-floating-promises"] === undefined) { - fail("the effective config is missing this package's own gates"); - } else { - ok(`lint config is live (${ruleCount} rules, typeAware on)`); - } -} catch (error) { - const detail = - error instanceof Error ? error.message.split("\n")[0] : String(error); - fail(`could not read the effective lint config: ${detail}`); -} - -/* --------------------------------------------------- 6. no credentials in the tree */ - -const SECRETS: [RegExp, string][] = [ - [/\bmonid_live_[A-Za-z0-9]{10,}/, "a Monid platform key"], - [/\bsk-tinyfish-[A-Za-z0-9_-]{10,}/, "a TinyFish key"], - [/\bgh[pousr]_[A-Za-z0-9]{20,}/, "a GitHub token"], - [/sk-ant-[A-Za-z0-9_-]{10,}/, "an Anthropic key"], -]; - -const parts = { m: "monid_live_", t: "sk-tinyfish-", g: "ghp_", a: "sk-ant-" }; -const CANARIES = [ - [`${parts.m}AbCdEf1234567890`, true], - [`${parts.t}Pm-QkiaUloPEyNN`, true], - [`${parts.g}abcdefghijklmnopqrstuvwxyz0123456789`, true], - [`${parts.a}api03-AbCdEf1234567890`, true], - [parts.m, false], - [parts.t, false], - ["$OPENCODE_GO_API_KEY", false], - ["Authorization: Bearer <key>", false], -]; -for (const [sample, shouldMatch] of CANARIES as [string, boolean][]) { - if (SECRETS.some(([re]) => re.test(sample)) !== shouldMatch) { - fail( - `the secret scanner is ${shouldMatch ? "missing" : "over-matching"} on a sample` - ); - } +const mtimeOf = (file: string): number | undefined => + existsSync(file) ? statSync(file).mtimeMs : undefined; + +const stale = libFreshness({ + artifacts: { + "client.js": mtimeOf(join(LIB, "client.js")), + "index.mjs": mtimeOf(join(LIB, "index.mjs")), + }, + hasClientCjs: existsSync(join(LIB, "client.cjs")), + newestSource: newestSource(join(ROOT, "src")), +}); +if (stale === undefined) { + ok("lib/ carries the current build, including the served client bundle"); +} else { + fail(stale); } -const SKIP = new Set(["node_modules", ".git", "lib"]); -let scanned = 0; -const walk = (dir: string): void => { - for (const name of readdirSync(dir)) { - if ( - SKIP.has(name) || - name.startsWith(".build-check") || - name.endsWith(".tgz") - ) - continue; - const full = join(dir, name); - if (statSync(full).isDirectory()) { - walk(full); - continue; - } - if (!/\.(mjs|js|ts|json|yml|yaml|md)$/.test(name)) continue; - scanned += 1; - const text = readFileSync(full, "utf8"); - for (const [re, what] of SECRETS) { - if (re.test(text)) - fail(`${full.replace(`${ROOT}/`, "")} contains ${what}`); - } - } -}; -walk(ROOT); -ok(`scanned ${scanned} files for credentials`); - -/* ------------------------------ 7. install it the way a consumer will, and load it */ +/* ---------------------- 2. install it the way a consumer will, and load both halves */ const scratch = mkdtempSync(join(tmpdir(), "dsh-opencode-")); @@ -347,8 +179,50 @@ try { stdio: ["ignore", "pipe", "pipe"], }); + // The client half, from the same install: the web client loader serves + // `${package}/client.js`, hands the bundle a `window` whose module loader + // registers it, and calls the factory with the host's `require`. The probe + // does the same with a recorder for each, and a `require` whose answer is a + // stand-in for any host module (callable, constructible, every property + // another stand-in), so the bundle's module-level code runs as it would. + // The rule is `clientBundleProblems` (scripts/package-checks.ts), which + // test/package-checks.test.ts runs on lib/client.js and on planted + // bundles; here it runs on the bundle the tarball installed, resolved + // through the package's own `exports`. + const clientProbe = ` + import { createRequire } from "node:module"; + import { readFileSync } from "node:fs"; + import { runInNewContext } from "node:vm"; + const { clientBundleProblems } = await import(${JSON.stringify(pathToFileURL(join(ROOT, "scripts", "package-checks.ts")).href)}); + const resolved = createRequire(process.cwd() + "/").resolve(${JSON.stringify(`${pkg.name}/client`)}); + if (!resolved.endsWith("/lib/client.js")) throw new Error("the client export resolves to " + resolved); + const { asked, problems } = clientBundleProblems( + readFileSync(resolved, "utf8"), + { ids: ${JSON.stringify([pkg.name, "@viztor/dsh-opencode-patch"])}, ns: ${JSON.stringify(pkg.name)} }, + runInNewContext + ); + if (problems.length > 0) throw new Error(problems.join("; ")); + process.stdout.write(asked.length + " host module(s) asked for"); + `; + const asked = execFileSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + "--input-type=module", + "-e", + clientProbe, + ], + { + cwd: project, + encoding: "utf8", + env: CHILD_ENV, + stdio: ["ignore", "pipe", "pipe"], + } + ); + ok( - `installed the packed tarball with plain npm (${packed.entryCount} entries) and loaded the host + client halves` + `installed the packed tarball with plain npm (${packed.entryCount} entries) and loaded the host + client halves (the client: ${asked})` ); } catch (error: unknown) { const err = error as { stderr?: string; message?: string }; @@ -364,144 +238,7 @@ try { rmSync(scratch, { recursive: true, force: true }); } -/* ------------------------------------------------- 8. the release can fire */ - -const workflows = join(ROOT, ".github/workflows"); -const release = readFileSync(join(workflows, "release.yml"), "utf8"); -const ci = readFileSync(join(workflows, "ci.yml"), "utf8"); - -if (/^\s*tags:\s*\[?\s*"?v\*\.\*\.\*"?/m.test(release)) { - ok("release.yml is triggered by a version tag"); -} else { - fail("release.yml does not trigger on v*.*.* tags — nothing would publish"); -} - -if (/^\s*tags-ignore:\s*\[?\s*"?v\*\.\*\.\*"?/m.test(ci)) { - ok("ci.yml ignores version tags, leaving them to release.yml"); -} else { - fail( - "ci.yml does not ignore version tags; a tag push matches no branch, so " + - "ci.yml never runs on one and anything gated on it is unreachable" - ); -} - -/* The nightly refresh must arrive with CI on it. A branch pushed with the - checkout credential is attributed to github-actions[bot], and GitHub parks - the pull_request run in action_required - the first refresh arrived that - way, with no checks until a human approved the run. The push therefore has - to carry the PAT, which makes the actor the PAT's owner. */ -if ( - ci.includes( - `x-access-token:\${GH_TOKEN}@github.com/\${GITHUB_REPOSITORY}.git` - ) -) { - ok("the refresh push carries the PAT, so the refresh PR gets CI"); -} else { - fail( - "the nightly refresh pushes through the checkout credential; the " + - "pull_request run then lands in action_required and the refresh gets " + - "no CI until someone approves the run by hand" - ); -} - -if (/^\s*id-token:\s*write/m.test(release)) { - ok("release.yml grants id-token: write for OIDC trusted publishing"); -} else { - fail( - "release.yml has no id-token: write, so npm trusted publishing cannot " + - "authenticate and the publish would need a stored token" - ); -} - -if (/npm view .* version 2>\/dev\/null/.test(release)) { - ok("release.yml skips a version that is already on the registry"); -} else { - fail( - "release.yml does not skip an already-published version, so re-running a " + - "tag fails on npm's refusal to republish" - ); -} - -/* --------------------------------------------------- 9. the toolchain is Vite+ */ - -const TOOLCHAIN = ["build", "check", "format", "lint", "test"]; -const UNBYPASSED = /(?:^|[\s(])(oxlint|oxfmt|tsdown|vitest|tsc)(?:[\s)]|$)/; - -if (pkg.devDependencies?.["vite-plus"]) { - ok(`the toolchain is vite-plus (${pkg.devDependencies["vite-plus"]})`); -} else { - fail("the package does not depend on vite-plus; the toolchain is unpinned"); -} - -for (const name of TOOLCHAIN) { - const script = pkg.scripts?.[name]; - if (!script) { - fail(`no ${name} script`); - } else if (!/\bvp\b/.test(script)) { - fail(`the ${name} script does not go through vp: ${script}`); - } else if (UNBYPASSED.test(script.replaceAll(/\bvp\b[^\s]*/g, ""))) { - const bare = script.match(new RegExp(UNBYPASSED, "g")) ?? []; - const real = bare.filter((tool: string) => tool.trim() !== "tsc"); - if (real.length > 0) { - fail( - `the ${name} script reaches past vp for ${real.map((t: string) => t.trim()).join(", ")}; ` + - "that bypasses the entry point that reads the config" - ); - } - } -} -ok(`${TOOLCHAIN.length} toolchain scripts route through vp`); - -/* -------------------------------------------- 10. the scoped alias ships too */ - -const releaseYml = readFileSync(join(workflows, "release.yml"), "utf8"); -if (releaseYml.includes("scripts/publish-scoped.ts")) { - ok("release.yml publishes scoped aliases from the same tree"); -} else { - fail( - "release.yml does not publish the scoped alias; @viztor/dsh-opencode-patch " + - "would go stale while dsh-opencode-patch moves on" - ); -} - -/* ----------------- 10b. what exports names ships, and what ships is checked */ - -// An `exports` target outside `files` resolves in this repository and nowhere -// else: `./src/*` was exported for releases while `src/` was never packed. -const outside = exportsOutsideFiles(pkg); -if (outside.length === 0) { - ok("every exports target is inside files"); -} else { - for (const target of outside) { - fail( - `exports names ${target}, which files does not pack; a consumer resolving ` + - `${target} from the tarball gets ENOENT` - ); - } -} - -// The verify step polls its own list. A name publish-scoped.ts publishes and -// the step never polls goes unchecked; a name it polls and nothing publishes -// fails every release. -const pending = releasePendingTargets(releaseYml); -if (pending === undefined) { - fail( - 'release.yml must carry exactly one pending="…" verify line (none, or more than one, found); the publish list cannot be checked' - ); -} else { - const mismatch = publishListMismatch([pkg.name, ...SCOPED_TARGETS], pending); - if (mismatch.length === 0) { - ok( - `release.yml verifies exactly what is published (${pending.join(", ")})` - ); - } else { - for (const message of mismatch) { - fail(message); - } - } -} - -/* ------------------------------------------ 11. the client stays lean */ +/* ------------------------------------------ 3. the client stays lean */ /* * A TRIPWIRE, not a budget. The owner: "the budget doesn't matter — it is there to @@ -628,435 +365,6 @@ if (existsSync(clientPath)) { fail("lib/client.js is missing — run `pnpm run build`"); } -/* ------------------------------------ 12. one name everywhere (identity + i18n) */ - -const settingsPage = readFileSync(join(ROOT, "src/settings-page.tsx"), "utf8"); -const nsMatch = /export const NS = "([^"]+)"/u.exec(settingsPage); -if (nsMatch?.[1] === pkg.name) { - ok(`settings namespace == package name (${pkg.name})`); -} else { - fail( - `settings NS is "${nsMatch?.[1]}" but package.json name is "${pkg.name}" — ` + - "the card would bind a namespace the host never serves" - ); -} - -const localeKeys = (value: unknown, prefix = ""): string[] => - value !== null && typeof value === "object" && !Array.isArray(value) - ? Object.entries(value as Record<string, unknown>).flatMap(([key, child]) => - localeKeys(child, prefix === "" ? key : `${prefix}.${key}`) - ) - : [prefix]; - -const [enLocale, zhLocale] = ["en", "zh"].map( - (lang) => - JSON.parse( - readFileSync(join(ROOT, `locale/${lang}.json`), "utf8") - ) as unknown -); -const enKeys = localeKeys(enLocale).toSorted(); -const zhKeys = localeKeys(zhLocale).toSorted(); -if (enKeys.join("\n") === zhKeys.join("\n")) { - ok(`${enKeys.length} locale keys in sync across en/zh`); -} else { - fail( - `locale/en.json and locale/zh.json disagree on keys (${enKeys.length} en vs ` + - `${zhKeys.length} zh) — a missing translation would render as a raw key` - ); -} - -const bundleTitle = (enLocale as { meta?: { title?: unknown } }).meta?.title; -if (bundleTitle === cardCopy.title) { - ok(`one display name everywhere ("${bundleTitle}")`); -} else { - fail( - `the plugin shows two names: locale/en.json meta.title "${String(bundleTitle)}" ` + - `!= settings-card title "${cardCopy.title}" — keep one display name` - ); -} - -/* ------------------------------- 13. the live suite stays wired and opt-in */ - -const e2eConfigPath = join(ROOT, "vitest.e2e.config.ts"); -const e2eScript: unknown = pkg.scripts?.["test:e2e"]; -if (!existsSync(e2eConfigPath)) { - fail("vitest.e2e.config.ts is missing — `pnpm run test:e2e` has no config"); -} else if ( - typeof e2eScript !== "string" || - !e2eScript.includes("vitest.e2e.config.ts") -) { - fail( - "the test:e2e script does not use vitest.e2e.config.ts, so the E2E files " + - "would either be collected by the unit run or never run at all" - ); -} else { - ok("the E2E suite has its own config and script"); -} - -// The unit run must stay offline. An E2E file matched by the unit `include` -// would start reaching the network on every `pnpm test`. -const unitInclude = - /include:\s*\[([^\]]*)\]/u.exec( - readFileSync(join(ROOT, "vite.config.ts"), "utf8") - )?.[1] ?? ""; -if (unitInclude.includes("e2e")) { - fail( - "the unit test include matches E2E files; `pnpm test` would stop being " + - "offline and deterministic" - ); -} else { - ok("the unit test include excludes the E2E files"); -} - -if (/pnpm run test:e2e/u.test(ci)) { - ok("ci.yml runs the live E2E suite"); -} else { - fail( - "ci.yml does not run `pnpm run test:e2e`; the live gateway contract would " + - "go unchecked until a user's meter broke" - ); -} - -/* --------------------------------- 14. the automation itself cannot rot quietly */ - -/** - * The generated catalog. - * - * `src/catalog-data.ts` is written from models.dev, and the one field that went - * stale before — `provider_npm` — put nine models on an endpoint that cannot - * serve them. That is a WRONG answer rather than a missing one, so no unit test - * can catch it: only regenerating and diffing can. Hence a script, and hence a - * job that runs it. - */ -const shimScript: unknown = pkg.scripts?.["catalog:shim"]; -const shimPath = join(ROOT, "scripts/regenerate-catalog-shim.ts"); -/** - * Whether a workflow RUNS a command, as opposed to merely mentioning it. - * - * Matching the whole file is not good enough: a step that fails prints the very - * command it ran, so `pnpm run catalog:shim` appears in ci.yml whether or not - * anything invokes it. So each line is reduced past its `run:` scaffolding — - * `- run: cmd`, `run: cmd`, `run: |` plus the block body — comments dropped — and - * the command has to be the first thing on a line. An `echo "… run 'pnpm run X'"` - * line no longer qualifies, which is exactly the false positive this exists to - * remove. - */ -const runs = (source: string, command: string): boolean => - source - .split("\n") - .map((line) => - line - .trim() - .replace(/^(?:- )?run:(?:\s*\|\s*)?/, "") - .trim() - ) - // A comment-only line is documentation, not an invocation. - .filter((line) => line.length > 0 && !line.startsWith("#")) - .some((line) => line.startsWith(command)); - -const shimScripted = - typeof shimScript === "string" && - shimScript.includes("scripts/regenerate-catalog-shim.ts"); -const shimRunsInCi = runs(ci, String.raw`pnpm run catalog:shim`); - -if (existsSync(shimPath) && shimScripted && shimRunsInCi) { - ok("the bundled catalog is generated, scripted, and checked on a schedule"); -} else { - // Plain unnested `if`s, not a chain: the first test has to be the POSITIVE - // one, and a chain of `else if (!…)` also hides that exactly one of several - // independent things broke. Most specific reason first. - if (!existsSync(shimPath)) { - fail( - "scripts/regenerate-catalog-shim.ts is missing; the catalog is unmaintainable" - ); - } - if (!shimScripted) { - fail( - "no catalog:shim script; regenerating the bundled catalog means knowing a " + - "path, and nothing runs it on a schedule" - ); - } - if (!shimRunsInCi) { - fail( - "no ci.yml step RUNS `pnpm run catalog:shim`; a stale bundled catalog " + - "goes unnoticed until a model is dispatched to the wrong endpoint" - ); - } -} - -/** - * The same arrangement for the Go plan's per-model dollar allowance. - * - * `src/go-limits-data.ts` is written from OpenCode's own Go documentation, and it - * is what lets the meter put a dollar amount on a percentage. A stale table is a - * WRONG dollar figure wearing the costume of a right one — the vendor says limits - * "may change", so this moves without a commit. No unit test can catch it: the - * tests read the very table they are meant to verify. So the same three things - * are asserted: the script exists, package.json points at it, and a workflow step - * actually RUNS it (matched as a command, for the reason `runs` documents). - */ -const limitsScript: unknown = pkg.scripts?.["limits:shim"]; -const limitsPath = join(ROOT, "scripts/generate-go-limits.ts"); -const limitsScripted = - typeof limitsScript === "string" && - limitsScript.includes("scripts/generate-go-limits.ts"); -const limitsRunsInCi = runs(ci, String.raw`pnpm run limits:shim`); - -if (existsSync(limitsPath) && limitsScripted && limitsRunsInCi) { - ok( - "the Go plan allowance table is generated, scripted, and checked on a schedule" - ); -} else { - if (!existsSync(limitsPath)) { - fail( - "scripts/generate-go-limits.ts is missing; the allowance table is unmaintainable" - ); - } - if (!limitsScripted) { - fail( - "no limits:shim script; regenerating the Go allowance means knowing a " + - "path, and nothing runs it on a schedule" - ); - } - if (!limitsRunsInCi) { - fail( - "no ci.yml step RUNS `pnpm run limits:shim`; a stale allowance table " + - "shows a wrong dollar figure that looks authoritative" - ); - } -} - -/** - * The coverage ratchet. - * - * A threshold set above today's number would block every PR, and one set below - * it would never fire. The value of a ratchet is that it fails when coverage - * DROPS, so what has to be asserted here is that one exists at all — and that - * it actually runs, or it is decoration. - */ -const viteConfig = readFileSync(join(ROOT, "vite.config.ts"), "utf8"); -const coverageProvider: unknown = pkg.devDependencies?.["@vitest/coverage-v8"]; -const hasCoverageScript = typeof pkg.scripts?.["test:coverage"] === "string"; -// Anchored: an unanchored /thresholds:/ also matches `_thresholds:`, so a -// rename would still read as present. -const hasCoverageThresholds = /^\s*thresholds\s*:/mu.test(viteConfig); -const coverageRunsInCi = runs(ci, String.raw`pnpm run test:coverage`); - -if ( - hasCoverageScript && - typeof coverageProvider === "string" && - hasCoverageThresholds && - coverageRunsInCi -) { - ok( - `coverage is ratcheted and runs in CI (@vitest/coverage-v8 ${coverageProvider})` - ); -} else { - if (!hasCoverageScript) { - fail( - "no test:coverage script; a coverage threshold that never runs is decoration" - ); - } - if (typeof coverageProvider !== "string") { - fail( - "@vitest/coverage-v8 is not a devDependency; `vp test --coverage` fails " + - "at startup without it" - ); - } - if (!hasCoverageThresholds) { - fail( - "vite.config.ts sets no coverage thresholds; coverage is reported but never " + - "gated, so a regression in the load-bearing modules goes unnoticed" - ); - } - if (!coverageRunsInCi) { - fail( - "no ci.yml step RUNS `pnpm run test:coverage`; the ratchet cannot fail a " + - "build from the unit run alone" - ); - } -} - -/** - * The `jobs:` block of a workflow, split into one string per job. - * - * Deliberately a scan rather than a YAML parse: the gate has no YAML dependency, - * and "a dependency to validate two files" is a worse trade than four lines of - * indentation-aware splitting. A job key is a bare `name:` at exactly two spaces, - * which is what `jobs:` children always look like — `steps:`/`with:`/`run:` are - * deeper, and top-level keys sit at column zero. - */ -const jobsIn = (source: string): string[] => { - const heading = /^jobs:\s*$/mu.exec(source); - if (heading === null) { - return []; - } - const body = source.slice(heading.index + heading[0].length); - // Everything before the next top-level key is the jobs block. - const nextTopLevel = /^\S/mu.exec(body); - const block = - nextTopLevel === null ? body : body.slice(0, nextTopLevel.index); - return block - .split(/^ {2}(?=[\w-]+:[ \t]*$)/mu) - .map((job) => job.trim()) - .filter((job) => job.length > 0); -}; - -/** - * Every job is bounded. - * - * GitHub's default is six hours. A hung test in a fast suite should fail in - * minutes, and a release whose polling loop has wedged should fail rather than - * hold a concurrency group open indefinitely. - */ -let unboundedTotal = 0; -for (const [label, source] of [ - ["ci.yml", ci], - ["release.yml", releaseYml], -] as const) { - const jobs = jobsIn(source); - if (jobs.length === 0) { - fail(`${label} has no readable jobs block; this check cannot see its jobs`); - continue; - } - const unbounded = jobs.filter((job) => !/^\s*timeout-minutes:/mu.test(job)); - if (unbounded.length > 0) { - unboundedTotal += unbounded.length; - fail( - `${label} has ${unbounded.length} of ${jobs.length} job(s) without ` + - "timeout-minutes; a hang would burn GitHub's six-hour default first" - ); - } -} -if (unboundedTotal === 0) { - ok( - `every job in ci.yml and release.yml is bounded (${jobsIn(ci).length + jobsIn(releaseYml).length} jobs)` - ); -} - -/** - * Release concurrency must queue, never cancel. - * - * Cancelling a re-run of the same tag is precisely the failure that lost - * v0.7.0: the first run holds a half-published registry and the second — the - * recovery — is killed before it can finish it. - */ -const releaseQueues = - /concurrency:/u.test(releaseYml) && - !/cancel-in-progress:\s*true/u.test(releaseYml); - -if (releaseQueues) { - ok("release runs queue per tag instead of cancelling each other"); -} else { - if (!/concurrency:/u.test(releaseYml)) { - fail( - "release.yml sets no concurrency group; two runs of one tag race on the " + - "registry" - ); - } - if (/cancel-in-progress:\s*true/u.test(releaseYml)) { - fail( - "release.yml cancels an in-progress run of the same tag; a re-run meant " + - "to recover a transient npm fault would instead be killed" - ); - } -} - -/** The tarball a release publishes is recorded, not assumed. */ -const packsTarball = /npm pack/u.test(releaseYml); -const hashesTarball = /shasum -a 256/u.test(releaseYml); - -if (packsTarball && hashesTarball) { - ok("the published tarball is packed, hashed and kept as an artifact"); -} else { - if (!packsTarball) { - fail( - "release.yml never packs the tarball it publishes, so the bytes that reach " + - "the registry cannot be compared with the bytes that were built" - ); - } - if (!hashesTarball) { - fail( - "release.yml packs without recording a digest; there is nothing to verify" - ); - } -} - -/** Dependabot, because the release path is actions and cannot be exercised. */ -const dependabotPath = join(ROOT, ".github/dependabot.yml"); -if (existsSync(dependabotPath)) { - const dependabot = readFileSync(dependabotPath, "utf8"); - const missing = ["github-actions", "npm"].filter( - (ecosystem) => !dependabot.includes(`package-ecosystem: ${ecosystem}`) - ); - if (missing.length > 0) { - fail( - `dependabot.yml does not cover ${missing.join(", ")}; the toolchain that ` + - "runs the release is the thing most likely to rot" - ); - } else { - ok("dependabot covers the release actions and the dependency tree"); - } -} else { - fail( - ".github/dependabot.yml is missing; a stale release action is how a tag " + - "stops producing a publish while the workflow stays green" - ); -} - -/* ------------------------------------------------------- build is current */ - -/** - * `vp pack` CLEANS `lib/` and emits `lib/client.cjs`; the served `lib/client.js` - * comes from `scripts/name-client-bundle.ts`. A build that failed, or one whose - * rename step was skipped, therefore leaves a `lib/` missing a file the host - * loads, or older than the source it should have been built from — - * indistinguishable from success to everything downstream, and the settings card - * and the meter load exactly the missing file. - * - * Not hypothetical: a `vp pack` whose output was discarded shipped a `lib/` with - * no `client.js`, and nothing here or in the suite noticed. - */ -const LIB = join(ROOT, "lib"); -const REQUIRED_ARTIFACTS = ["index.mjs", "client.js"]; - -const newestSource = (dir: string): number => { - let newest = 0; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - newest = Math.max( - newest, - entry.isDirectory() ? newestSource(full) : statSync(full).mtimeMs - ); - } - return newest; -}; - -const missingArtifacts = REQUIRED_ARTIFACTS.filter( - (name) => !existsSync(join(LIB, name)) -); -if (missingArtifacts.length > 0) { - const hint = existsSync(join(LIB, "client.cjs")) - ? " (lib/client.cjs exists — run scripts/name-client-bundle.ts)" - : ""; - fail( - `lib/ is missing ${missingArtifacts.join(", ")}${hint}; the host loads these ` + - "files, so a build that failed looks exactly like one that succeeded" - ); -} else { - const builtAt = Math.min( - ...REQUIRED_ARTIFACTS.map((name) => statSync(join(LIB, name)).mtimeMs) - ); - if (builtAt < newestSource(join(ROOT, "src"))) { - fail( - "lib/ is OLDER than src/ — the artifacts were not rebuilt from the current " + - "source, which ships a plugin whose behaviour does not match its code" - ); - } else { - ok("lib/ carries the current build, including the served client bundle"); - } -} - /* ------------------------------------------------------------------- report */ for (const note of notes) console.log(` ok ${note}`); diff --git a/scripts/generate-go-limits.ts b/scripts/generate-go-limits.ts index 9ba69715..2229cf97 100644 --- a/scripts/generate-go-limits.ts +++ b/scripts/generate-go-limits.ts @@ -17,7 +17,9 @@ * source, served as `text/plain`), and CI checks it for drift: a pull request * that changes this generator (or a module it loads) fails on a stale table; * any other pull request, and a push to main, warns, and the nightly run - * proposes the refresh. + * proposes the refresh. "Stale" is the rule the generators share + * (`generator-kit.ts`): the shipped `GO_MODEL_LIMITS` and the page's figures + * differ in data, never in bytes. * * **That URL is gone.** Measured 2026-10-12: it answers 404, the docs live under * `/v2/docs/console/go/` now, and the new page serves HTML - the allowance figures @@ -63,41 +65,59 @@ import { readFileSync, writeFileSync } from "node:fs"; import { OPENCODE_GO_CATALOG } from "../src/catalog-data.ts"; +import { GO_MODEL_LIMITS } from "../src/go-limits-data.ts"; import { GO_LIMITS_DOC_TIMEOUT_MS, GO_LIMITS_DOC_URL, parseGoLimitsDoc, } from "../src/go-limits.ts"; +import { generatorArgs, outcome, readSource, stale } from "./generator-kit.ts"; import { GO_LIMITS_DOC_MOVED_TO, htmlToDoc } from "./go-limits-html.ts"; +import { renderGoLimits } from "./go-limits-render.ts"; const TARGET = new URL("../src/go-limits-data.ts", import.meta.url); -const argv = process.argv.slice(2); -const shouldWrite = argv.includes("--write"); -const fromIndex = argv.indexOf("--from"); -const fromPath = fromIndex === -1 ? undefined : argv[fromIndex + 1]; +const { from: fromPath, write } = generatorArgs(process.argv.slice(2)); + +const causeOf = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** + * The markdown URL is gone; measured 2026-10-12, the docs moved under + * `/v2/docs/console/` and the new page serves HTML. Read the page instead of + * failing: the figures are the same figures, in a table. + */ +const readMovedDoc = async (primary: string): Promise<string> => { + try { + return htmlToDoc( + await readSource({ + timeoutMs: GO_LIMITS_DOC_TIMEOUT_MS, + url: GO_LIMITS_DOC_MOVED_TO, + }) + ); + } catch (error) { + throw new Error( + `go.md: ${primary}; the moved page ${GO_LIMITS_DOC_MOVED_TO}: ${causeOf(error)}`, + { cause: error } + ); + } +}; const readDoc = async (): Promise<string> => { if (fromPath !== undefined) { - return readFileSync(fromPath, "utf8"); + return readSource({ + from: fromPath, + timeoutMs: GO_LIMITS_DOC_TIMEOUT_MS, + url: GO_LIMITS_DOC_URL, + }); } - const response = await fetch(GO_LIMITS_DOC_URL, { - signal: AbortSignal.timeout(GO_LIMITS_DOC_TIMEOUT_MS), - }); - if (response.ok) { - return response.text(); - } - // The markdown URL is gone; measured 2026-10-12, the docs moved under - // `/v2/docs/console/` and the new page serves HTML. Read the page instead of - // failing: the figures are the same figures, in a table. - const moved = await fetch(GO_LIMITS_DOC_MOVED_TO, { - signal: AbortSignal.timeout(GO_LIMITS_DOC_TIMEOUT_MS), - }); - if (!moved.ok) { - throw new Error( - `go.md answered ${response.status} ${response.statusText} and the moved page ${GO_LIMITS_DOC_MOVED_TO} answered ${moved.status}` - ); + try { + return await readSource({ + timeoutMs: GO_LIMITS_DOC_TIMEOUT_MS, + url: GO_LIMITS_DOC_URL, + }); + } catch (error) { + return readMovedDoc(causeOf(error)); } - return htmlToDoc(await moved.text()); }; /** Catalog ids, so a doc label can be resolved to the id the gateway uses. */ @@ -118,17 +138,6 @@ const resolveId = (slug: string): string | undefined => { return catalogIds.has(folded) ? folded : undefined; }; -/** - * A model id as an object key, quoted only when it has to be. - * - * `vp fmt` strips the quotes from any key that is a valid identifier, so a - * uniformly-quoted generator produces a file that differs from its own - * output by whitespace alone — and a staleness check that compares bytes - * then reports drift that is not there. - */ -const jsKey = (id: string): string => - /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(id) ? id : JSON.stringify(id); - const doc = await readDoc(); const { limits, unmapped } = parseGoLimitsDoc(doc); @@ -156,82 +165,44 @@ for (const [slug, allowance] of limits) { } } -/** - * A number as TypeScript source. - * - * A model the vendor's page does not list has no published allowance, which the - * table records as `NaN`. The bare `NaN` identifier is a lint error, so the - * literal is written as `Number.NaN`; it stays `NaN` at runtime, which is the - * point - a model with no figure must not read as `$0`. - * - * @param value - the allowance. - * @returns the literal to emit. - */ -const numberLiteral = (value: number): string => - Number.isNaN(value) ? "Number.NaN" : String(value); - -const body = [...resolved.keys()] - .toSorted() - .map((id) => { - const row = resolved.get(id) as { - go: number; - goPlus: number; - name: string; - }; - // One field per line, which is what `vp fmt` produces for an object - // this size. A compact single line reads better until the formatter - // reflows it — and then the file no longer matches what this script - // emits, so the next run reports STALE for a difference that is only - // whitespace. - return [ - ` ${jsKey(id)}: {`, - ` go: ${numberLiteral(row.go)},`, - ` goPlus: ${numberLiteral(row.goPlus)},`, - ` name: ${JSON.stringify(row.name)},`, - " },", - ].join("\n"); - }) - .join("\n"); - -const file = `/** - * GENERATED by \`scripts/generate-go-limits.ts\` — do not edit by hand. - * - * The Go plan's monthly allowance per model, in whole US dollars, for both tiers: - * \`go\` is the $10/month plan, \`goPlus\` the $40/month one. Source of truth is - * OpenCode's own Go documentation; \`pnpm run limits:shim\` reports staleness and - * \`--write\` rewrites this file. - * - * ${resolved.size} models. - */ - -import type { ModelAllowance } from "./go-limits.ts"; - -export const GO_MODEL_LIMITS: Readonly<Record<string, ModelAllowance>> = { -${body} -}; -`; - -/** The file as it stands, or `""` when it does not exist yet. */ -const readCurrent = (): string => { - try { - return readFileSync(TARGET, "utf8"); - } catch { - return ""; - } -}; - -const current = readCurrent(); -if (current === file) { +const rows = Object.fromEntries(resolved); +const file = renderGoLimits(rows); + +// The shipped table is imported, as `src/` imports it: a missing file breaks +// the build long before it breaks this, and the file is committed. +const onDisk = readFileSync(TARGET, "utf8"); + +// The one staleness rule the generators share: the table's DATA against what +// the page says, so a formatter's reflow or a reordered key is never drift, +// and a current table is left untouched by `--write`. +// +// With `--write`, a table whose data is current is still rewritten when its +// TEXT is not what the renderer makes of that data (`outcome`'s `rewrite`): a +// template change is applied by the generator, never by hand. That compares +// bytes safely only because the renderer's text is already what the +// formatter produces, which `test/generators.test.ts` holds by +// round-tripping the shipped table. +const decided = outcome({ + stale: stale(GO_MODEL_LIMITS, rows), + textCurrent: onDisk === file, + write, +}); +if (decided.action === "rewrite") { + writeFileSync(TARGET, file); + console.log( + `rewrote src/go-limits-data.ts: its data is current (${resolved.size} models), its text was not the renderer's` + ); +} else if (decided.action === "current") { console.log(`go-limits-data.ts is up to date (${resolved.size} models)`); -} else if (shouldWrite) { +} else if (decided.action === "write") { writeFileSync(TARGET, file); console.log(`wrote ${resolved.size} models to src/go-limits-data.ts`); } else { console.error( "go-limits-data.ts is STALE — run: pnpm run limits:shim -- --write" ); - process.exitCode = 1; } +process.exitCode = decided.exit; if (unmatched.length > 0) { console.log( diff --git a/scripts/generator-kit.ts b/scripts/generator-kit.ts new file mode 100644 index 00000000..55d048cf --- /dev/null +++ b/scripts/generator-kit.ts @@ -0,0 +1,155 @@ +/** + * What the two generators (`regenerate-catalog-shim.ts`, `generate-go-limits.ts`) + * and `verify-catalog.ts` share: their arguments, how each reads its source, + * and the ONE staleness rule. + * + * The rule used to be three. The catalog generator compared canonical data + * but only its two arrays, so a moved plane or family endpoint read as + * current; the limits generator compared file bytes, so a formatter's reflow + * read as drift; `verify-catalog` compared id sets, so a changed price or + * protocol on a kept id read as current. Now each asks `stale` over every + * value it generates, and a difference in data is staleness, while key order + * and formatting never are. + * + * Everything here but `readSource` is a function of its arguments + * (`test/generators.test.ts`). + * + * @module scripts/generator-kit + */ +import { readFileSync } from "node:fs"; + +/** A generator's command line: whether to write, and a local source if given. */ +export interface GeneratorArgs { + readonly from?: string; + readonly write: boolean; +} + +/** + * Read `--write` and `--from <path>`. + * + * @param argv - the arguments after the script's path. + * @returns the parsed flags. + * @throws {Error} when `--from` names no path: reading nothing must never pass for + * reading the registry. + */ +export const generatorArgs = (argv: readonly string[]): GeneratorArgs => { + const at = argv.indexOf("--from"); + const write = argv.includes("--write"); + if (at === -1) { + return { write }; + } + const from = argv[at + 1]; + if (from === undefined || from.startsWith("--")) { + throw new Error("--from needs the path of a local copy of the source"); + } + return { from, write }; +}; + +/** + * Canonical form for comparison: object keys sorted at every depth. + * + * Needed because a parser builds an object in its own insertion order while a + * renderer emits sorted keys, so a plain `JSON.stringify` compares equal data + * as different and reports the file stale forever. Order is the renderer's + * business, not the data's. `NaN` (a model with no published allowance) + * reads as `null` on both sides, so it compares equal to itself. + * + * @param value - any JSON-shaped value. + * @returns a string equal for equal data. + */ +export const canonical = (value: unknown): string => { + if (Array.isArray(value)) { + return `[${value.map(canonical).join(",")}]`; + } + if (value !== null && typeof value === "object") { + const record = value as Record<string, unknown>; + return `{${Object.keys(record) + .toSorted() + .map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +}; + +/** + * The one staleness rule: what is shipped and what the source says differ in + * data. Key order and formatting never count. + * + * @param current - what the generated file holds now. + * @param next - what the source makes of it today. + * @returns whether the generated file is stale. + */ +export const stale = (current: unknown, next: unknown): boolean => + canonical(current) !== canonical(next); + +/** + * What a generator does with its answer, and how it exits. + * + * Without `--write` a stale file is a failure (exit 1), which is what the + * pull-request gate reads; with `--write` it is rewritten. A file whose data + * is current is left untouched, with one exception for a generator that + * renders the whole file: with `--write`, data that is current in TEXT that + * is not the renderer's (`textCurrent: false`, a template change) is + * `rewrite`, so the generator applies the template, never a hand. Without + * `--write` that text is still `current`: a template is not vendor drift. + * + * @param input.stale - whether the file is stale. + * @param input.textCurrent - whether the file's text is what the renderer + * makes of its data (default true: a generator that splices, as the + * catalog's does, has no text of its own to compare). + * @param input.write - whether `--write` was given. + * @returns the exit code and the action. + */ +export const outcome = ({ + stale: isStale, + textCurrent = true, + write, +}: { + readonly stale: boolean; + readonly textCurrent?: boolean; + readonly write: boolean; +}): { + readonly action: "current" | "report-stale" | "rewrite" | "write"; + readonly exit: 0 | 1; +} => { + if (!isStale) { + return write && !textCurrent + ? { action: "rewrite", exit: 0 } + : { action: "current", exit: 0 }; + } + return write + ? { action: "write", exit: 0 } + : { action: "report-stale", exit: 1 }; +}; + +/** + * A generator's source text: the local file `from` names, else `url` + * fetched with a timeout. + * + * @param input.from - a local copy of the source, the offline path. + * @param input.url - the source. + * @param input.timeoutMs - how long the fetch may take. + * @returns the text. + * @throws {Error} on an unreadable file or a non-ok answer, naming the status: a + * source that was not read is never answered as current. + */ +export const readSource = async ({ + from, + timeoutMs, + url, +}: { + readonly from?: string; + readonly timeoutMs: number; + readonly url: string; +}): Promise<string> => { + if (from !== undefined) { + return readFileSync(from, "utf8"); + } + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) { + throw new Error( + `${new URL(url).host} answered ${response.status} ${response.statusText}` + ); + } + return response.text(); +}; diff --git a/scripts/go-limits-render.ts b/scripts/go-limits-render.ts new file mode 100644 index 00000000..83da9e9b --- /dev/null +++ b/scripts/go-limits-render.ts @@ -0,0 +1,85 @@ +/** + * The pure half of `scripts/generate-go-limits.ts`: the text of + * `src/go-limits-data.ts`, rendered from the allowance rows. + * + * A function of its rows, so `test/generators.test.ts` can render the shipped + * `GO_MODEL_LIMITS`, run the formatter the nightly runs, and require the + * committed file byte for byte: a template change that was not regenerated + * fails the unit run, and the generator may then compare the rendered text + * with the file to apply such a change with `--write`. + * + * @module scripts/go-limits-render + */ +import type { ModelAllowance } from "../src/go-limits.ts"; + +/** + * A model id as an object key, quoted only when it has to be. + * + * `vp fmt` strips the quotes from any key that is a valid identifier, so a + * uniformly-quoted generator produces a file that differs from its own + * output by quoting alone, and the nightly's refresh would carry a diff that + * is not a change. + * + * @param id - the model id. + * @returns the key as TypeScript source. + */ +export const jsKey = (id: string): string => + /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(id) ? id : JSON.stringify(id); + +/** + * A number as TypeScript source. + * + * A model the vendor's page does not list has no published allowance, which the + * table records as `NaN`. The bare `NaN` identifier is a lint error, so the + * literal is written as `Number.NaN`; it stays `NaN` at runtime, which is the + * point - a model with no figure must not read as `$0`. + * + * @param value - the allowance. + * @returns the literal to emit. + */ +export const numberLiteral = (value: number): string => + Number.isNaN(value) ? "Number.NaN" : String(value); + +/** + * `src/go-limits-data.ts`, rendered: the header, the type import and one + * entry per model, sorted by id, one field per line (what `vp fmt` produces + * for an object this size, so the formatter has nothing to reflow). + * + * @param rows - the allowance per model id. + * @returns the file's text. + */ +export const renderGoLimits = ( + rows: Readonly<Record<string, ModelAllowance>> +): string => { + const entries = Object.entries(rows).toSorted( + ([left], [right]) => Number(left > right) - Number(left < right) + ); + const body = entries + .map(([id, row]) => + [ + ` ${jsKey(id)}: {`, + ` go: ${numberLiteral(row.go)},`, + ` goPlus: ${numberLiteral(row.goPlus)},`, + ` name: ${JSON.stringify(row.name)},`, + " },", + ].join("\n") + ) + .join("\n"); + return `/** + * GENERATED by \`scripts/generate-go-limits.ts\` — do not edit by hand. + * + * The Go plan's monthly allowance per model, in whole US dollars, for both tiers: + * \`go\` is the $10/month plan, \`goPlus\` the $40/month one. Source of truth is + * OpenCode's own Go documentation; \`pnpm run limits:shim\` reports staleness and + * \`--write\` rewrites this file. + * + * ${entries.length} models. + */ + +import type { ModelAllowance } from "./go-limits.ts"; + +export const GO_MODEL_LIMITS: Readonly<Record<string, ModelAllowance>> = { +${body} +}; +`; +}; diff --git a/scripts/host-bump.ts b/scripts/host-bump.ts new file mode 100644 index 00000000..203731d0 --- /dev/null +++ b/scripts/host-bump.ts @@ -0,0 +1,651 @@ +/** + * The pure half of `scripts/propose-host-bump.ts`: what the nightly makes of + * a channel that moved, and the edits that move CONTRIBUTING's release-step-3 + * data in the files that hold it (D22''). + * + * - **The plan** (`bumpPlan`) reads the same findings the drift report prints + * (`readChannel`), so the two never disagree about what moved: a plan is + * `current` exactly when the report has no line for the channel. The + * dist-tag is the one field the report never prints alone, so it is never + * a move alone either: it moves with `dsh` or not at all. The plan decides: + * `current` (nothing moved), `blocked` (any answer unread: a proposal is + * never made from a partial read), `waiting` (the new `dsh` or pi is younger + * than pnpm's one-day release-age window, so an install would refuse it; the + * next night proposes it, and nothing lifts the window), `range` (the cut + * opens a prerelease tuple the peer ranges do not admit, moves the + * bridge's pi range, or sits under a dist-tag the record type does not + * admit: a reviewed change), or `data`. + * - **pi is one value** (`coupleSharedPi`): both lockfiles hold the same pi + * (the rc relock starts from the root's), and each record's `pi` must equal + * its lockfile's. So a proposed pi move carries every other channel's + * record with it: a channel waiting only on its own young `dsh` takes a + * pi-only move the same night when its recorded pi range admits the pi, + * and one that cannot take it (a partial read, another pi, a recorded + * range that stops short) holds the pi move as `blocked` rather than + * proposing a record the lockfile, or its own range, contradicts. + * - **The edits** move version data only: the channel's record + * (`withRecord`), the README pair's "today" sentences (`withReadmePolicy`), + * and, through the existing `channelManifest`, alpha's exact pins. A + * measured table (`WHOLE_CATALOG`, `PI_GAPS_BY_CELL`, `PI_AI_DEFAULTS`, + * `s0PluginFor`) is never edited here: a row the new host turns red is the + * reviewed part of the proposed pull request, which the body names. + * + * Every function is a function of its arguments (`test/host-bump.test.ts`). + * + * @module scripts/host-bump + */ +import semver from "semver"; + +import { channelManifest } from "./channel-manifest.ts"; +import { + BUMP_FIELDS, + type BumpField, + type DriftRecord, + FAMILY_DECLARERS, + readChannel, + REGISTRY_PACKAGES, + type RegistryView, +} from "./host-drift.ts"; + +/** pnpm's default `minimumReleaseAge`: a day, in milliseconds. */ +export const RELEASE_AGE_MS = 24 * 60 * 60 * 1000; + +/** The peer each moved field has to stay inside, by package. */ +const PEERS = { + cordis: "@deepseek-ai/cordis", + dsh: "@deepseek-ai/dsh-typert-protocol", +} as const; + +/** The family fields: moving any of them needs the floor cells. */ +const FAMILY_FIELDS: ReadonlySet<BumpField> = new Set( + FAMILY_DECLARERS.map(({ field }) => field) +); + +/** What the nightly makes of one channel. */ +export interface BumpPlan { + readonly channel: string; + /** The record as it stands. */ + readonly from: DriftRecord; + readonly kind: "blocked" | "current" | "data" | "range" | "waiting"; + /** The fields that moved, in the report's order. */ + readonly moved: readonly BumpField[]; + /** + * Whether the bump moves `piFloor` or a cordis-family field: a pull request + * runs only the default installs, so the floor cells run on a dispatch. + */ + readonly needsFloorCells: boolean; + /** Why the plan is what it is, one sentence each. */ + readonly notes: readonly string[]; + /** The record the bump proposes: the old one with every moved field set. */ + readonly to: DriftRecord; +} + +/** + * Plan one channel's bump. + * + * @param input.channel - the channel's name. + * @param input.record - its record. + * @param input.registry - the registry view. + * @param input.peers - `package.json`'s `peerDependencies`. + * @param input.publishedAt - when the new `dsh` and pi were published (epoch + * ms); an absent time for a field that moved is an unread answer. + * @param input.now - the clock, in epoch ms. + * @param input.tags - the dist-tags the record type admits (`RECORD_TAGS`). + * @returns the plan. + */ +export const bumpPlan = ({ + channel, + now, + peers, + publishedAt, + record, + registry, + tags, +}: { + readonly channel: string; + readonly now: number; + readonly peers: Readonly<Record<string, string>>; + readonly publishedAt: Readonly<Partial<Record<"dsh" | "pi", number>>>; + readonly record: DriftRecord; + readonly registry: RegistryView; + readonly tags: readonly string[]; +}): BumpPlan => { + const findings = readChannel(record, registry); + const found = new Map<BumpField, string>(); + const unread: string[] = []; + for (const finding of findings) { + if (finding.kind === "unread") { + unread.push(finding.message); + } else { + found.set(finding.field, finding.found); + } + } + for (const field of BUMP_FIELDS) { + if (unread.length === 0 && !found.has(field)) { + unread.push(`${field} was not read`); + } + } + const differs = (field: BumpField): boolean => + found.has(field) && found.get(field) !== record[field]; + // The dist-tag is carried with a dsh move, never moved alone: the report + // names it only in the dsh line's label, so a tag-only change (`next` + // retired once `latest` catches up) is "current" there and here. + const moved = BUMP_FIELDS.filter( + (field) => differs(field) && (field !== "distTag" || differs("dsh")) + ); + const to: DriftRecord = { + ...record, + ...(Object.fromEntries( + moved.map((field) => [field, found.get(field) ?? record[field]]) + ) as Partial<Record<BumpField, string>>), + }; + const plan = ( + kind: BumpPlan["kind"], + notes: readonly string[] + ): BumpPlan => ({ + channel, + from: record, + kind, + moved, + needsFloorCells: moved.some( + (field) => field === "piFloor" || FAMILY_FIELDS.has(field) + ), + notes, + to, + }); + + if (unread.length > 0) { + return plan("blocked", unread); + } + if (moved.length === 0) { + return plan("current", []); + } + + const young: string[] = []; + const unknown: string[] = []; + for (const [role, field] of [ + ["dsh", "dsh"], + ["pi", "pi"], + ] as const) { + if (!moved.includes(field)) continue; + const at = publishedAt[role]; + const name = REGISTRY_PACKAGES[role]; + if (at === undefined) { + unknown.push(`the publish time of ${name}@${to[field]} was not read`); + } else if (now - at < RELEASE_AGE_MS) { + young.push( + `${name}@${to[field]} was published ${Math.floor((now - at) / 3_600_000)}h ago, inside pnpm's one-day release-age window; the nightly proposes it once it is a day old` + ); + } + } + if (unknown.length > 0) { + return plan("blocked", unknown); + } + if (young.length > 0) { + return plan("waiting", young); + } + + const range: string[] = []; + for (const [field, peer] of Object.entries(PEERS) as [ + keyof typeof PEERS, + string, + ][]) { + const declared = peers[peer]; + if ( + moved.includes(field) && + declared !== undefined && + !semver.satisfies(to[field], declared) + ) { + const tuple = `${semver.major(to[field])}.${semver.minor(to[field])}.${semver.patch(to[field])}`; + range.push( + `${peer} ${declared} does not admit ${to[field]}: the cut opens the ${tuple} prerelease tuple, so add a \`>=${tuple}-0\` clause (with the upper bound the other clauses use) and the new line to the README pair's peer-range paragraph` + ); + } + } + if (moved.includes("distTag") && !tags.includes(to.distTag)) { + range.push( + `the newest build sits under the \`${to.distTag}\` dist-tag, which the record type does not admit (${tags.map((tag) => `\`${tag}\``).join(", ")}): add it to \`ChannelRecord.distTag\` in review` + ); + } + if (moved.includes("piRange")) { + range.push( + `the bridge's pi range moved from ${record.piRange} to ${to.piRange}: the plugin's own pi range and the union rule follow it in review` + ); + } + return range.length > 0 ? plan("range", range) : plan("data", []); +}; + +/** + * `test/support/host-versions.ts` with one channel's record moved to `to`: + * only that block's literal fields change, everything else is kept byte for + * byte. + * + * @param source - the file's text. + * @param channel - the record to move. + * @param to - the proposed record. + * @returns the edited text. + * @throws {Error} unless every field a bump moves is found exactly once in the block, + * so an edit that could miss its line is refused rather than half-written. + */ +export const withRecord = ( + source: string, + channel: string, + to: Readonly<Record<BumpField, string>> +): string => { + const records = source.indexOf("export const CHANNELS"); + const opening = `\n ${channel}: {\n`; + const start = source.indexOf(opening, records); + const end = source.indexOf("\n },", start); + if (records === -1 || start === -1 || end === -1) { + throw new Error(`no ${channel} record in the CHANNELS block`); + } + let block = source.slice(start, end); + for (const field of BUMP_FIELDS) { + const line = new RegExp(`^( +${field}: )"([^"]*)",$`, "gmu"); + const matches = [...block.matchAll(line)]; + if (matches.length !== 1) { + throw new Error( + `the ${channel} record carries ${matches.length} ${field} line(s), not one` + ); + } + block = block.replaceAll(line, `$1${JSON.stringify(to[field])},`); + } + return `${source.slice(0, start)}${block}${source.slice(end)}`; +}; + +/** + * One README's support-policy paragraph and the installs paragraph after it, + * with a channel's moved values replaced where they appear in backticks. + * + * A value the other channel records too (both say pi `^1.3.0`) is one + * sentence about both: it is rewritten only when the other channel's + * proposed record moves it to the SAME new value. When the other channel + * keeps it, or moves it somewhere else, which half says what is a reviewer's + * call: it is left as it is and returned in `unresolved`, and the proposed + * pull request names it. A value the other channel is moving TO is left the + * same way, since after that channel's edit the text no longer says whose it + * is. For a field in {@link README_HELD_FIELDS} the pull request is then red + * in `test/harness-channel.test.ts` until a reviewer edits the sentence; for + * any other field nothing reads the wording, and the body says so. A moved + * dist-tag is never rewritten either: the tag words in those paragraphs are + * prose about both lines (`next` ahead of `latest`), so it is left for review + * the same way whenever the old tag appears, and no test holds it (the rc + * paragraph names `latest` in backticks whichever tag the record carries). + * + * @param text - the README. + * @param marker - the policy paragraph's opening (`**Support policy.**`). + * @param from - the channel's record now. + * @param to - the record the bump proposes. + * @param other.now - the other channel's record now. + * @param other.proposed - the other channel's record as the bump proposes it + * (`now` when that channel is not part of the bump). + * @returns the edited text, and the fields left for review. + */ +export const withReadmePolicy = ( + text: string, + marker: string, + from: Readonly<Record<BumpField, string>>, + to: Readonly<Record<BumpField, string>>, + other: { + readonly now: Readonly<Record<BumpField, string>>; + readonly proposed: Readonly<Record<BumpField, string>>; + } +): { readonly text: string; readonly unresolved: readonly BumpField[] } => { + const lines = text.split("\n"); + const policy = lines.findIndex((line) => line.startsWith(marker)); + if (policy === -1) { + throw new Error(`no ${marker} paragraph`); + } + const offset = lines.slice(policy + 1).findIndex((line) => line !== ""); + const edited = offset === -1 ? [policy] : [policy, policy + 1 + offset]; + const unresolved: BumpField[] = []; + for (const field of BUMP_FIELDS) { + if (from[field] === to[field]) continue; + const old = `\`${from[field]}\``; + if (!edited.some((index) => lines[index]?.includes(old) === true)) { + continue; + } + const shared = other.now[field] === from[field]; + if ( + field === "distTag" || + other.proposed[field] === from[field] || + (shared && other.proposed[field] !== to[field]) + ) { + unresolved.push(field); + continue; + } + for (const index of edited) { + lines[index] = (lines[index] ?? "").replaceAll(old, `\`${to[field]}\``); + } + } + return { text: lines.join("\n"), unresolved }; +}; + +/** + * The record fields `test/harness-channel.test.ts` holds both READMEs' + * policy paragraph to (it reads this list): a README value of one of these + * left for review turns the proposal red until a reviewer edits it. + */ +export const README_HELD_FIELDS = ["dsh", "piRange", "piFloor"] as const; + +/** A README the bump edits: its path, its policy marker and its text. */ +export interface BumpReadme { + readonly file: string; + readonly marker: string; + readonly text: string; +} + +/** + * The text half of a proposal: every plan applied to the records file, to + * `package.json` (alpha's pins, when alpha moves) and to each README, with + * the README fields left for review merged per channel. Each plan's README + * edit is checked against the OTHER channel's record now and as the bump + * PROPOSES it (the current one when that channel is not part of the bump), + * so a value both channels share is rewritten only when both plans move it + * to the same value, and left for review when one plan moves it alone or the + * two move it apart. The CLI writes what this returns and runs the installs; + * it holds no edit of its own. + * + * @param texts.records - `test/support/host-versions.ts`. + * @param texts.manifest - `package.json`. + * @param texts.readmes - the README pair. + * @param plans - the plans to propose (`data` and `range`). + * @param current - every channel's record now, by channel. + * @returns the edited texts and the fields left for review, per channel. + */ +export const applyBumpTexts = ( + texts: { + readonly manifest: string; + readonly readmes: readonly BumpReadme[]; + readonly records: string; + }, + plans: readonly BumpPlan[], + current: Readonly<Record<string, Readonly<Record<BumpField, string>>>> +): { + readonly manifest: string; + readonly readmes: readonly BumpReadme[]; + readonly records: string; + readonly unresolved: Readonly<Record<string, readonly BumpField[]>>; +} => { + let { records } = texts; + for (const each of plans) { + records = withRecord(records, each.channel, each.to); + } + const alpha = plans.find((each) => each.channel === "alpha"); + const manifest = + alpha === undefined + ? texts.manifest + : channelManifest(texts.manifest, alpha.to); + const now = (channel: string): Readonly<Record<BumpField, string>> => { + const record = current[channel]; + if (record === undefined) { + throw new Error(`no ${channel} record`); + } + return record; + }; + const after = (channel: string): Readonly<Record<BumpField, string>> => + plans.find((each) => each.channel === channel)?.to ?? now(channel); + const unresolved: Record<string, BumpField[]> = {}; + const readmes = texts.readmes.map((readme) => { + let { text } = readme; + for (const each of plans) { + const other = + Object.keys(current).find((channel) => channel !== each.channel) ?? + each.channel; + const edited = withReadmePolicy(text, readme.marker, each.from, each.to, { + now: now(other), + proposed: after(other), + }); + ({ text } = edited); + unresolved[each.channel] = [ + ...new Set([...(unresolved[each.channel] ?? []), ...edited.unresolved]), + ]; + } + return { ...readme, text }; + }); + return { manifest, readmes, records, unresolved }; +}; + +/** `a`, `a and b`, `a, b and c`. */ +const listed = (items: readonly string[]): string => + items.length <= 1 + ? (items[0] ?? "") + : `${items.slice(0, -1).join(", ")} and ${items.at(-1) ?? ""}`; + +/** Whether a plan is one the nightly proposes. */ +const proposes = (plan: BumpPlan): boolean => + plan.kind === "data" || plan.kind === "range"; + +/** + * The plans with pi treated as the one value it is. Both lockfiles hold the + * same pi (the rc relock starts from the root lockfile, and the proposer's + * `pnpm update` moves the root's), and `test/harness-channel.test.ts` holds + * each record's `pi` to its lockfile's. So when a proposed plan moves pi, + * every other channel's record has to end at the same pi: + * + * - one that already records it is left as it is; + * - one `waiting` only on its own young cut, whose read found the same new + * pi and whose RECORDED pi range (the range its installed bridge declares, + * which the pi-only move keeps) admits it, takes a pi-only `data` move the + * same night (pi's publish time is the proposed plan's, already past the + * window), and its other moves wait; + * - any other (a partial read, a channel current at another pi, a waiting + * channel whose recorded range stops short of the pi, a proposed plan + * that does not move pi, two proposed plans that disagree) cannot: every + * proposed plan that moves pi is then `blocked`, with a note naming the + * channel, a waiting plan is returned as it was (never converted), and + * nothing moves either lockfile's pi. + * + * A plan that moves no pi is returned as it is, so a dsh-only proposal is + * never held by another channel's pi; it holds THEIR pi move instead, since + * its record would keep the old pi. + * + * @param plans - every channel's plan, in the channels' order. + * @returns the plans in the same order, coupled. + */ +export const coupleSharedPi = (plans: readonly BumpPlan[]): BumpPlan[] => { + const movers = plans.filter( + (plan) => proposes(plan) && plan.moved.includes("pi") + ); + if (movers.length === 0) { + return [...plans]; + } + const targets = [...new Set(movers.map((plan) => plan.to.pi))]; + const [target = ""] = targets; + const names = listed(movers.map((plan) => plan.channel)); + const coupled: BumpPlan[] = []; + const holds: string[] = + targets.length > 1 + ? [ + `${names} resolve different pis (${listed(targets)}), and both lockfiles hold one: the pi move is held for review`, + ] + : []; + /** Each waiting plan converted to a pi-only move, by the converted plan. */ + const converted = new Map<BumpPlan, BumpPlan>(); + for (const plan of plans) { + const waitsOnTarget = + plan.kind === "waiting" && + plan.moved.includes("pi") && + plan.to.pi === target; + if (movers.includes(plan) || plan.from.pi === target) { + coupled.push(plan); + } else if (waitsOnTarget && semver.satisfies(target, plan.from.piRange)) { + const move: BumpPlan = { + ...plan, + kind: "data", + moved: ["pi"], + needsFloorCells: false, + notes: [ + `pi moves with ${names}, since both lockfiles hold one pi; ${plan.channel}'s other moves wait: ${plan.notes.join("; ")}`, + ], + to: { ...plan.from, pi: target }, + }; + converted.set(move, plan); + coupled.push(move); + } else { + let why = `its read found pi ${plan.to.pi}`; + if (plan.kind === "blocked") { + why = `its read was partial (${plan.notes.join("; ")})`; + } else if (proposes(plan)) { + why = "its proposal does not move pi"; + } else if (waitsOnTarget) { + why = `its recorded pi range ${plan.from.piRange} does not admit ${target}`; + } + holds.push( + `pi ${target} is one value in both lockfiles, and ${plan.channel} records ${plan.from.pi} and cannot move with it tonight: ${why}; the pi move is held until every record can take it` + ); + coupled.push(plan); + } + } + if (holds.length === 0) { + return coupled; + } + // Held: every mover is blocked, and a plan converted above goes back to + // the wait it was, so nothing moves pi. + return coupled.map((plan) => + movers.includes(plan) + ? { ...plan, kind: "blocked", notes: [...holds, ...plan.notes] } + : (converted.get(plan) ?? plan) + ); +}; + +/** + * What a proposal proposes, in full: each proposed channel's `to` record, + * every bump field, in an HTML comment the body carries. The push step + * compares it with the open pull request's body to decide "the same + * target"; the title cannot, since it names only each plan's headline value + * (a pi that moves after a dsh cut leaves it unchanged). + * + * @param plans - the plans (`data` and `range` only are read). + * @returns the marker line. + */ +export const bumpTarget = (plans: readonly BumpPlan[]): string => { + const target = Object.fromEntries( + plans + .filter(proposes) + .toSorted((a, b) => a.channel.localeCompare(b.channel)) + .map((plan) => [ + plan.channel, + Object.fromEntries(BUMP_FIELDS.map((field) => [field, plan.to[field]])), + ]) + ); + return `<!-- host-bump-target ${JSON.stringify(target)} -->`; +}; + +/** The headline value of a plan: its dsh when that moved, else its pi. */ +const headline = (plan: BumpPlan): string => { + if (plan.moved.includes("dsh")) { + return plan.to.dsh; + } + if (plan.moved.includes("pi")) { + return `pi ${plan.to.pi}`; + } + return plan.moved.map((field) => `${field} ${plan.to[field]}`).join(", "); +}; + +/** + * The proposal's title, a Conventional Commit of type `build` (a 1.x change + * either way, never a breaking marker): a data bump names the records it + * moves, and a range change says it needs review. + * + * @param plans - the plans to propose (`data` and `range` only are read). + * @returns the title. + */ +export const bumpTitle = (plans: readonly BumpPlan[]): string => { + // Both records move together on a pi patch: one headline, not two equal. + const headlines = (list: readonly BumpPlan[]): string => + listed([...new Set(list.map(headline))]); + const range = plans.filter((plan) => plan.kind === "range"); + if (range.length > 0) { + return `build: propose ${REGISTRY_PACKAGES.dsh} ${headlines(range)} for ${listed(range.map((plan) => plan.channel))} (peer range change, needs review)`; + } + const data = plans.filter((plan) => plan.kind === "data"); + return `build: bump the ${listed(data.map((plan) => plan.channel))} harness record${data.length === 1 ? "" : "s"} to ${headlines(data)}`; +}; + +/** + * The body's paragraph on README fields left for review: which a test holds + * (red until edited) and which nothing reads (the reviewer's alone). + */ +const leftForReview = (fields: readonly BumpField[]): string[] => { + if (fields.length === 0) { + return []; + } + const code = (list: readonly string[]): string => + list.map((field) => `\`${field}\``).join(", "); + const held = fields.filter((field) => + (README_HELD_FIELDS as readonly string[]).includes(field) + ); + const unheld = fields.filter((field) => !held.includes(field)); + return [ + "", + `Left for review in both READMEs (a value the other channel records too, or a dist-tag, whose sentences are prose about both lines): ${code(fields)}.`, + ...(held.length === 0 + ? [] + : [ + `- ${code(held)}: \`test/harness-channel.test.ts\` is red until the sentence is edited.`, + ]), + ...(unheld.length === 0 + ? [] + : [ + `- ${code(unheld)}: no test reads this wording, so nothing turns red; edit the policy and installs paragraphs of both READMEs before merging.`, + ]), + ]; +}; + +/** + * The proposal's body: per channel what moved, the files the bump changed, + * what was left for review, what CONTRIBUTING's release step 3 asks to + * re-measure when a tier turns red, and whether the floor cells run. + * + * @param plans - the plans to propose. + * @param effects.files - the files the proposal wrote. + * @param effects.unresolved - README fields left for review, per channel. + * @returns the markdown. + */ +export const bumpBody = ( + plans: readonly BumpPlan[], + { + files, + unresolved, + }: { + readonly files: readonly string[]; + readonly unresolved: Readonly<Record<string, readonly BumpField[]>>; + } +): string => { + const proposed = plans.filter(proposes); + const sections = proposed.map((plan) => { + const rows = plan.moved.map( + (field) => + `| \`${field}\` | \`${plan.from[field]}\` | \`${plan.to[field]}\` |` + ); + return [ + `### ${plan.channel}${plan.kind === "range" ? " (peer range change)" : ""}`, + "", + "| field | was | proposed |", + "| :-- | :-- | :-- |", + ...rows, + ...(plan.notes.length === 0 + ? [] + : ["", ...plan.notes.map((note) => `- ${note}`)]), + ...leftForReview(unresolved[plan.channel] ?? []), + ].join("\n"); + }); + const floor = proposed.some((plan) => plan.needsFloorCells); + return [ + "The nightly `host-drift` job found the harness channels moved past their records and proposes CONTRIBUTING's release-step-3 data. Generated by CI: nothing here is a measured table.", + "", + ...sections.flatMap((section) => [section, ""]), + "**Files moved:**", + "", + ...(files.length === 0 ? ["- none"] : files.map((file) => `- \`${file}\``)), + "", + "**If a tier or the binary e2e goes red**, the host behaves differently, and the re-measure is the reviewed part of this pull request, never edited by the nightly: a pi whose catalog or bases moved re-measures the `newest` cell's column of `WHOLE_CATALOG` (`test/host-shapes.test.ts`) and of `PI_GAPS_BY_CELL` (`test/support/host.ts`); a moved `piFloor`, the floor's columns of the same two; a bridge whose defaults moved, `PI_AI_DEFAULTS`, which feeds `s0PluginFor`; a host event whose call shape moved, the route claim's listener (`src/host/route-claim.ts`) and the tier listeners.", + "", + floor + ? "A floor-cell field moved (`piFloor` or the cordis family), and a pull request runs only the default installs: the floor cells were dispatched on `chore/host-bump` (ci.yml, `workflow_dispatch`)." + : "No floor-cell field moved: the floor cells were not dispatched, and this pull request's own CI is the whole run.", + "", + bumpTarget(proposed), + ].join("\n"); +}; diff --git a/scripts/host-drift.ts b/scripts/host-drift.ts index 89730904..1648f214 100644 --- a/scripts/host-drift.ts +++ b/scripts/host-drift.ts @@ -32,7 +32,9 @@ * * Every function here is a function of its arguments - the registry's answers * are data handed in - so `test/host-drift.test.ts` holds every rule with known - * answers. The CLI fetches the packuments and prints one warning per line. + * answers. `readChannel` is the one reading: the report (`hostDrift`, printed + * by `scripts/report-host-drift.ts`, one warning per line) and the nightly bump + * proposal (`scripts/host-bump.ts`) are both built on it. * * @module scripts/host-drift */ @@ -205,102 +207,156 @@ export const channelBuild = ( : { tag: newest[0], version: newest[1] }; }; +/** Every record field a bump can move, in the order the report reads them. */ +export const BUMP_FIELDS = [ + "distTag", + "dsh", + "piRange", + "piFloor", + "pi", + ...FAMILY_DECLARERS.map(({ field }) => field), +] as const satisfies readonly (keyof DriftRecord)[]; + +/** A record field a bump can move: dsh's line, pi's three and the family. */ +export type BumpField = + | "distTag" + | "dsh" + | "pi" + | "piFloor" + | "piRange" + | FamilyField; + /** - * One line per field where the registry and a channel's record disagree, each - * naming the channel, the field, the record's value and the registry's. An - * empty list means every record is current. A registry answer that cannot be - * read (no dist-tag, no declaring package at that version, no declared range, - * nothing in the pi range) is a line too: "read nothing" must never look like "current". + * What the registry says about one field of a channel's record, or why a + * field could not be read. `label` is how the report names the field. */ -export const hostDrift = ( - records: Readonly<Record<string, DriftRecord>>, +export type ChannelFinding = + | { + readonly field: BumpField; + readonly found: string; + readonly kind: "value"; + readonly label: string; + } + | { readonly kind: "unread"; readonly message: string }; + +/** + * What the registry says about every field of one channel's record, in the + * report's order: one `value` finding per field it read (equal to the record + * or not), and one `unread` finding per answer it could not read (no + * dist-tag, no declaring package at that version, no declared range, nothing + * in the pi range). The drift report prints the values that differ and every + * unread; the bump proposal (`scripts/host-bump.ts`) reads the same findings, + * so the two can never disagree about what moved. + * + * @param record - the channel's record. + * @param registry - the packuments, one per role. + * @returns the findings; a `distTag` value is carried but never printed alone. + */ +export const readChannel = ( + record: DriftRecord, registry: RegistryView -): string[] => { - const lines: string[] = []; - for (const [channel, record] of Object.entries(records)) { - const say = (field: string, recorded: string, found: string): void => { - lines.push( - `${channel}: ${field} is ${found} on the registry; the record says ${recorded}` - ); - }; - const build = channelBuild(registry.dsh["dist-tags"] ?? {}, record); - if (build === undefined) { - const line = lineOf(record.dsh); - lines.push( - `${channel}: ${REGISTRY_PACKAGES.dsh} has no '${record.distTag}' dist-tag${line === undefined ? "" : ` and no build on the ${line} line`}; nothing compared` - ); - continue; +): readonly ChannelFinding[] => { + const findings: ChannelFinding[] = []; + const value = (field: BumpField, label: string, found: string): void => { + findings.push({ field, found, kind: "value", label }); + }; + const unread = (message: string): void => { + findings.push({ kind: "unread", message }); + }; + const build = channelBuild(registry.dsh["dist-tags"] ?? {}, record); + if (build === undefined) { + const line = lineOf(record.dsh); + unread( + `${REGISTRY_PACKAGES.dsh} has no '${record.distTag}' dist-tag${line === undefined ? "" : ` and no build on the ${line} line`}; nothing compared` + ); + return findings; + } + const tagged = build.version; + value("distTag", "the dist-tag", build.tag); + value("dsh", `${REGISTRY_PACKAGES.dsh}@${build.tag}`, tagged); + const bridge = registry.bridge.versions?.[tagged]; + if (bridge === undefined) { + unread( + `${REGISTRY_PACKAGES.bridge}@${tagged} is not published; the ranges were not compared` + ); + return findings; + } + const declared = declaredRanges(bridge); + + const piRange = declared[REGISTRY_PACKAGES.pi]; + if (piRange === undefined) { + unread( + `${REGISTRY_PACKAGES.bridge}@${tagged} declares no ${REGISTRY_PACKAGES.pi}` + ); + } else { + value("piRange", "the bridge's pi range", piRange); + const floor = floorOf(piRange); + if (floor !== undefined) { + value("piFloor", "the pi range's floor", floor); } - const tagged = build.version; - if (tagged !== record.dsh) { - say(`${REGISTRY_PACKAGES.dsh}@${build.tag}`, record.dsh, tagged); + const pi = newestIn(registry.pi, piRange); + if (pi === undefined) { + unread(`no published ${REGISTRY_PACKAGES.pi} satisfies ${piRange}`); + } else { + value("pi", `the newest pi ${piRange} resolves`, pi); } - const bridge = registry.bridge.versions?.[tagged]; - if (bridge === undefined) { - lines.push( - `${channel}: ${REGISTRY_PACKAGES.bridge}@${tagged} is not published; the ranges were not compared` - ); + } + + const floors: Partial<Record<FamilyField, string>> = {}; + for (const { declarer, field, name } of FAMILY_DECLARERS) { + const at = declarer === "cordis" ? floors.cordis : tagged; + const declarerName = REGISTRY_PACKAGES[declarer]; + if (at === undefined) { + unread(`no ${declarerName} floor was read; ${name} was not compared`); continue; } - const declared = declaredRanges(bridge); - - const piRange = declared[REGISTRY_PACKAGES.pi]; - if (piRange === undefined) { - lines.push( - `${channel}: ${REGISTRY_PACKAGES.bridge}@${tagged} declares no ${REGISTRY_PACKAGES.pi}` + const manifest = registry[declarer].versions?.[at]; + if (manifest === undefined) { + unread( + `${declarerName}@${at} is not published; ${name} was not compared` ); - } else { - if (piRange !== record.piRange) { - say("the bridge's pi range", record.piRange, piRange); - } - const floor = floorOf(piRange); - if (floor !== undefined && floor !== record.piFloor) { - say("the pi range's floor", record.piFloor, floor); - } - const pi = newestIn(registry.pi, piRange); - if (pi === undefined) { - lines.push( - `${channel}: no published ${REGISTRY_PACKAGES.pi} satisfies ${piRange}` - ); - } else if (pi !== record.pi) { - say(`the newest pi ${piRange} resolves`, record.pi, pi); - } + continue; } - - const floors: Partial<Record<FamilyField, string>> = {}; - for (const { declarer, field, name } of FAMILY_DECLARERS) { - const at = declarer === "cordis" ? floors.cordis : tagged; - const declarerName = REGISTRY_PACKAGES[declarer]; - if (at === undefined) { - lines.push( - `${channel}: no ${declarerName} floor was read; ${name} was not compared` - ); - continue; - } - const manifest = registry[declarer].versions?.[at]; - if (manifest === undefined) { - lines.push( - `${channel}: ${declarerName}@${at} is not published; ${name} was not compared` - ); - continue; - } - const range = declaredRanges(manifest)[name]; - if (range === undefined) { - lines.push(`${channel}: ${declarerName}@${at} declares no ${name}`); - continue; - } - const floor = floorOf(range); - if (floor === undefined) { - lines.push( - `${channel}: ${declarerName}@${at} declares ${name} ${range}, which has no floor` - ); - continue; - } - floors[field] = floor; - if (floor !== record[field]) { - say(`the floor of ${name} ${range}`, record[field], floor); - } + const range = declaredRanges(manifest)[name]; + if (range === undefined) { + unread(`${declarerName}@${at} declares no ${name}`); + continue; } + const floor = floorOf(range); + if (floor === undefined) { + unread( + `${declarerName}@${at} declares ${name} ${range}, which has no floor` + ); + continue; + } + floors[field] = floor; + value(field, `the floor of ${name} ${range}`, floor); } - return lines; + return findings; }; + +/** + * One line per field where the registry and a channel's record disagree, each + * naming the channel, the field, the record's value and the registry's. An + * empty list means every record is current. A registry answer that cannot be + * read (no dist-tag, no declaring package at that version, no declared range, + * nothing in the pi range) is a line too: "read nothing" must never look like "current". + */ +export const hostDrift = ( + records: Readonly<Record<string, DriftRecord>>, + registry: RegistryView +): string[] => + Object.entries(records).flatMap(([channel, record]) => + readChannel(record, registry).flatMap((finding) => { + if (finding.kind === "unread") { + return [`${channel}: ${finding.message}`]; + } + const recorded = record[finding.field]; + // The dist-tag is named in the dsh line's label, never on its own. + return finding.field === "distTag" || finding.found === recorded + ? [] + : [ + `${channel}: ${finding.label} is ${finding.found} on the registry; the record says ${recorded}`, + ]; + }) + ); diff --git a/scripts/package-checks.ts b/scripts/package-checks.ts index acabdce5..06731700 100644 --- a/scripts/package-checks.ts +++ b/scripts/package-checks.ts @@ -1,11 +1,13 @@ /** * Pure checks over the package's publish shape, shared by `scripts/check.ts` - * and `test/package-checks.test.ts`. + * (which asks only `libFreshness`: the gate keeps what needs a build) and the + * unit tests (`test/package-checks.test.ts`, `test/toolchain.test.ts`, + * `test/no-local-paths.test.ts`), which hold every other rule. * * Every function here is a function of its arguments — the caller reads - * `package.json` and `release.yml`, and these decide — so the test can prove - * each answer on a fixture whose answer is known before it trusts the answer - * on the real tree. + * `package.json`, `release.yml`, the locales or a file's mtime, and these + * decide — so a test can prove each answer on a fixture whose answer is known + * before it trusts the answer on the real tree. * * @module scripts/package-checks */ @@ -138,3 +140,272 @@ export const publishListMismatch = ( `${name} is verified by release.yml but nothing publishes it, so every release fails on it` ), ]; + +/** + * Whether `lib/` carries the current build, as one rule: a missing artifact, + * a half-built `lib/` (`vp pack` emits `client.cjs`, and the served + * `client.js` comes from `scripts/name-client-bundle.ts`), or an artifact + * older than the newest source. + * + * Not hypothetical: a `vp pack` whose output was discarded shipped a `lib/` + * with no `client.js`, and nothing noticed. The host loads exactly these + * files, so a build that failed looks like one that succeeded to everything + * downstream. + * + * @param input.artifacts - each required artifact's mtime, `undefined` when + * the file is missing. + * @param input.newestSource - the newest mtime under `src/`. + * @param input.hasClientCjs - whether `lib/client.cjs` exists. + * @returns the failure, or `undefined` when `lib/` is current. + */ +export const libFreshness = ({ + artifacts, + hasClientCjs, + newestSource, +}: { + readonly artifacts: Readonly<Record<string, number | undefined>>; + readonly hasClientCjs: boolean; + readonly newestSource: number; +}): string | undefined => { + const missing = Object.entries(artifacts) + .filter(([, mtime]) => mtime === undefined) + .map(([name]) => name); + if (missing.length > 0) { + const hint = + hasClientCjs && missing.includes("client.js") + ? " (lib/client.cjs exists — run scripts/name-client-bundle.ts)" + : ""; + return ( + `lib/ is missing ${missing.join(", ")}${hint}; run \`pnpm run build\`. ` + + "The host loads these files, so a build that failed looks exactly like one that succeeded" + ); + } + const builtAt = Math.min( + ...Object.values(artifacts).map((mtime) => mtime ?? 0) + ); + return builtAt < newestSource + ? "lib/ is OLDER than src/ — the artifacts were not rebuilt from the current " + + "source, which ships a plugin whose behaviour does not match its code; run `pnpm run build`" + : undefined; +}; + +/** + * Each peer range npm cannot use: empty, a pnpm-only protocol npm cannot + * parse (so the install fails), or an exact pin (so a DSH patch release + * orphans the plugin). + * + * @param peers - `package.json`'s `peerDependencies`. + * @returns one message per bad peer, in declaration order. + */ +export const peerRangeProblems = ( + peers: Readonly<Record<string, unknown>> +): string[] => + Object.entries(peers).flatMap(([name, range]) => { + if (typeof range !== "string" || range.trim() === "") { + return [`peer ${name} has no usable range`]; + } + if (/^(workspace|link|file|catalog):/u.test(range)) { + return [ + `peer ${name} uses "${range.split(":")[0]}:" — npm cannot parse it, so install fails`, + ]; + } + if (!/^[\^~><=|*]/u.test(range.trim())) { + return [ + `peer ${name} is pinned to "${range}" — a DSH patch release would orphan the plugin`, + ]; + } + return []; + }); + +/** The credential shapes the tree must never carry, each with what it is. */ +export const SECRET_PATTERNS: readonly (readonly [RegExp, string])[] = [ + [/\bmonid_live_[A-Za-z0-9]{10,}/u, "a Monid platform key"], + [/\bsk-tinyfish-[A-Za-z0-9_-]{10,}/u, "a TinyFish key"], + [/\bgh[pousr]_[A-Za-z0-9]{20,}/u, "a GitHub token"], + [/sk-ant-[A-Za-z0-9_-]{10,}/u, "an Anthropic key"], +]; + +/** + * What credentials a text carries, by kind. + * + * @param text - a file's contents. + * @returns one description per pattern that matches (empty when none does). + */ +export const secretsIn = (text: string): string[] => + SECRET_PATTERNS.filter(([pattern]) => pattern.test(text)).map( + ([, what]) => what + ); + +/** The scripts that must reach the toolchain through `vp`. */ +export const TOOLCHAIN_SCRIPTS: readonly string[] = [ + "build", + "check", + "format", + "lint", + "test", +]; + +/** A tool `vp` wraps, named directly: that bypasses the config `vp` reads. */ +const UNBYPASSED = /(?:^|[\s(])(oxlint|oxfmt|tsdown|vitest|tsc)(?:[\s)]|$)/gu; + +/** + * Each toolchain script that is missing, does not go through `vp`, or reaches + * past it for a tool `vp` wraps. `tsc` is the one tool `vp` does not wrap, + * so a `vp` script may name it. + * + * @param scripts - `package.json`'s `scripts`. + * @param names - the scripts to hold to the rule. + * @returns one message per offending script. + */ +export const scriptsBypassingVp = ( + scripts: Readonly<Record<string, string | undefined>>, + names: readonly string[] = TOOLCHAIN_SCRIPTS +): string[] => + names.flatMap((name) => { + const script = scripts[name]; + if (script === undefined) { + return [`no ${name} script`]; + } + if (!/\bvp\b/u.test(script)) { + return [`the ${name} script does not go through vp: ${script}`]; + } + const bare = [...script.replaceAll(/\bvp\b\S*/gu, "").matchAll(UNBYPASSED)] + .map((match) => match[1] ?? "") + .filter((tool) => tool !== "tsc"); + return bare.length === 0 + ? [] + : [ + `the ${name} script reaches past vp for ${bare.join(", ")}; that bypasses the entry point that reads the config`, + ]; + }); + +/** + * Every leaf of a locale file as a dotted key path, so two locales can be + * compared key for key: a key missing from one renders as the raw key. + * + * @param value - a parsed locale file, or any value within it. + * @param prefix - the path to `value` (empty at the root). + * @returns the leaf paths, in document order. + */ +export const localeKeyPaths = (value: unknown, prefix = ""): string[] => + value !== null && typeof value === "object" && !Array.isArray(value) + ? Object.entries(value as Record<string, unknown>).flatMap(([key, child]) => + localeKeyPaths(child, prefix === "" ? key : `${prefix}.${key}`) + ) + : [prefix]; + +/** + * An error's message, from any realm: an error thrown inside a `vm` context + * is not an `instanceof Error` here. + */ +const messageOf = (error: unknown): string => + typeof error === "object" && + error !== null && + typeof (error as { message?: unknown }).message === "string" + ? (error as { message: string }).message + : String(error); + +/** + * The target of a host-module stand-in: a plain function, which both the + * `apply` and the `construct` trap can wrap (an arrow function cannot be + * constructed). Its body never runs; the traps answer instead. + */ +function standInTarget(): void { + // Never called: the proxy's traps answer every call and construction. +} + +/** One `window.__ModuleLoader__.load` call a client bundle makes. */ +interface ClientRegistration { + readonly factory?: (require: (name: string) => unknown) => unknown; + readonly id?: unknown; +} + +/** + * What is wrong with a client bundle, read the way the web client loader + * reads it: the bundle runs with a `window` whose module loader records each + * registration, every expected id must be registered, and the first + * registration's factory, called with a `require` that answers a stand-in for + * any host module (callable, constructible, every property another stand-in), + * must answer the expected `NS` and an `apply()`. + * + * `scripts/check.ts` runs it on the bundle the packed tarball installs; + * `test/package-checks.test.ts` runs it on `lib/client.js` and on planted + * bundles. The context runner is a parameter (`node:vm`'s + * `runInNewContext`), so this stays a function of its arguments. + * + * @param source - the bundle's text. + * @param expected.ids - the ids the bundle must register. + * @param expected.ns - the `NS` its factory must answer. + * @param runInContext - runs `source` with `sandbox` as its global object. + * @returns one message per problem (empty when the bundle loads as the host + * loads it), and the host modules the factory asked for. + */ +export const clientBundleProblems = ( + source: string, + expected: { readonly ids: readonly string[]; readonly ns: string }, + runInContext: (code: string, sandbox: Record<string, unknown>) => unknown +): { readonly asked: readonly string[]; readonly problems: string[] } => { + const registered: ClientRegistration[] = []; + const asked: string[] = []; + try { + runInContext(source, { + window: { + __ModuleLoader__: { + load: (spec: ClientRegistration) => { + registered.push(spec); + }, + }, + }, + }); + } catch (error) { + return { + asked, + problems: [`the client bundle threw while loading: ${messageOf(error)}`], + }; + } + const ids = registered.map((spec) => String(spec.id)); + const problems = expected.ids + .filter((id) => !ids.includes(id)) + .map( + (id) => + `the client bundle did not register ${id} (it registered ${ids.join(", ") || "nothing"})` + ); + const factory = registered[0]?.factory; + if (typeof factory !== "function") { + return { + asked, + problems: [...problems, "the client bundle registered no factory"], + }; + } + const stand = (): unknown => + new Proxy(standInTarget, { + apply: () => stand(), + construct: () => stand() as object, + get: (target, key) => { + if (key === "prototype") { + return target.prototype; + } + return typeof key === "symbol" ? undefined : stand(); + }, + }); + let client: unknown; + try { + client = factory((name) => { + asked.push(name); + return stand(); + }); + } catch (error) { + return { + asked, + problems: [...problems, `the client factory threw: ${messageOf(error)}`], + }; + } + const { NS, apply } = (client ?? {}) as { NS?: unknown; apply?: unknown }; + if (NS !== expected.ns) { + problems.push(`the client factory answered NS ${String(NS)}`); + } + if (typeof apply !== "function") { + problems.push("the client factory answered no apply()"); + } + return { asked, problems }; +}; diff --git a/scripts/propose-host-bump.ts b/scripts/propose-host-bump.ts new file mode 100644 index 00000000..27bd24a0 --- /dev/null +++ b/scripts/propose-host-bump.ts @@ -0,0 +1,242 @@ +/** + * Propose the harness bump the nightly drift report found (D22''). + * + * node --experimental-strip-types scripts/propose-host-bump.ts [--registry <url>] [--write] [--out <dir>] [--root <dir>] + * + * Reads the registry the way the report does (`scripts/registry-view.ts`), + * plans each channel (`bumpPlan` in `scripts/host-bump.ts`), couples the + * channels' pi (`coupleSharedPi`: both lockfiles hold one pi, so a pi move + * carries every record or none) and prints one line per plan. A plan that cannot be proposed is a `::warning::` line + * (`blocked`: a partial read; `waiting`: a cut inside pnpm's one-day + * release-age window, proposed the next night), and nothing that cannot be + * proposed ever fails the run: it exits 0. + * + * With `--write`, for each `data` or `range` plan it moves CONTRIBUTING's + * release-step-3 data and nothing else: the record in + * `test/support/host-versions.ts`, alpha's exact pins in `package.json` + * (`channelManifest`), the README pair's "today" sentences, the root + * lockfile (`pnpm install`, and `pnpm update @earendil-works/pi-ai --no-save` + * when pi moved, since an install keeps a pi the lockfile satisfies) and the + * rc lockfile (`scripts/use-channel.ts rc --relock`). It runs no test: the + * proposed pull request's own CI is the run. A pnpm step that fails puts back + * every tracked file this wrote and exits 1. + * + * With `--out <dir>`, it leaves what CI's step needs to open the pull request: + * `title`, `body.md`, `target` (the marker line `body.md` ends with, which + * names every proposed record in full: the push step's "same target"), + * `files` (the paths it changed, one per line) and `dispatch` (present only + * when the floor cells must run). No `title` means nothing to propose. + * + * With `--root <dir>`, the files it reads and writes, and the commands it + * runs, are under that directory instead of the repository (the records it + * plans from are still the repository's own). The mock suite uses it to run + * the write and its put-back on a scratch copy. + * + * @module scripts/propose-host-bump + */ +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + CHANNELS, + type Channel, + type ChannelRecord, + RECORD_TAGS, +} from "../test/support/host-versions.ts"; +import { + applyBumpTexts, + type BumpPlan, + bumpBody, + bumpPlan, + bumpTarget, + bumpTitle, + coupleSharedPi, +} from "./host-bump.ts"; +import { + type BumpField, + readChannel, + REGISTRY_PACKAGES, +} from "./host-drift.ts"; +import { fetchPublishTimes, fetchRegistryView } from "./registry-view.ts"; + +const argv = process.argv.slice(2); +const option = (name: string): string | undefined => { + const at = argv.indexOf(name); + return at === -1 ? undefined : argv[at + 1]; +}; +const ROOT = option("--root") ?? join(import.meta.dirname, ".."); +const registry = option("--registry") ?? "https://registry.npmjs.org"; +const write = argv.includes("--write"); +const out = option("--out"); + +/** The files a bump may move, from the repository root. */ +const RECORDS = "test/support/host-versions.ts"; +const READMES = [ + ["README.md", "**Support policy.**"], + ["README.zh-CN.md", "**支持策略。**"], +] as const; +const MOVABLE = [ + RECORDS, + "package.json", + ...READMES.map(([file]) => file), + "pnpm-lock.yaml", + ...Object.values(CHANNELS).map((record) => record.lockfile), +].filter((file, index, all) => all.indexOf(file) === index); + +const read = (file: string): string => readFileSync(join(ROOT, file), "utf8"); + +/** One line per plan, as the nightly log reads it. */ +const describe = (plan: BumpPlan): string => { + if (plan.kind === "current") { + return `${plan.channel}: current (dsh@${plan.from.dsh}, pi ${plan.from.pi})`; + } + if (plan.kind === "blocked" || plan.kind === "waiting") { + return `::warning::${plan.channel}: ${plan.kind}: ${plan.notes.join("; ")}`; + } + const moves = plan.moved + .map((field) => `${field} ${plan.from[field]} -> ${plan.to[field]}`) + .join(", "); + return `${plan.channel}: ${plan.kind}: ${moves}${plan.notes.length === 0 ? "" : `; ${plan.notes.join("; ")}`}`; +}; + +/** Run a command in the repository, its output inherited; throws on failure. */ +const run = (command: string, args: readonly string[]): void => { + console.log(`$ ${command} ${args.join(" ")}`); + const result = spawnSync(command, args, { cwd: ROOT, stdio: "inherit" }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} exited with ${String(result.status)}` + ); + } +}; + +/** Read the registry and plan every channel; undefined when it could not be read. */ +const plan = async (): Promise<BumpPlan[] | undefined> => { + try { + const view = await fetchRegistryView(registry); + const channels = Object.entries(CHANNELS) as [Channel, ChannelRecord][]; + // The versions each channel would move to, for their publish times. + const foundOf = (record: ChannelRecord, field: BumpField) => + readChannel(record, view).flatMap((finding) => + finding.kind === "value" && finding.field === field + ? [finding.found] + : [] + ); + const wanted = { + dsh: channels.flatMap(([, record]) => foundOf(record, "dsh")), + pi: channels.flatMap(([, record]) => foundOf(record, "pi")), + }; + const [dshTimes, piTimes] = await Promise.all([ + fetchPublishTimes(registry, REGISTRY_PACKAGES.dsh, wanted.dsh), + fetchPublishTimes(registry, REGISTRY_PACKAGES.pi, wanted.pi), + ]); + const peers = ( + JSON.parse(read("package.json")) as { + peerDependencies?: Record<string, string>; + } + ).peerDependencies; + const planned = channels.map(([channel, record]) => { + const [dsh] = foundOf(record, "dsh"); + const [pi] = foundOf(record, "pi"); + return bumpPlan({ + channel, + now: Date.now(), + peers: peers ?? {}, + publishedAt: { + dsh: dsh === undefined ? undefined : dshTimes[dsh], + pi: pi === undefined ? undefined : piTimes[pi], + }, + record, + registry: view, + tags: RECORD_TAGS, + }); + }); + return coupleSharedPi(planned); + } catch (error) { + console.log( + `::warning::host bump not proposed: ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + } +}; + +/** Move the data, then the lockfiles; the README fields left for review. */ +const apply = ( + proposed: readonly BumpPlan[] +): Readonly<Record<string, readonly BumpField[]>> => { + const texts = applyBumpTexts( + { + manifest: read("package.json"), + readmes: READMES.map(([file, marker]) => ({ + file, + marker, + text: read(file), + })), + records: read(RECORDS), + }, + proposed, + CHANNELS + ); + writeFileSync(join(ROOT, RECORDS), texts.records); + writeFileSync(join(ROOT, "package.json"), texts.manifest); + for (const { file, text } of texts.readmes) { + writeFileSync(join(ROOT, file), text); + } + + run("pnpm", ["install", "--no-frozen-lockfile"]); + if (proposed.some((each) => each.moved.includes("pi"))) { + run("pnpm", ["update", "@earendil-works/pi-ai", "--no-save"]); + } + run(process.execPath, [ + "--experimental-strip-types", + "scripts/use-channel.ts", + "rc", + "--relock", + ]); + return texts.unresolved; +}; + +const plans = await plan(); +if (plans !== undefined) { + for (const each of plans) { + console.log(describe(each)); + } + const proposed = plans.filter( + (each) => each.kind === "data" || each.kind === "range" + ); + if (proposed.length > 0) { + const before = new Map(MOVABLE.map((file) => [file, read(file)])); + let unresolved: Record<string, readonly BumpField[]> = {}; + if (write) { + try { + unresolved = apply(proposed); + } catch (error) { + for (const [file, text] of before) { + writeFileSync(join(ROOT, file), text); + } + console.log( + `::error::the bump was not proposed: ${error instanceof Error ? error.message : String(error)}; every tracked file it wrote is put back` + ); + process.exit(1); + } + } + const files = MOVABLE.filter((file) => read(file) !== before.get(file)); + if (out !== undefined) { + mkdirSync(out, { recursive: true }); + writeFileSync(join(out, "title"), `${bumpTitle(proposed)}\n`); + writeFileSync(join(out, "target"), `${bumpTarget(proposed)}\n`); + writeFileSync( + join(out, "body.md"), + `${bumpBody(proposed, { files, unresolved })}\n` + ); + writeFileSync( + join(out, "files"), + files.map((file) => `${file}\n`).join("") + ); + if (proposed.some((each) => each.needsFloorCells)) { + writeFileSync(join(out, "dispatch"), "floor cells\n"); + } + } + } +} diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index c392f660..4b9fa28d 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -18,7 +18,7 @@ * two spellings. * * The list is `SCOPED_TARGETS` (`scripts/scoped-targets.ts`), which - * `scripts/check.ts` holds in step with release.yml's verify step. + * `test/package-checks.test.ts` holds in step with release.yml's verify step. * * Usage: node --experimental-strip-types scripts/publish-scoped.ts * PUBLISH_DRY_RUN=1 node --experimental-strip-types scripts/publish-scoped.ts diff --git a/scripts/regenerate-catalog-shim.ts b/scripts/regenerate-catalog-shim.ts index ebc300fd..02a78a20 100644 --- a/scripts/regenerate-catalog-shim.ts +++ b/scripts/regenerate-catalog-shim.ts @@ -19,14 +19,20 @@ * ``` * * Without `--write` it only reports what would change, so it is safe to run in CI - * or by hand to answer "is the shim stale?". + * or by hand to answer "is the shim stale?". Stale is decided by the one rule + * the generators share (`generator-kit.ts`) over everything the file exports: + * both catalogs, the plane endpoints and the family endpoints + * (`catalog-shim.ts`, the pure half). With `--write`, a current shim is left + * untouched. */ import { readFileSync, writeFileSync } from "node:fs"; import { basename } from "node:path"; import { + OPENCODE_FAMILY_API, OPENCODE_GO_CATALOG, + OPENCODE_PLANE_API, OPENCODE_ZEN_CATALOG, } from "../src/catalog-data.ts"; import { @@ -35,149 +41,26 @@ import { MODELS_OPENCODE_URL, parseModelsDevCatalog, } from "../src/models-catalog.ts"; +import { + render, + renderFamilyApi, + renderPlaneApi, + shimData, + shimStale, + splice, + spliceObject, +} from "./catalog-shim.ts"; +import { generatorArgs, outcome, readSource } from "./generator-kit.ts"; const TARGET = new URL("../src/catalog-data.ts", import.meta.url); -const argv = process.argv.slice(2); -const shouldWrite = argv.includes("--write"); -const fromIndex = argv.indexOf("--from"); -const fromPath = fromIndex === -1 ? undefined : argv[fromIndex + 1]; - -/** The payload, from a local file when given one — the offline path. */ -const readPayload = async (): Promise<string> => { - if (fromPath !== undefined) { - return readFileSync(fromPath, "utf8"); - } - const response = await fetch(MODELS_OPENCODE_URL, { - signal: AbortSignal.timeout(MODELS_DEV_TIMEOUT_MS), - }); - if (!response.ok) { - throw new Error( - `models.opencode.ai answered ${response.status} ${response.statusText}` - ); - } - return response.text(); -}; - -/** Thousands separators, matching how the file already reads. */ -const numeral = (value: number): string => - String(value).replaceAll(/\B(?=(\d{3})+(?!\d))/gu, "_"); - -/** - * One catalog entry, keys sorted so a regeneration produces a minimal diff. - * - * Sorting is the whole reason this is a generator and not a hand-edit: the file - * is formatted by `vp fmt`, which does not sort object keys, so a hand-added key - * lands wherever it was typed and the next run moves it. - */ -const literal = (spec: CatalogModelSpec): string => { - const lines = Object.keys(spec) - .toSorted() - .map((key) => { - const value: unknown = (spec as unknown as Record<string, unknown>)[key]; - if (key === "cost" && value !== undefined && value !== null) { - const rate = value as Record<string, number>; - const inner = Object.keys(rate) - .toSorted() - .map((name) => ` ${name}: ${rate[name]}`) - .join(",\n"); - return `cost: {\n${inner},\n },`; - } - if (Array.isArray(value)) { - return `${key}: [${value.map((v) => JSON.stringify(v)).join(", ")}],`; - } - if (typeof value === "number") { - return `${key}: ${numeral(value)},`; - } - return `${key}: ${JSON.stringify(value)},`; - }); - return ` {\n${lines.map((line) => ` ${line}`).join("\n")}\n }`; -}; - -/** The array literal for one provider, ready to splice into the file. */ -const render = (specs: readonly CatalogModelSpec[]): string => - `[\n${specs.map((spec) => `${literal(spec)},`).join("\n")}\n]`; - -/** The models one provider contributes, sorted deterministically by id. */ -const forProvider = ( - parsed: ReturnType<typeof parseModelsDevCatalog>, - provider: "go" | "zen" -): CatalogModelSpec[] => - parsed[provider].toSorted((a, b) => a.id.localeCompare(b.id)); - -/** - * Replace one exported object literal, by its `export const NAME` anchor. - * - * `splice` is array-shaped: it looks for `[` and ends at `];`. The plane export - * is an object, so it needs its own opener and terminator - and using the array - * one on it would have thrown rather than half-written the file. - */ -const spliceObject = (source: string, name: string, body: string): string => { - const anchorName = `export const ${name}: `; - const start = source.indexOf(anchorName); - if (start === -1) { - throw new Error(`could not find ${name} in ${basename(TARGET.pathname)}`); - } - const open = source.indexOf("{", start + anchorName.length); - const close = source.indexOf("\n};", open); - if (open === -1 || close === -1) { - throw new Error(`${name} is not an object literal`); - } - // `close` is the newline before `};`, so the literal ends two characters on. - return `${source.slice(0, open)}${body}${source.slice(close + 2)}`; -}; - -/** Replace one exported array literal, by its `export const NAME` anchor. */ -const splice = (source: string, name: string, body: string): string => { - const anchor = `export const ${name}: readonly CatalogModelSpec[] = `; - const start = source.indexOf(anchor); - if (start === -1) { - throw new Error(`could not find ${name} in ${basename(TARGET.pathname)}`); - } - const open = source.indexOf("[", start + anchor.length); - const close = source.indexOf("\n];", open); - if (open === -1 || close === -1) { - throw new Error(`${name} is not an array literal`); - } - // `close` is the newline before `];`, so the literal ends two characters on. - return `${source.slice(0, open)}${body}${source.slice(close + 2)}`; -}; +const { from: fromPath, write } = generatorArgs(process.argv.slice(2)); -/** - * The plane-level export: the endpoint each plane's provider declares. - * - * The client consumes exactly this field (`models-dev.ts:107` lifts `item.api` - * into `settings.baseURL`), so the shim carries the same value and the mount - * reads it instead of hard-coding a family. - */ -const renderPlaneApi = ( - parsed: ReturnType<typeof parseModelsDevCatalog> -): string => - `{ - go: ${JSON.stringify(parsed.api.go)}, - zen: ${JSON.stringify(parsed.api.zen)}, -}`; - -/** - * The endpoint each wire family declares, per plane, keyed by its SDK package. - * - * The provider-level `api` is only the default family's address: anthropic and - * google are served at their own paths and the catalog records that per model. - * Emitting it keeps the mount reading DATA, so a vendor move is a regeneration. - */ -const renderFamilyApi = ( - parsed: ReturnType<typeof parseModelsDevCatalog> -): string => - `{ - go: ${JSON.stringify(parsed.familyApi.go)}, - zen: ${JSON.stringify(parsed.familyApi.zen)}, -}`; - -const payload = await readPayload(); -const parsed = parseModelsDevCatalog(JSON.parse(payload)); -const go = forProvider(parsed, "go"); -const zen = forProvider(parsed, "zen"); -const planeApi = renderPlaneApi(parsed); -const familyApi = renderFamilyApi(parsed); +const payload = await readSource({ + from: fromPath, + timeoutMs: MODELS_DEV_TIMEOUT_MS, + url: MODELS_OPENCODE_URL, +}); +const next = shimData(parseModelsDevCatalog(JSON.parse(payload))); /** What the shim holds now, for the report. */ const report = ( @@ -203,46 +86,43 @@ const report = ( console.log( `source: ${fromPath ?? MODELS_OPENCODE_URL} (${payload.length} bytes${fromPath === undefined ? "" : ", local"})` ); -report("opencode-go", OPENCODE_GO_CATALOG, go); -report("opencode ", OPENCODE_ZEN_CATALOG, zen); - -/** - * Canonical form for comparison: object keys sorted at every depth. - * - * Needed because the parser builds `cost` in its own insertion order while the - * renderer emits sorted keys, so a plain `JSON.stringify` compares equal data as - * different and reports the shim stale forever. Order is the renderer's business, - * not the data's. - */ -const canonical = (value: unknown): string => { - if (Array.isArray(value)) { - return `[${value.map(canonical).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const record = value as Record<string, unknown>; - return `{${Object.keys(record) - .toSorted() - .map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`) - .join(",")}}`; - } - return JSON.stringify(value) ?? "null"; -}; - -const changed = - canonical(go) !== canonical(OPENCODE_GO_CATALOG) || - canonical(zen) !== canonical(OPENCODE_ZEN_CATALOG); - -if (!shouldWrite) { +report("opencode-go", OPENCODE_GO_CATALOG, next.go); +report("opencode ", OPENCODE_ZEN_CATALOG, next.zen); + +const decided = outcome({ + stale: shimStale( + { + familyApi: OPENCODE_FAMILY_API, + go: OPENCODE_GO_CATALOG, + planeApi: OPENCODE_PLANE_API, + zen: OPENCODE_ZEN_CATALOG, + }, + next + ), + write, +}); + +if (decided.action === "write") { + let source = readFileSync(TARGET, "utf8"); + source = splice(source, "OPENCODE_GO_CATALOG", render(next.go)); + source = splice(source, "OPENCODE_ZEN_CATALOG", render(next.zen)); + source = spliceObject( + source, + "OPENCODE_PLANE_API", + renderPlaneApi(next.planeApi) + ); + source = spliceObject( + source, + "OPENCODE_FAMILY_API", + renderFamilyApi(next.familyApi) + ); + writeFileSync(TARGET, source); + console.log(`\nwrote ${basename(TARGET.pathname)}`); +} else { console.log( - changed ? "\nshim is STALE — re-run with --write" : "\nshim is current" + decided.action === "report-stale" + ? "\nshim is STALE — re-run with --write" + : "\nshim is current" ); - process.exit(changed ? 1 : 0); } - -let source = readFileSync(TARGET, "utf8"); -source = splice(source, "OPENCODE_GO_CATALOG", render(go)); -source = splice(source, "OPENCODE_ZEN_CATALOG", render(zen)); -source = spliceObject(source, "OPENCODE_PLANE_API", planeApi); -source = spliceObject(source, "OPENCODE_FAMILY_API", familyApi); -writeFileSync(TARGET, source); -console.log(`\nwrote ${basename(TARGET.pathname)}`); +process.exitCode = decided.exit; diff --git a/scripts/registry-view.ts b/scripts/registry-view.ts new file mode 100644 index 00000000..664c96f4 --- /dev/null +++ b/scripts/registry-view.ts @@ -0,0 +1,86 @@ +/** + * The registry reads the nightly drift report and the bump proposal share: + * one abbreviated packument per role (`fetchRegistryView`), and the publish + * times the proposal needs to honour pnpm's release-age window + * (`fetchPublishTimes`). The rules that read them are pure + * (`scripts/host-drift.ts`, `scripts/host-bump.ts`). + * + * @module scripts/registry-view + */ +import { + type Packument, + REGISTRY_PACKAGES, + type RegistryRole, + type RegistryView, +} from "./host-drift.ts"; + +/** A registry base URL without its trailing slashes. */ +export const registryBase = (url: string): string => url.replace(/\/+$/u, ""); + +/** A package's document URL on `registry` (a scope's slash escaped). */ +const documentUrl = (registry: string, name: string): string => + `${registryBase(registry)}/${name.replace("/", "%2f")}`; + +/** + * One abbreviated packument per role, fetched together. + * + * @param registry - the registry's base URL. + * @returns the view the rules read. + * @throws {Error} naming the package and the status when any answer is not ok: a + * view with a hole in it is never handed to the rules. + */ +export const fetchRegistryView = async ( + registry: string +): Promise<RegistryView> => { + const packument = async (name: string): Promise<Packument> => { + const response = await fetch( + documentUrl(registry, name), + // The abbreviated form carries dist-tags and each version's dependency + // ranges, which is all the rules read. + { headers: { accept: "application/vnd.npm.install-v1+json" } } + ); + if (!response.ok) { + throw new Error(`the registry answered ${response.status} for ${name}`); + } + return (await response.json()) as Packument; + }; + const roles = Object.keys(REGISTRY_PACKAGES) as RegistryRole[]; + const answers = await Promise.all( + roles.map(async (role) => packument(REGISTRY_PACKAGES[role])) + ); + return Object.fromEntries( + roles.map((role, index) => [role, answers[index] ?? {}]) + ) as RegistryView; +}; + +/** + * When each of `versions` of `name` was published, from the full packument's + * `time` (the abbreviated one does not carry it). + * + * @param registry - the registry's base URL. + * @param name - the package. + * @param versions - the versions asked about. + * @returns epoch milliseconds per version; a version the registry gives no + * time for is absent, which the proposal reads as unread. + */ +export const fetchPublishTimes = async ( + registry: string, + name: string, + versions: readonly string[] +): Promise<Readonly<Record<string, number>>> => { + const response = await fetch(documentUrl(registry, name), { + headers: { accept: "application/json" }, + }); + if (!response.ok) { + throw new Error(`the registry answered ${response.status} for ${name}`); + } + const { time } = (await response.json()) as { + readonly time?: Readonly<Record<string, string>>; + }; + return Object.fromEntries( + versions.flatMap((version) => { + const at = Date.parse(time?.[version] ?? ""); + return Number.isNaN(at) ? [] : [[version, at]]; + }) + ); +}; diff --git a/scripts/report-host-drift.ts b/scripts/report-host-drift.ts index e412faf8..b5d3931e 100644 --- a/scripts/report-host-drift.ts +++ b/scripts/report-host-drift.ts @@ -7,51 +7,28 @@ * CI runs it nightly and on dispatch. It REPORTS and never gates: each drift is * one `::warning::` line and the exit code is 0, whatever the registry * answered, because a channel moving is the harness's release, not a - * regression of this repository. Turning the report into a proposed bump is - * P-T3's; a bump itself follows CONTRIBUTING's release step 3. + * regression of this repository. The bump itself is proposed by + * `scripts/propose-host-bump.ts`, the nightly step after this one, following + * CONTRIBUTING's release step 3. * * The rules are `scripts/host-drift.ts`; this half fetches one abbreviated * packument per package from the registry (`--registry`, default the public - * one) and prints what the rules answer. A fetch that fails is a warning too, - * so a run that read nothing never prints "current". + * one; `scripts/registry-view.ts`) and prints what the rules answer. A fetch + * that fails is a warning too, so a run that read nothing never prints + * "current". * * @module scripts/report-host-drift */ import { CHANNELS } from "../test/support/host-versions.ts"; -import { - hostDrift, - type Packument, - REGISTRY_PACKAGES, - type RegistryRole, - type RegistryView, -} from "./host-drift.ts"; +import { hostDrift } from "./host-drift.ts"; +import { fetchRegistryView } from "./registry-view.ts"; const flag = process.argv.indexOf("--registry"); -const registry = ( - flag === -1 ? "https://registry.npmjs.org" : (process.argv[flag + 1] ?? "") -).replace(/\/+$/u, ""); - -const packument = async (name: string): Promise<Packument> => { - const response = await fetch( - `${registry}/${name.replace("/", "%2f")}`, - // The abbreviated form carries dist-tags and each version's dependency - // ranges, which is all the rules read. - { headers: { accept: "application/vnd.npm.install-v1+json" } } - ); - if (!response.ok) { - throw new Error(`the registry answered ${response.status} for ${name}`); - } - return (await response.json()) as Packument; -}; +const registry = + flag === -1 ? "https://registry.npmjs.org" : (process.argv[flag + 1] ?? ""); try { - const roles = Object.keys(REGISTRY_PACKAGES) as RegistryRole[]; - const answers = await Promise.all( - roles.map(async (role) => packument(REGISTRY_PACKAGES[role])) - ); - const view = Object.fromEntries( - roles.map((role, index) => [role, answers[index] ?? {}]) - ) as RegistryView; + const view = await fetchRegistryView(registry); const lines = hostDrift(CHANNELS, view); for (const line of lines) { console.log(`::warning::${line}`); diff --git a/scripts/scoped-targets.ts b/scripts/scoped-targets.ts index f1639afa..9b4df23e 100644 --- a/scripts/scoped-targets.ts +++ b/scripts/scoped-targets.ts @@ -2,9 +2,9 @@ * The scoped aliases `scripts/publish-scoped.ts` publishes the package under. * * Release.yml's verify step polls the same names on its `pending="…"` line, and - * `scripts/check.ts` fails when the two lists disagree. The list lives apart - * from `publish-scoped.ts` because that script publishes at import time, so the - * check and its test could not read the list from there. + * `test/package-checks.test.ts` fails when the two lists disagree. The list + * lives apart from `publish-scoped.ts` because that script publishes at import + * time, so the test could not read the list from there. * * `@viztor/dsh-opencode` is DEPRECATED and still published: a thin wrapper that * re-exports the canonical package, kept so an existing install keeps resolving. diff --git a/scripts/session-band.ts b/scripts/session-band.ts new file mode 100644 index 00000000..64a7c8d8 --- /dev/null +++ b/scripts/session-band.ts @@ -0,0 +1,206 @@ +/** + * The pure half of `verify-session-band.ts`: the vendor's session-id + * construction, ported, and the four facts the script reports about the + * low-twelve-bit band `[0xfc0, 0xffe]`, each with the answer it must have. + * + * The script printed "CONFIRMED" whatever it measured, so a derivation that + * left the band would have been confirmed too. The facts are now data, and + * `bandMismatches` names each one that disagrees with its expectation; the + * script prints the verdict only when there is none + * (`test/generators.test.ts`). + * + * @module scripts/session-band + */ + +/** The direction the vendor mints an id in: sessions are `descending`. */ +export type Direction = "ascending" | "descending"; + +/** + * A faithful port of upstream `packages/opencode/src/id/id.ts`, both + * directions, with its per-millisecond counter held in a closure, so each + * minter starts where the vendor's process does. + * + * The bitwise shift/mask and the `BigInt(...)` calls are deliberate: this is a + * byte-for-byte port of the vendor's arithmetic, and the point of the script is + * that it reproduces THEIR output rather than a rewritten approximation. The + * lint rules that object are disabled for exactly this port and this reason. + * + * @returns `create(prefix, timestamp, direction)`, which mints one id. + */ +export const upstreamMinter = (): (( + prefix: string, + timestamp: number, + direction: Direction +) => string) => { + /* oxlint-disable eslint/no-bitwise, unicorn/prefer-bigint-literals -- port of the vendor's arithmetic */ + let lastTimestamp = 0; + let counter = 0; + return (prefix, timestamp, direction) => { + if (timestamp !== lastTimestamp) { + lastTimestamp = timestamp; + counter = 0; + } + counter += 1; + + const raw = BigInt(timestamp) * BigInt(0x1000) + BigInt(counter); + const now = direction === "descending" ? ~raw : raw; + const timeBytes = new Uint8Array(6); + for (let i = 0; i < 6; i += 1) { + timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)); + } + return `${prefix}_${[...timeBytes] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("")}`; + }; + /* oxlint-enable eslint/no-bitwise, unicorn/prefer-bigint-literals -- port ends here */ +}; + +/** The low twelve bits: the last three hex characters of the 12-hex time. */ +export const low12 = (id: string): number => + Number.parseInt(id.slice(4, 16), 16) % 0x1000; + +export const BAND_LO = 0xfc0; +export const BAND_HI = 0xffe; + +/** Whether a low-twelve value lands in the band the gateway admits. */ +export const inBand = (low: number): boolean => + low >= BAND_LO && low <= BAND_HI; + +/** One measured sample: every low-twelve value, and how many are in band. */ +export interface BandFact { + readonly inBand: number; + readonly lows: readonly number[]; + readonly total: number; +} + +/** The four facts the script reports. */ +export interface BandFacts { + /** One id per millisecond, ascending: the branch sessions do NOT use. */ + readonly ascending: BandFact; + /** One id per millisecond, descending: what a real client mints. */ + readonly descending: BandFact; + /** The plugin's own derivation, over distinct session ids. */ + readonly ours: BandFact; + /** Eighty ids inside ONE millisecond, descending: the counter climbs. */ + readonly withinOneMs: BandFact; +} + +/** The number of ids minted inside one millisecond. */ +const WITHIN_ONE_MS = 80; + +/** The first millisecond every upstream sample starts from. */ +const EPOCH = 1_750_000_000_000; + +const fact = (lows: readonly number[]): BandFact => ({ + inBand: lows.filter(inBand).length, + lows, + total: lows.length, +}); + +/** + * Measure the band: the vendor's construction in both directions, the + * counter climbing inside one millisecond, and the plugin's derivation. + * + * @param input.derive - the plugin's derivation, from a session id. + * @param input.samples - how many ids each upstream direction mints. + * @param input.oursSamples - how many session ids the plugin derives + * (`samples` when omitted). + * @returns the four facts. + */ +export const bandFacts = ({ + derive, + oursSamples, + samples, +}: { + readonly derive: (sessionId: string) => string; + readonly oursSamples?: number; + readonly samples: number; +}): BandFacts => { + const onePerMillisecond = (direction: Direction): number[] => { + const create = upstreamMinter(); + return Array.from({ length: samples }, (_, i) => + low12(create("ses", EPOCH + i, direction)) + ); + }; + const create = upstreamMinter(); + const within = Array.from({ length: WITHIN_ONE_MS }, () => + low12(create("ses", EPOCH, "descending")) + ); + const ours = Array.from({ length: oursSamples ?? samples }, (_, i) => + low12(derive(`band-check-${i}`)) + ); + return { + ascending: fact(onePerMillisecond("ascending")), + descending: fact(onePerMillisecond("descending")), + ours: fact(ours), + withinOneMs: fact(within), + }; +}; + +/** + * Each fact that disagrees with what the band's explanation predicts: + * ascending ids are never in it (their low bits are the counter, `0x001` + * first); descending ones always are (`0xFFF - counter`, `0xFFE` first); + * inside one millisecond exactly the first 63 of 80 fit; and every id the + * plugin derives is in it. + * + * @param facts - `bandFacts`' answer. + * @returns one sentence per mismatch; empty when the explanation holds. + */ +export const bandMismatches = (facts: BandFacts): string[] => { + const mismatches: string[] = []; + const { ascending, descending, ours, withinOneMs } = facts; + if (ascending.inBand !== 0) { + mismatches.push( + `upstream ascending: ${ascending.inBand} of ${ascending.total} in band, expected none` + ); + } + if (descending.inBand !== descending.total) { + mismatches.push( + `upstream descending: ${descending.inBand} of ${descending.total} in band, expected all` + ); + } + const fit = BAND_HI - BAND_LO + 1; + if (withinOneMs.inBand !== Math.min(fit, withinOneMs.total)) { + mismatches.push( + `upstream, one millisecond: ${withinOneMs.inBand} of ${withinOneMs.total} in band, expected the first ${fit}` + ); + } + if (ours.inBand !== ours.total) { + mismatches.push( + `this plugin's derivation: ${ours.inBand} of ${ours.total} in band, expected all` + ); + } + return mismatches; +}; + +/** + * The verdict the script prints, and its exit code: one `MISMATCH:` line per + * fact the explanation does not predict and exit 1, or the `CONFIRMED` + * paragraph and exit 0. Never both: a verdict that confirms after a mismatch + * is the "prints CONFIRMED regardless" defect this replaces. + * + * @param facts - `bandFacts`' answer. + * @returns the lines to print and the exit code. + */ +export const bandVerdict = ( + facts: BandFacts +): { readonly exit: 0 | 1; readonly lines: readonly string[] } => { + const mismatches = bandMismatches(facts); + if (mismatches.length > 0) { + return { + exit: 1, + lines: mismatches.map((mismatch) => `MISMATCH: ${mismatch}`), + }; + } + return { + exit: 0, + lines: [ + "CONFIRMED. The band is real, and it is a CONSEQUENCE of minting descending:", + "the vendor's own construction puts a session's low twelve bits at 0xFFF - counter,", + "which is inside [0xfc0, 0xffe] for any client minting fewer than 64 ids per", + "millisecond. This plugin mints ONE id per session, so its counter is 1 and its", + "low twelve bits are 0xFFE. No clamp is involved anywhere.", + ], + }; +}; diff --git a/scripts/vendor-version.ts b/scripts/vendor-version.ts index 18207669..8e224815 100644 --- a/scripts/vendor-version.ts +++ b/scripts/vendor-version.ts @@ -50,14 +50,28 @@ export const scansFile = (path: string): boolean => /** The constant every other copy must agree with, read from `src/identity.ts`. */ export const PINNED_VERSION = /OPENCODE_CLI_VERSION\s*=\s*"(\d+\.\d+\.\d+)"/; -/** A literal that names the CLI: `opencode/1.19.5`. */ +/** + * A literal that names the CLI in the 1.x User-Agent form: `opencode/1.19.5`, + * whatever follows it (`opencode/1.19.5/cli` is still a 1.x copy). The 2.x + * form never matches: its first segment is a channel name, never a digit. + */ export const CLI_LITERAL = /\bopencode\/(\d+\.\d+\.\d+)\b/g; +/** + * A literal in the 2.x User-Agent form, `opencode/<channel>/<version>/<client>`: + * a channel (`latest`, `dev`, `next`, ...), a semver version with an optional + * prerelease, and the client that sent it (`cli`, ...). Captures the three + * parts. A placeholder such as `opencode/<channel>/<version>/<client>` is + * not one: every part must be a value. + */ +export const CLI_GENERATION_LITERAL = + /\bopencode\/([a-z][a-z0-9-]*)\/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\/([a-z][\w-]*)\b/g; + /** * A bare (or `v`-prefixed) literal of the pinned SERIES: `1.19.5`, `v1.19.5`. * - * The second form the scan needs. The `(?<!opencode/)` keeps it from matching - * the same token twice, which would report every prefixed copy as two copies. + * The second form the scan needs. The lookbehinds keep it from matching a + * token the prefixed scans own, which would report every prefixed copy twice. * * @param pinned - the version the constant declares; its first two fields are * the series this pattern accepts. @@ -65,7 +79,12 @@ export const CLI_LITERAL = /\bopencode\/(\d+\.\d+\.\d+)\b/g; */ export const bareLiteral = (pinned: string): RegExp => { const series = pinned.split(".").slice(0, 2).join("\\."); - return new RegExp(String.raw`(?<!opencode/)\bv?(${series}\.\d+)\b`, "g"); + // Neither the 1.x form (`opencode/<version>`) nor the version inside the + // 2.x one (`opencode/<channel>/<version>`): each scan owns its own form. + return new RegExp( + String.raw`(?<!opencode/)(?<!opencode/[a-z][a-z0-9-]*/)\bv?(${series}\.\d+)\b`, + "g" + ); }; /** @@ -100,3 +119,100 @@ export const disagreements = ( .filter(([version]) => version !== pinned) .map(([version, files]) => ({ files, version })) .toSorted((left, right) => left.version.localeCompare(right.version)); + +/** + * One message per literal in the 2.x User-Agent form. + * + * No constant pins that generation: `OPENCODE_CLI_VERSION` is a 1.x version + * and the plugin sends the 1.x form. A 2.x literal in the tree is therefore a + * claim about what the CLI sends that nothing checks, the kind of copy this + * gate exists to keep from drifting. The client-generation table that would + * pin it is P7's (L6); until then such a literal is a placeholder, never a + * value. + * + * @param found - each 2.x-form literal, with the files that say it. + * @returns the messages, one per literal. + */ +export const unpinnedGenerationClaims = ( + found: ReadonlyMap<string, readonly string[]> +): string[] => + [...found].map( + ([literal, files]) => + `${literal} in ${files.join(", ")}: no constant pins the 2.x User-Agent form, so it is a claim nothing checks (write opencode/<channel>/<version>/<client>)` + ); + +/** What the registry publishes of the CLI: the stable line and the snapshots. */ +export interface PublishedCli { + /** + * Each tag carrying a `0.0.0-<tag>-<stamp>` snapshot of its own name. Most + * are feature-branch builds from the 1.x years (`snapshot-pnpm`, `ci`), and + * only some (`dev`, `next`, `beta`) carry the 2.x line, so nothing about a + * snapshot's generation or User-Agent format is read from its tag. + */ + readonly snapshots: Readonly<Record<string, string>>; + /** `latest`, the stable release. */ + readonly stable: string; +} + +/** + * Read `opencode-ai`'s dist-tags: `latest` as the stable release, and every + * tag whose value is a snapshot of its own name (`dev` carries + * `0.0.0-dev-<stamp>`). The 2.x line publishes that way, and so did 1.x's + * feature branches, so the snapshots are listed and never classified: the + * only generation claim is `pinVerdict`'s, on `latest`. + * + * @param distTags - the registry's `/-/package/opencode-ai/dist-tags` answer. + * @returns the reading, or `undefined` when there is no plain `x.y.z` + * `latest`: a registry that said nothing usable is never read as current. + */ +export const publishedCli = (distTags: unknown): PublishedCli | undefined => { + if (distTags === null || typeof distTags !== "object") { + return undefined; + } + const tags = distTags as Record<string, unknown>; + const { latest } = tags; + if (typeof latest !== "string" || !/^\d+\.\d+\.\d+$/u.test(latest)) { + return undefined; + } + // String operations, never a pattern built from a tag: a tag is the + // registry's data, and `c++` or `a.b` must neither throw nor match loosely. + const isSnapshotOf = (tag: string, value: string): boolean => { + const prefix = `0.0.0-${tag}-`; + return ( + value.startsWith(prefix) && /^\d+$/u.test(value.slice(prefix.length)) + ); + }; + const snapshots = Object.fromEntries( + Object.entries(tags).filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && isSnapshotOf(entry[0], entry[1]) + ) + ); + return { snapshots, stable: latest }; +}; + +/** + * The pin against what is published. + * + * @param pinned - `OPENCODE_CLI_VERSION`. + * @param published - `publishedCli`'s reading. + * @param files - the files that carry a copy, for the bump's message. + * @returns `undefined` when the pin is `latest`; the bump when `latest` is + * another version on the pinned major; and a generation change when + * `latest` left the pinned major, because the User-Agent format moved with + * it and a version bump alone would claim a format the plugin does not send. + */ +export const pinVerdict = ( + pinned: string, + published: PublishedCli, + files: readonly string[] = [] +): string | undefined => { + const { stable } = published; + if (stable === pinned) { + return undefined; + } + if (stable.split(".")[0] !== pinned.split(".")[0]) { + return `pinned opencode/${pinned}, and latest is ${stable}, which left the pinned major: that is a generation change, not a bump: the User-Agent format differs (opencode/<channel>/<version>/<client>), and the plugin claims the 1.x one`; + } + return `pinned opencode/${pinned}, published opencode/${stable} - bump OPENCODE_CLI_VERSION, and the ${files.length > 0 ? files.join(", ") : "docs"}`; +}; diff --git a/scripts/verify-catalog.ts b/scripts/verify-catalog.ts index d62b31b0..443389c6 100644 --- a/scripts/verify-catalog.ts +++ b/scripts/verify-catalog.ts @@ -9,7 +9,10 @@ * * Checks, in order: * - * 1. **Shim freshness** — the bundled `catalog-data.ts` must match the live registry. + * 1. **Shim freshness** — the bundled `catalog-data.ts` must match the live + * registry, by the rule the generator itself uses (`shimStale`: every export, + * compared as data). A registry that could not be read FAILS: it is not the + * same as current. `--offline` skips this check out loud. * 2. **Dead rows** — no offered model may answer `400 Model is unavailable` or * `410 ModelDeprecated` (needs `--probe`; otherwise skipped). * 3. **The declared list** — the composer's source (`planeProfileFor`) publishes @@ -24,17 +27,29 @@ * ```sh * node --experimental-strip-types scripts/verify-catalog.ts * node --experimental-strip-types scripts/verify-catalog.ts --probe + * node --experimental-strip-types scripts/verify-catalog.ts --from <payload> + * node --experimental-strip-types scripts/verify-catalog.ts --offline * ``` * + * `--from` reads the registry from a local copy; `--offline` skips check 1. + * Either way nothing else is fetched: checks 3-5 read the shipped shim, and + * its background refresh is handed a fetch that refuses. + * * Exit code is 1 when any check fails, so it can gate a release. * * @module scripts/verify-catalog */ -import { OPENCODE_FAMILY_API } from "../src/catalog-data.ts"; import { - getLiveGoCatalog, - getLiveZenCatalog, + OPENCODE_FAMILY_API, + OPENCODE_GO_CATALOG, + OPENCODE_PLANE_API, + OPENCODE_ZEN_CATALOG, +} from "../src/catalog-data.ts"; +import { + getLiveGoCatalog as liveGoCatalog, + getLiveZenCatalog as liveZenCatalog, + MODELS_DEV_TIMEOUT_MS, MODELS_OPENCODE_URL, parseModelsDevCatalog, } from "../src/models-catalog.ts"; @@ -43,8 +58,27 @@ import { isCoreToolModel } from "../src/tool-fallback.ts"; import { planeModelFor } from "../src/utils/plane-model.ts"; import { planeProfileFor } from "../src/utils/plane-profile.ts"; import { isServableSdk, protocolForSdk } from "../src/utils/protocols.ts"; +import { shimData, shimStale } from "./catalog-shim.ts"; +import { generatorArgs, readSource } from "./generator-kit.ts"; const PROBE = process.argv.includes("--probe"); +const OFFLINE = process.argv.includes("--offline"); +const { from } = generatorArgs(process.argv.slice(2)); + +/** + * The live catalogs, as the plugin reads them. Each read may start a + * background refresh; on a run told to stay off the network (`--offline`, or + * a local `--from`) that refresh is handed a fetch that refuses, so the shim is + * what every check reads and nothing leaves the machine. + */ +const catalogFetch: typeof fetch = + OFFLINE || from !== undefined + ? async () => { + throw new Error("verify-catalog: no network on this run"); + } + : globalThis.fetch; +const getLiveGoCatalog = () => liveGoCatalog(catalogFetch); +const getLiveZenCatalog = () => liveZenCatalog(catalogFetch); const failures: string[] = []; const fail = (message: string): void => { @@ -58,34 +92,42 @@ const ok = (message: string): void => { // ── 1. Shim freshness ─────────────────────────────────────────────────────── console.log("\n1. shim freshness"); -try { - const response = await fetch(MODELS_OPENCODE_URL, { - signal: AbortSignal.timeout(30_000), - }); - const live = parseModelsDevCatalog(await response.json()); - for (const [plane, shipped, fresh] of [ - ["zen", getLiveZenCatalog(), live.zen], - ["go", getLiveGoCatalog(), live.go], - ] as const) { - const shippedIds = new Set(shipped.map((m) => m.id)); - // The shim and the live parse come from the same parser over the same - // registry, so the comparison is raw against raw: a difference is - // staleness, full stop. What the registry should have flagged but did - // not is the probe's business, not this check's. - const freshIds = new Set(fresh.map((m) => m.id)); - const missing = [...freshIds].filter((id) => !shippedIds.has(id)); - const extra = [...shippedIds].filter((id) => !freshIds.has(id)); - if (missing.length > 0 || extra.length > 0) { +if (OFFLINE) { + console.log(" skip registry not asked (--offline)"); +} else { + let payload: string | undefined; + try { + payload = await readSource({ + from, + timeoutMs: MODELS_DEV_TIMEOUT_MS, + url: MODELS_OPENCODE_URL, + }); + } catch (error) { + fail( + `registry not read, which is not the same as current (${String(error).slice(0, 80)}); pass --offline to skip this check` + ); + } + if (payload !== undefined) { + // The generator's own rule, over everything the shim exports: a + // difference in any catalog row, plane endpoint or family endpoint is + // staleness, and order is not. + const live = shimData(parseModelsDevCatalog(JSON.parse(payload))); + const shipped = { + familyApi: OPENCODE_FAMILY_API, + go: OPENCODE_GO_CATALOG, + planeApi: OPENCODE_PLANE_API, + zen: OPENCODE_ZEN_CATALOG, + }; + if (shimStale(shipped, live)) { fail( - `${plane} shim is stale: ${missing.length} missing, ${extra.length} extra ` + - `(run scripts/regenerate-catalog-shim.ts --write)` + `shim is stale against ${from ?? MODELS_OPENCODE_URL} (run scripts/regenerate-catalog-shim.ts --write)` ); } else { - ok(`${plane}: ${shippedIds.size} models match the registry`); + ok( + `zen: ${shipped.zen.length} and go: ${shipped.go.length} models, and both planes' endpoints, match the registry` + ); } } -} catch (error) { - console.log(` skip registry unreachable (${String(error).slice(0, 60)})`); } // ── 2. Dead rows ──────────────────────────────────────────────────────────── diff --git a/scripts/verify-session-band.ts b/scripts/verify-session-band.ts index 54ee0ac6..988f8e1c 100644 --- a/scripts/verify-session-band.ts +++ b/scripts/verify-session-band.ts @@ -30,77 +30,20 @@ * * Run: node --experimental-strip-types scripts/verify-session-band.ts * + * It prints "CONFIRMED" only when every measured fact is the one the + * explanation predicts (`bandVerdict` in `session-band.ts`, the pure half); + * otherwise it prints one `MISMATCH:` line per fact and exits 1. + * * @module scripts/verify-session-band */ import { openCodeSessionIdFor } from "../src/session.ts"; +import { type BandFact, bandFacts, bandVerdict } from "./session-band.ts"; -/** - * Faithful port of upstream `packages/opencode/src/id/id.ts`, both directions. - * - * The bitwise shift/mask and the `BigInt(...)` calls are deliberate: this is a - * byte-for-byte port of the vendor's arithmetic, and the point of the script is - * that it reproduces THEIR output rather than a rewritten approximation. The - * lint rules that object are disabled for exactly this file and this reason. - */ -/* oxlint-disable eslint/no-bitwise, unicorn/prefer-bigint-literals -- port of the vendor's arithmetic */ -let lastTimestamp = 0; -let counter = 0; - -const upstreamCreate = ( - prefix: string, - timestamp: number, - direction: "ascending" | "descending" -): string => { - if (timestamp !== lastTimestamp) { - lastTimestamp = timestamp; - counter = 0; - } - counter += 1; - - const raw = BigInt(timestamp) * BigInt(0x1000) + BigInt(counter); - const now = direction === "descending" ? ~raw : raw; - const timeBytes = Buffer.alloc(6); - for (let i = 0; i < 6; i += 1) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)); - } - return `${prefix}_${timeBytes.toString("hex")}`; -}; -/* oxlint-enable eslint/no-bitwise, unicorn/prefer-bigint-literals -- port ends here */ - -/** The low twelve bits: the last three hex characters of the 12-hex time. */ -const low12 = (id: string): number => - Number.parseInt(id.slice(4, 16), 16) % 0x1000; - -const BAND_LO = 0xfc0; -const BAND_HI = 0xffe; -const inBand = (low: number): boolean => low >= BAND_LO && low <= BAND_HI; - -/** One id per millisecond, which is what a real client mints. */ -const onePerMillisecond = (direction: "ascending" | "descending"): number[] => { - const lows: number[] = []; - for (let i = 0; i < 200_000; i += 1) { - lows.push(low12(upstreamCreate("ses", 1_750_000_000_000 + i, direction))); - } - return lows; -}; - -/** Several ids inside ONE millisecond: `counter` climbs, so the residue moves. */ -const withinOneMillisecond = (): number[] => { - lastTimestamp = 0; - counter = 0; - const lows: number[] = []; - for (let i = 0; i < 80; i += 1) { - lows.push(low12(upstreamCreate("ses", 1_750_000_000_000, "descending"))); - } - return lows; -}; - -const report = (label: string, lows: readonly number[]): void => { - const banded = lows.filter(inBand).length; +const report = (label: string, { inBand, lows, total }: BandFact): void => { const distinct = [...new Set(lows)].toSorted((a, b) => a - b); console.log(`\n── ${label} ──`); - console.log(`samples : ${lows.length}`); + console.log(`samples : ${total}`); console.log( `distinct low-12 : ${distinct .slice(0, 6) @@ -110,45 +53,40 @@ const report = (label: string, lows: readonly number[]): void => { }` ); console.log( - `in band [fc0,ffe] : ${banded} (${((banded / lows.length) * 100).toFixed(2)}%)` + `in band [fc0,ffe] : ${inBand} (${((inBand / total) * 100).toFixed(2)}%)` ); }; +const facts = bandFacts({ + derive: (sessionId) => openCodeSessionIdFor(sessionId), + oursSamples: 50_000, + samples: 200_000, +}); + report( "upstream, ASCENDING — the branch the first version of this script modelled", - onePerMillisecond("ascending") + facts.ascending ); report( "upstream, DESCENDING — the branch sessions actually use", - onePerMillisecond("descending") + facts.descending ); -const within = withinOneMillisecond(); +const within = facts.withinOneMs; console.log("\n── upstream, descending, 80 ids inside ONE millisecond ──"); console.log( - `low-12 runs 0x${Math.max(...within).toString(16)} → 0x${Math.min(...within).toString(16)}` + `low-12 runs 0x${Math.max(...within.lows).toString(16)} → 0x${Math.min(...within.lows).toString(16)}` ); console.log( - `in band : ${within.filter(inBand).length} of ${within.length} — the <64 that fit` + `in band : ${within.inBand} of ${within.total} — the <64 that fit` ); -const ours: number[] = []; -for (let i = 0; i < 50_000; i += 1) { - ours.push(low12(openCodeSessionIdFor(`band-check-${i}`))); -} -report("this plugin's derivation", ours); +report("this plugin's derivation", facts.ours); console.log("\n── verdict ──"); -console.log( - "CONFIRMED. The band is real, and it is a CONSEQUENCE of minting descending:" -); -console.log( - "the vendor's own construction puts a session's low twelve bits at 0xFFF - counter," -); -console.log( - "which is inside [0xfc0, 0xffe] for any client minting fewer than 64 ids per" -); -console.log( - "millisecond. This plugin mints ONE id per session, so its counter is 1 and its" -); -console.log("low twelve bits are 0xFFE. No clamp is involved anywhere."); +// The verdict is the pure half's (`bandVerdict`): the script only prints it. +const verdict = bandVerdict(facts); +for (const line of verdict.lines) { + console.log(line); +} +process.exitCode = verdict.exit; diff --git a/test/architecture.test.ts b/test/architecture.test.ts index 176b86d6..1bd61c07 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -8,8 +8,9 @@ * * So the membership test is a property: **a utility is a function of its * arguments.** No context, no globals, no I/O. This test is what makes that true - * rather than aspirational — it fails the build, the way `scripts/check.ts` - * already fails it for a client module reaching host-only code. + * rather than aspirational — it fails the build, the way this file's "the + * client bundle reaches nothing host-only" block already fails it for a + * client module reaching host-only code. * * @module test/architecture.test */ diff --git a/test/ci-wiring.test.ts b/test/ci-wiring.test.ts new file mode 100644 index 00000000..44338d11 --- /dev/null +++ b/test/ci-wiring.test.ts @@ -0,0 +1,694 @@ +/** + * The CI and release workflows, held to the rules a run depends on. + * + * Actions cannot run here, so every rule reads the workflows PARSED + * (`test/support/workflows.ts`): a step's `if:` is evaluated for the event and + * the matrix cell, and a `run:` counts only the commands it invokes. Most + * rules are functions shown to bite on an inline fixture whose answer is + * known before they are trusted with the real files: one install path for + * every job, a build before anything that reads `lib/`, a pnpm version no + * comment contradicts, Dependabot over every action directory, the catalog + * job's fork rule and every job bounded. The setup action's Node choice is + * executed. The rest are pins on the real files, with no fixture: one unit + * run per install (evaluated for every event the workflow runs on), the + * type-check (the check job's command, and the tsx tests in the program + * `tsc` reads), and the release and nightly wiring `scripts/check.ts` used to + * hold. + * + * @module test/ci-wiring.test + */ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + execute, + type Job, + loadWorkflow, + loadYaml, + matrixCells, + runCommands, + type Step, + stepRuns, + stepsRunning, + type Workflow, + workflowFiles, +} from "./support/workflows.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const SETUP = "./.github/actions/setup"; + +const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + devDependencies: Record<string, string | undefined>; + packageManager: string; + scripts: Record<string, string | undefined>; +}; + +const ci = loadWorkflow("ci.yml"); +const release = loadWorkflow("release.yml"); +const WORKFLOWS = { "ci.yml": ci, "release.yml": release } as const; + +/** Every job of the given workflows, labelled `<file>: <job>`. */ +const jobsOf = ( + workflows: Readonly<Record<string, Workflow>> +): [string, Job][] => + Object.entries(workflows).flatMap(([file, workflow]) => + Object.entries(workflow.jobs).map( + ([name, job]) => [`${file}: ${name}`, job] as [string, Job] + ) + ); + +/** A step that runs the repository's toolchain: it needs the install. */ +const needsInstall = (step: Step): boolean => + runCommands(step).some( + (line) => + line.startsWith("pnpm ") || + line.startsWith("node --experimental-strip-types") + ); + +/** + * Where a job installs by hand, or runs the toolchain without the one setup + * action after its own checkout. One message per finding. + */ +const installProblems = ( + workflows: Readonly<Record<string, Workflow>> +): string[] => + jobsOf(workflows).flatMap(([label, job]) => { + const steps = job.steps ?? []; + const problems: string[] = []; + for (const step of steps) { + if ( + step.uses?.startsWith("pnpm/action-setup@") === true || + step.uses?.startsWith("actions/setup-node@") === true + ) { + problems.push(`${label} sets up ${step.uses.split("@")[0]} itself`); + } + if (runCommands(step).some((line) => line.includes("pnpm install"))) { + problems.push(`${label} runs pnpm install itself`); + } + } + const first = steps.findIndex(needsInstall); + if (first === -1) { + return problems; + } + const checkout = steps.findIndex( + (step) => step.uses?.startsWith("actions/checkout@") === true + ); + const setup = steps.findIndex((step) => step.uses === SETUP); + if (setup === -1) { + problems.push(`${label} runs the toolchain without ${SETUP}`); + } else if (checkout === -1 || setup < checkout) { + problems.push(`${label} uses ${SETUP} before its own checkout`); + } else if (setup > first) { + problems.push(`${label} runs the toolchain before ${SETUP}`); + } + return problems; + }); + +/** The jobs that run the toolchain, labelled as {@link jobsOf} does. */ +const installingJobs = ( + workflows: Readonly<Record<string, Workflow>> +): string[] => + jobsOf(workflows) + .filter(([, job]) => (job.steps ?? []).some(needsInstall)) + .map(([label]) => label); + +/** The commands that read `lib/`: the unit suite, the binary e2e, the gate. */ +const LIB_READERS: readonly (RegExp | string)[] = [ + "pnpm run test", + "pnpm run test:coverage", + "pnpm run test:shuffle", + "pnpm run test:e2e:host", + /\bscripts\/check\.ts\b/u, +]; + +/** Each step that reads `lib/` with no `pnpm run build` before it in its job. */ +const unbuiltReaders = ( + workflows: Readonly<Record<string, Workflow>> +): string[] => + jobsOf(workflows).flatMap(([label, job]) => { + const builds = stepsRunning(job, "pnpm run build").map( + ({ index }) => index + ); + const built = builds.length === 0 ? Infinity : Math.min(...builds); + return LIB_READERS.flatMap((command) => + stepsRunning(job, command) + .filter(({ index }) => index < built) + .map(({ step }) => `${label}: ${runCommands(step).join("; ")}`) + ); + }); + +/** Every job without `timeout-minutes`, which would burn GitHub's six hours. */ +const unboundedJobs = ( + workflows: Readonly<Record<string, Workflow>> +): string[] => + jobsOf(workflows) + .filter(([, job]) => typeof job["timeout-minutes"] !== "number") + .map(([label]) => label); + +/** A pnpm version a `#` comment line names that `packageManager` does not pin. */ +const stalePnpmComments = ( + files: Readonly<Record<string, string>>, + pinned: string +): string[] => + Object.entries(files).flatMap(([file, text]) => + text + .split("\n") + .map((line) => line.trim()) + .filter( + (line) => line.startsWith("#") && /pnpm|packageManager/u.test(line) + ) + .flatMap((line) => + [...line.matchAll(/\b\d+\.\d+\.\d+\b/gu)] + .map((match) => match[0]) + .filter((version) => version !== pinned) + .map((version) => `${file}: ${version} in "${line}"`) + ) + ); + +describe("ci wiring · every file parses", () => { + it("every YAML file under .github parses, and the setup action is a composite whose run steps name a shell", () => { + const files = workflowFiles(); + // Known answers: the two workflows this file is about are among them. + expect(files).toContain(".github/workflows/ci.yml"); + expect(files).toContain(".github/workflows/release.yml"); + for (const file of files) { + expect(() => loadYaml(file), file).not.toThrow(); + } + const action = loadYaml(".github/actions/setup/action.yml") as { + runs: { steps: Step[]; using: string }; + }; + expect(action.runs.using).toBe("composite"); + const { steps } = action.runs; + for (const step of steps.filter((s) => s.run !== undefined)) { + expect(step.shell, step.name ?? step.run).toBe("bash"); + } + const uses = (prefix: string): number => + steps.filter((step) => step.uses?.startsWith(prefix) === true).length; + expect(uses("pnpm/action-setup@")).toBe(1); + expect(uses("actions/setup-node@")).toBe(1); + expect( + steps + .flatMap((step) => runCommands(step)) + .filter((line) => line === "pnpm install --frozen-lockfile") + ).toHaveLength(1); + // A local action is read from the workspace the job checked out, so it + // cannot bring the checkout itself. + expect(uses("actions/checkout@")).toBe(0); + + // In order: `cache: pnpm` needs pnpm on the PATH before setup-node runs, + // and setup-node takes the version the `node` step chose. + const at = (match: (step: Step) => boolean): number => + steps.findIndex(match); + const order = [ + at((step) => step.uses?.startsWith("pnpm/action-setup@") === true), + at((step) => step.id === "node"), + at((step) => step.uses?.startsWith("actions/setup-node@") === true), + at((step) => + runCommands(step).includes("pnpm install --frozen-lockfile") + ), + ]; + expect(order.every((index) => index !== -1)).toBe(true); + expect(order).toStrictEqual(order.toSorted((a, b) => a - b)); + const setupNode = steps.find( + (step) => step.uses?.startsWith("actions/setup-node@") === true + ); + expect(setupNode?.with?.["node-version"]).toBe( + ["$", "{{ steps.node.outputs.version }}"].join("") + ); + expect(setupNode?.with?.cache).toBe("pnpm"); + }); + + it("the setup action's node step picks .node-version, or the input when one is given", () => { + const action = loadYaml(".github/actions/setup/action.yml") as { + runs: { steps: Step[] }; + }; + const node = action.runs.steps.find((step) => step.id === "node"); + expect(node?.env?.NODE_VERSION_INPUT).toBe( + ["$", "{{ inputs.node-version }}"].join("") + ); + const pinned = readFileSync(join(ROOT, ".node-version"), "utf8").trim(); + // Known answer: the file names a version, and it is not the override. + expect(pinned).toMatch(/^\d+/u); + expect(pinned).not.toBe("24"); + for (const [input, expected] of [ + ["", pinned], + ["24", "24"], + ] as const) { + const ran = execute(node?.run ?? "", "", { NODE_VERSION_INPUT: input }); + expect(ran.status, ran.stderr).toBe(0); + expect(ran.output, input).toBe(`version=${expected}\n`); + } + }); +}); + +describe("ci wiring · one setup", () => { + it("every job that installs does it through the one setup action, after its own checkout", () => { + // The rule bites: a job that sets Node up itself is named. + const fixture: Workflow = { + jobs: { + bad: { + steps: [ + { uses: "actions/checkout@0" }, + { uses: "actions/setup-node@0" }, + { run: "pnpm install --frozen-lockfile" }, + { run: "pnpm run test" }, + ], + }, + good: { + steps: [ + { uses: "actions/checkout@0" }, + { uses: SETUP }, + { run: "pnpm run test" }, + ], + }, + early: { + steps: [ + { uses: SETUP }, + { uses: "actions/checkout@0" }, + { run: "pnpm run test" }, + ], + }, + }, + }; + expect(installProblems({ "x.yml": fixture })).toStrictEqual([ + "x.yml: bad sets up actions/setup-node itself", + "x.yml: bad runs pnpm install itself", + `x.yml: bad runs the toolchain without ${SETUP}`, + `x.yml: early uses ${SETUP} before its own checkout`, + ]); + + expect(installProblems(WORKFLOWS)).toStrictEqual([]); + expect(installingJobs(WORKFLOWS)).toStrictEqual([ + "ci.yml: check", + "ci.yml: test", + "ci.yml: host-drift", + "ci.yml: engine-floor", + "ci.yml: catalog", + "ci.yml: e2e", + "ci.yml: e2e-host", + "release.yml: verify", + "release.yml: publish", + ]); + }); +}); + +describe("ci wiring · one unit run per install", () => { + /** The steps of the `test` job that run in one cell. */ + const running = (names: Readonly<Record<string, string>>): Step[] => + (ci.jobs.test?.steps ?? []).filter((step) => stepRuns(step, names)); + const count = (steps: readonly Step[], command: string): number => + stepsRunning({ steps }, command).length; + + it("each install runs the unit suite once: the default install in the required check job with the coverage ratchet, every other cell in the matrix", () => { + const events = Object.keys(ci.on ?? {}); + // Known answer: the four events the workflow runs on. + expect(events.toSorted()).toStrictEqual([ + "pull_request", + "push", + "schedule", + "workflow_dispatch", + ]); + const check = ci.jobs.check ?? {}; + // A job-level `if:` would skip the run as a success on some event. + expect(check.if).toBeUndefined(); + // The default install's run: the check job, which sets no DSH_CHANNEL and + // switches nothing, so host-versions holds the root (alpha) install. + expect(check.env?.DSH_CHANNEL).toBeUndefined(); + expect(stepsRunning(check, /\bscripts\/use-channel\.ts\b/u)).toStrictEqual( + [] + ); + expect(stepsRunning(check, "pnpm run test:coverage")).toHaveLength(1); + expect(stepsRunning(check, "pnpm run test")).toStrictEqual([]); + expect( + (check.steps ?? []).filter((step) => step.with?.name === "coverage") + ).toHaveLength(1); + for (const event of events) { + const cells = matrixCells(event); + // Known answers: both channels on every event, the floor cells nightly + // and on dispatch. + const floors = event === "schedule" || event === "workflow_dispatch"; + expect(cells.length, event).toBe(floors ? 4 : 2); + for (const cell of cells) { + const label = `${event} ${cell.channel}·${cell.pi}`; + const steps = running(cell.names); + const isDefault = cell.channel === "alpha" && cell.pi === "newest"; + expect(count(steps, "pnpm run test:coverage"), label).toBe(0); + expect(count(steps, "pnpm run test"), label).toBe(isDefault ? 0 : 1); + expect(count(steps, "pnpm run test:mock"), label).toBe(1); + expect( + steps.filter((step) => step.with?.name === "coverage").length, + label + ).toBe(0); + } + } + for (const [name, job] of Object.entries(ci.jobs)) { + if (name === "test" || name === "check") continue; + expect(stepsRunning(job, "pnpm run test"), name).toStrictEqual([]); + expect(stepsRunning(job, "pnpm run test:coverage"), name).toStrictEqual( + [] + ); + } + }); +}); + +describe("ci wiring · the build before lib/", () => { + it("a step that reads lib/ comes after an explicit build in its own job", () => { + const fixture: Workflow = { + jobs: { + late: { + steps: [{ run: "pnpm run test" }, { run: "pnpm run build" }], + }, + none: { + steps: [{ run: "node --experimental-strip-types scripts/check.ts" }], + }, + ok: { + steps: [{ run: "pnpm run build" }, { run: "pnpm run test:shuffle" }], + }, + }, + }; + expect(unbuiltReaders({ "x.yml": fixture })).toStrictEqual([ + "x.yml: late: pnpm run test", + "x.yml: none: node --experimental-strip-types scripts/check.ts", + ]); + expect(unbuiltReaders(WORKFLOWS)).toStrictEqual([]); + }); +}); + +describe("ci wiring · the type-check", () => { + it("CI type-checks through the check job's `pnpm run check`, over a program that holds the tsx tests", () => { + // CI runs no `typecheck` script: `vp check` type-checks, while the lint + // config's `typeCheck` is on (test/toolchain.test.ts holds that). + expect(stepsRunning(ci.jobs.check ?? {}, "pnpm run check")).toHaveLength(1); + expect(pkg.scripts.check).toBe("vp check"); + // The program tsc reads, listed: the tsx tests are in it, not just in an + // include array. + const listed = spawnSync( + process.execPath, + [ + join(ROOT, "node_modules", "typescript", "bin", "tsc"), + "--listFilesOnly", + "-p", + join(ROOT, "tsconfig.json"), + ], + { cwd: ROOT, encoding: "utf8" } + ); + expect(listed.status, listed.stderr).toBe(0); + const files = listed.stdout.split("\n"); + for (const file of [ + "test/usage-panel.test.tsx", + "test/ci-wiring.test.ts", + ]) { + expect( + files.some((line) => line.endsWith(`/${file}`)), + file + ).toBe(true); + } + }); +}); + +describe("ci wiring · comments that name a version", () => { + it("no comment under .github names a pnpm version package.json does not pin", () => { + const pinned = pkg.packageManager.replace(/^pnpm@/u, ""); + // Known answer: the pin is a version, so the comparison has a side. + expect(pinned).toMatch(/^\d+\.\d+\.\d+$/u); + expect( + stalePnpmComments( + { "x.yml": " # package.json pins pnpm 1.0.0 exactly\n # pi 1.3.0\n" }, + pinned + ) + ).toStrictEqual([ + 'x.yml: 1.0.0 in "# package.json pins pnpm 1.0.0 exactly"', + ]); + const files = Object.fromEntries( + workflowFiles().map((file) => [ + file, + readFileSync(join(ROOT, file), "utf8"), + ]) + ); + expect(stalePnpmComments(files, pinned)).toStrictEqual([]); + }); +}); + +/** Dependabot's configuration, the slice the rule reads. */ +interface DependabotConfig { + readonly updates: readonly { + readonly directories?: readonly string[]; + readonly directory?: string; + readonly "package-ecosystem": string; + }[]; +} + +/** Each directory that pins an action (`/` and every composite) Dependabot does not watch. */ +const unwatchedActionDirs = ( + config: DependabotConfig, + files: readonly string[] +): string[] => { + const actions = config.updates.find( + (update) => update["package-ecosystem"] === "github-actions" + ); + const watched = new Set([ + ...(actions?.directories ?? []), + ...(actions?.directory === undefined ? [] : [actions.directory]), + ]); + const actionDirs = files + .filter((file) => /^\.github\/actions\/.+\/action\.ya?ml$/u.test(file)) + .map((file) => `/${dirname(file)}`); + return ["/", ...actionDirs].filter((dir) => !watched.has(dir)); +}; + +describe("ci wiring · dependabot", () => { + it("dependabot watches every directory that pins an action", () => { + // The rule bites: a config that watches only the root misses the action. + expect( + unwatchedActionDirs( + { + updates: [{ directory: "/", "package-ecosystem": "github-actions" }], + }, + [".github/actions/setup/action.yml", ".github/workflows/ci.yml"] + ) + ).toStrictEqual(["/.github/actions/setup"]); + + const config = loadYaml(".github/dependabot.yml") as DependabotConfig; + // Known answer: the setup action is among the files the rule reads. + expect(workflowFiles()).toContain(".github/actions/setup/action.yml"); + expect(unwatchedActionDirs(config, workflowFiles())).toStrictEqual([]); + expect( + config.updates.some((update) => update["package-ecosystem"] === "npm") + ).toBe(true); + }); +}); + +/** + * What keeps the catalog job from running on a fork's pull request, or lets + * a step other than the nightly one write. One message per finding. + */ +const catalogForkProblems = (job: Job | undefined): string[] => { + if (job === undefined) { + return ["no catalog job"]; + } + const problems: string[] = []; + if ((job.if ?? "").includes("head.repo")) { + problems.push(`the job's if reads the head repository: ${job.if ?? ""}`); + } + for (const step of job.steps ?? []) { + if ((step.if ?? "").includes("head.repo")) { + problems.push(`${step.name ?? "a step"} reads the head repository`); + } + const writes = + runCommands(step).some((line) => line.includes("git push")) || + Object.values(step.env ?? {}).some((value) => + String(value).includes("secrets.") + ); + if (writes && step.if !== "github.event_name == 'schedule'") { + problems.push(`${step.name ?? "a step"} can write outside the nightly`); + } + } + return problems; +}; + +describe("ci wiring · the catalog job", () => { + it("the catalog job runs for a fork, and only its nightly step can write", () => { + // The rule bites: the fork condition this job once carried, and a + // writing step that is not the nightly one. + expect( + catalogForkProblems({ + if: "github.event.pull_request.head.repo.full_name == github.repository", + steps: [ + { + env: { GH_TOKEN: "secrets.X" }, + name: "push", + run: "git push origin x", + }, + ], + }) + ).toStrictEqual([ + "the job's if reads the head repository: github.event.pull_request.head.repo.full_name == github.repository", + "push can write outside the nightly", + ]); + expect(catalogForkProblems(ci.jobs.catalog)).toStrictEqual([]); + // Known answer: the nightly step is a writer, so the rule had one to read. + expect( + (ci.jobs.catalog?.steps ?? []).some( + (step) => + step.if === "github.event_name == 'schedule'" && + runCommands(step).some((line) => line.includes("git push")) + ) + ).toBe(true); + }); +}); + +describe("ci wiring · the required check", () => { + it("the job with the name main's branch protection requires gates the unit suite and the ratchet", () => { + // Renaming it leaves the required check unreported, and every merge + // waits on a check that never comes. + const required = Object.entries(ci.jobs).filter( + ([, job]) => job.name === "check · test · build · gate" + ); + expect(required.map(([name]) => name)).toStrictEqual(["check"]); + // The required job is the only gate a merge waits on, so it is the one + // that runs the unit suite with the coverage ratchet, unconditionally: + // a run that lived only in the matrix could fail and still merge. + const [, job] = required[0] ?? ["", {}]; + const runs = stepsRunning(job, "pnpm run test:coverage"); + expect(runs).toHaveLength(1); + // Neither the step nor the job is conditional: GitHub reports a job its + // `if:` skipped as a success, which a required check does not block on. + expect(runs[0]?.step.if).toBeUndefined(); + expect((job as Job).if).toBeUndefined(); + for (const command of ["pnpm run check", "pnpm run build"]) { + expect(stepsRunning(job, command), command).toHaveLength(1); + } + expect(stepsRunning(job, /\bscripts\/check\.ts\b/u)).toHaveLength(1); + }); +}); + +describe("ci wiring · the release and CI wiring check.ts used to hold (moved)", () => { + const releaseText = readFileSync( + join(ROOT, ".github", "workflows", "release.yml"), + "utf8" + ); + const ciText = readFileSync( + join(ROOT, ".github", "workflows", "ci.yml"), + "utf8" + ); + const allRuns = (workflow: Workflow): string[] => + Object.values(workflow.jobs).flatMap((job) => + (job.steps ?? []).flatMap((step) => runCommands(step)) + ); + const ciRuns = (command: string): boolean => + Object.values(ci.jobs).some((job) => stepsRunning(job, command).length > 0); + + it("a version tag fires the release, and only the release", () => { + const releasePush = release.on?.push as { tags?: string[] } | undefined; + const ciPush = ci.on?.push as { "tags-ignore"?: string[] } | undefined; + expect(releasePush?.tags).toContain("v*.*.*"); + expect(ciPush?.["tags-ignore"]).toContain("v*.*.*"); + }); + + it("the publish authenticates by OIDC, skips a published version, and records what it packed", () => { + expect(release.jobs.publish?.permissions?.["id-token"]).toBe("write"); + expect( + (release.jobs.publish?.steps ?? []).some((step) => + /npm view .* version 2>\/dev\/null/u.test(step.run ?? "") + ) + ).toBe(true); + // Queued per tag, never cancelled: a re-run is how a release recovers. + expect(release.concurrency?.["cancel-in-progress"]).toBe(false); + expect(allRuns(release).some((line) => line.startsWith("npm pack"))).toBe( + true + ); + expect( + allRuns(release).some((line) => line.startsWith("shasum -a 256")) + ).toBe(true); + expect(releaseText).toContain("scripts/publish-scoped.ts"); + }); + + it("every job in every workflow is bounded", () => { + expect( + unboundedJobs({ + "x.yml": { jobs: { a: { "timeout-minutes": 5 }, b: {} } }, + }) + ).toStrictEqual(["x.yml: b"]); + const every = Object.fromEntries( + workflowFiles() + .filter((file) => file.startsWith(".github/workflows/")) + .map((file) => [ + file.slice(".github/workflows/".length), + loadWorkflow(file.slice(".github/workflows/".length)), + ]) + ); + // Known answer: the three workflows, release-please's among them. + expect(Object.keys(every)).toStrictEqual([ + "ci.yml", + "release-please.yml", + "release.yml", + ]); + expect(jobsOf(every).length).toBeGreaterThan(5); + expect(unboundedJobs(every)).toStrictEqual([]); + }); + + it("CI runs the generators, the live suite and the coverage ratchet, as commands", () => { + // A command NAMED in a step is not a command RUN by it: the failure + // message of a step quotes the command it ran. + expect( + runCommands({ + run: `echo "run 'pnpm run catalog:shim'"\n# pnpm run catalog:shim`, + }) + ).toStrictEqual([]); + expect( + stepsRunning( + { steps: [{ run: `echo "run 'pnpm run catalog:shim'"` }] }, + "pnpm run catalog:shim" + ) + ).toStrictEqual([]); + for (const command of [ + "pnpm run catalog:shim", + "pnpm run limits:shim", + "pnpm run test:e2e", + "pnpm run test:coverage", + ]) { + expect(ciRuns(command), command).toBe(true); + } + expect(pkg.scripts["catalog:shim"]).toContain( + "scripts/regenerate-catalog-shim.ts" + ); + expect(pkg.scripts["limits:shim"]).toContain( + "scripts/generate-go-limits.ts" + ); + expect(existsSync(join(ROOT, "scripts/regenerate-catalog-shim.ts"))).toBe( + true + ); + expect(existsSync(join(ROOT, "scripts/generate-go-limits.ts"))).toBe(true); + }); + + it("the nightly refresh pushes as the token's owner", () => { + const refresh = (ci.jobs.catalog?.steps ?? []).find( + (step) => step.name === "propose the refresh (nightly only)" + ); + expect(refresh?.run).toContain( + `x-access-token:\${GH_TOKEN}@github.com/\${GITHUB_REPOSITORY}.git` + ); + expect(ciText).toContain("x-access-token:"); + }); + + it("the coverage ratchet is anchored and its provider installed", () => { + const viteConfig = readFileSync(join(ROOT, "vite.config.ts"), "utf8"); + // Anchored: `_thresholds:` must not read as present. + expect(/^\s*thresholds\s*:/mu.test(" _thresholds: {")).toBe(false); + expect(/^\s*thresholds\s*:/mu.test(viteConfig)).toBe(true); + expect(typeof pkg.devDependencies["@vitest/coverage-v8"]).toBe("string"); + expect(typeof pkg.scripts["test:coverage"]).toBe("string"); + }); + + it("the unit run stays offline, and the live suite has its own config", () => { + const viteConfig = readFileSync(join(ROOT, "vite.config.ts"), "utf8"); + const unitInclude = /include:\s*\[([^\]]*)\]/u.exec(viteConfig)?.[1]; + expect(unitInclude).toBeDefined(); + expect(unitInclude).not.toContain("e2e"); + expect(pkg.scripts["test:e2e"]).toContain("vitest.e2e.config.ts"); + expect(existsSync(join(ROOT, "vitest.e2e.config.ts"))).toBe(true); + }); +}); diff --git a/test/freshness-scope.test.ts b/test/freshness-scope.test.ts index 64350b93..f772b361 100644 --- a/test/freshness-scope.test.ts +++ b/test/freshness-scope.test.ts @@ -42,6 +42,11 @@ import { type SourceReader, } from "../scripts/freshness-scope.ts"; import { GENERATED_TABLES } from "../scripts/vendor-version.ts"; +import { + evaluateIf, + execute, + inheritedWithoutGit, +} from "./support/workflows.ts"; const ROOT = join(import.meta.dirname, ".."); @@ -98,6 +103,10 @@ describe("freshness scope · the walk reads the real generators", () => { expect(inputs).toContain("scripts/regenerate-catalog-shim.ts"); expect(inputs).toContain("src/models-catalog.ts"); expect(inputs).toContain("src/utils/guards.ts"); + // The shared generator helpers and the shim's pure half: a change to the + // one staleness rule gates both generators. + expect(inputs).toContain("scripts/generator-kit.ts"); + expect(inputs).toContain("scripts/catalog-shim.ts"); // `src/config.ts` is loaded (`DEFAULT_GATEWAY_URLS`), so a settings change // gates this check; `src/session-cost.ts` is reached only through // `import type`, which loads nothing, so it does not. @@ -118,12 +127,15 @@ describe("freshness scope · the walk reads the real generators", () => { const inputs = inputsOf("limits:shim", read); expect(inputs).toContain("scripts/generate-go-limits.ts"); expect(inputs).toContain("src/go-limits.ts"); - // The generator's source holds `import type … from "./go-limits.ts"`, but + expect(inputs).toContain("scripts/generator-kit.ts"); + expect(inputs).toContain("scripts/go-limits-render.ts"); + expect(inputs).not.toContain("scripts/catalog-shim.ts"); + // The renderer's source holds `import type … from "./go-limits.ts"`, but // as template text: the line it writes into the file it emits, not an - // import of the generator. The premise is checked first, so the second + // import of the renderer. The premise is checked first, so the second // assertion has something to miss. (The line is also type-only; the // known-tree case below separates template text from that on its own.) - const source = read("scripts/generate-go-limits.ts") ?? ""; + const source = read("scripts/go-limits-render.ts") ?? ""; expect(source).toMatch( /^import type \{[^}]*\} from "\.\/go-limits\.ts";$/mu ); @@ -436,70 +448,6 @@ describe("freshness scope · the decision", () => { }); }); -/** A GitHub Actions `if:` expression, evaluated over the few names the job uses. */ -const evaluate = ( - expression: string, - names: Readonly<Record<string, string>> -): boolean | string => { - const tokens = - expression.match(/'[^']*'|==|!=|&&|\|\||[()!]|[A-Za-z_][\w.-]*/gu) ?? []; - const consumed = tokens.join(""); - if (consumed !== expression.replaceAll(/\s+/gu, "")) { - throw new Error(`unparsed text in: ${expression}`); - } - let at = 0; - const truthy = (value: boolean | string): boolean => - typeof value === "boolean" ? value : value !== ""; - const primary = (): boolean | string => { - const token = tokens[at]; - at += 1; - if (token === "(") { - const value = or(); - const closing = tokens[at]; - at += 1; - if (closing !== ")") throw new Error(`unbalanced: ${expression}`); - return value; - } - if (token === "!") return !truthy(primary()); - if (token?.startsWith("'")) return token.slice(1, -1); - if (token !== undefined && Object.hasOwn(names, token)) { - return names[token] ?? ""; - } - throw new Error(`unknown name ${token} in: ${expression}`); - }; - const comparison = (): boolean | string => { - const left = primary(); - const op = tokens[at]; - if (op === "==" || op === "!=") { - at += 1; - const right = primary(); - return op === "==" ? left === right : left !== right; - } - return left; - }; - const and = (): boolean | string => { - let value = comparison(); - while (tokens[at] === "&&") { - at += 1; - const right = comparison(); - value = truthy(value) && truthy(right); - } - return value; - }; - const or = (): boolean | string => { - let value = and(); - while (tokens[at] === "||") { - at += 1; - const right = and(); - value = truthy(value) || truthy(right); - } - return value; - }; - const result = or(); - if (at !== tokens.length) throw new Error(`trailing tokens: ${expression}`); - return truthy(result); -}; - describe("freshness scope · the CI wiring, parsed", () => { interface Step { readonly id?: string; @@ -543,7 +491,7 @@ describe("freshness scope · the CI wiring, parsed", () => { ): boolean => step.if === undefined ? true - : evaluate(step.if, { + : evaluateIf(step.if, { "github.event_name": event, ...Object.fromEntries( ["catalog", "limits", "cli"].map((key) => [ @@ -586,71 +534,6 @@ describe("freshness scope · the CI wiring, parsed", () => { expect(Object.keys(keys).toSorted()).toStrictEqual([...CHECKS].toSorted()); }); - /** - * The test process's environment without any `GIT_*` variable. A git hook, - * or `git rebase --exec` in a linked worktree, exports an absolute `GIT_DIR` - * and `GIT_INDEX_FILE`, which git honours before the working directory: a - * child that inherited them would commit, branch and configure the - * repository they name instead of the one it runs in. `GIT_CONFIG_PARAMETERS` - * goes too, since it carries `-c` settings such as signing. - */ - const inheritedWithoutGit = (): NodeJS.ProcessEnv => - Object.fromEntries( - Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")) - ); - - /** - * One step's `run` text, executed the way the runner executes it (`bash -e`, - * GitHub's default shell, which has no pipefail), in the repository, with a - * shell function named in `prelude` standing in for a command the step calls. - * It runs in the repository unless `cwd` names another directory, and - * inherits no `GIT_*` variable (`inheritedWithoutGit`). - */ - const execute = ( - run: string, - prelude: string, - env: Readonly<Record<string, string>> = {}, - cwd: string = ROOT - ): { - status: number | null; - stdout: string; - stderr: string; - output: string; - calls: string; - } => { - const temp = mkdtempSync(join(tmpdir(), "ci-step-")); - try { - const result = spawnSync("bash", ["-e", "-c", `${prelude}\n${run}`], { - cwd, - encoding: "utf8", - env: { - ...inheritedWithoutGit(), - ...env, - CALLS: join(temp, "calls"), - GITHUB_OUTPUT: join(temp, "github-output"), - PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: temp, - }, - }); - const readOr = (path: string): string => { - try { - return readFileSync(path, "utf8"); - } catch { - return ""; - } - }; - return { - calls: readOr(join(temp, "calls")), - output: readOr(join(temp, "github-output")), - status: result.status, - stderr: result.stderr, - stdout: result.stdout, - }; - } finally { - rmSync(temp, { force: true, recursive: true }); - } - }; - /** `pnpm` as a function that records its arguments and fails, naming a cause. */ const FAILING_PNPM = 'pnpm() { echo "$*" >> "$CALLS"; echo "error: boom" >&2; return 1; }'; @@ -1276,12 +1159,12 @@ describe("freshness scope · the CI wiring, parsed", () => { it("the evaluator answers the cases it is trusted with", () => { expect( - evaluate("a == 'x' && (b == 'y' || !c)", { a: "x", b: "z", c: "" }) + evaluateIf("a == 'x' && (b == 'y' || !c)", { a: "x", b: "z", c: "" }) ).toBe(true); - expect(evaluate("a != 'x'", { a: "x" })).toBe(false); - expect(() => evaluate("steps.scop.outputs.catalog == 'gate'", {})).toThrow( - /unknown name/u - ); + expect(evaluateIf("a != 'x'", { a: "x" })).toBe(false); + expect(() => + evaluateIf("steps.scop.outputs.catalog == 'gate'", {}) + ).toThrow(/unknown name/u); }); }); @@ -1308,13 +1191,14 @@ describe("cli:version · the offline copy scan (`--copies`)", () => { const runIn = ( pin: string, copy: string, - args: readonly string[] + args: readonly string[], + extra = "" ): { status: number | null; stdout: string; stderr: string } => { const root = mkdtempSync(join(tmpdir(), "cli-version-")); try { for (const [path, text] of Object.entries({ "README.md": "# fixture\n", - "docs/x.md": `User-Agent: opencode/${copy}\n`, + "docs/x.md": `User-Agent: opencode/${copy}\n${extra}`, [PIN_FILE]: `export const OPENCODE_CLI_VERSION = "${pin}";\n`, ...Object.fromEntries( GENERATED_OUTPUTS.map((output) => [ @@ -1373,6 +1257,29 @@ describe("cli:version · the offline copy scan (`--copies`)", () => { } }); + it("cli:version --copies fails on a 2.x-form User-Agent no constant pins, and still passes on this repository", () => { + // Assembled, never spelled: this file is in the real scan's path. + const ua = [ + "opencode", + "dev", + ["0.0.0", "dev", "202611092046"].join("-"), + "cli", + ].join("/"); + const run = runIn( + "0.0.1", + "0.0.1", + ["--copies"], + `The 2.x client says ${ua}.\n` + ); + expect(run.status).toBe(1); + const [first] = run.stderr.split("\n"); + expect(first).toMatch(/^error: /u); + expect(first).toContain("no constant pins"); + expect(run.stderr).toContain("docs/x.md"); + // The control: the same tree without the 2.x literal passes. + expect(runIn("0.0.1", "0.0.1", ["--copies"]).status).toBe(0); + }); + it("passes when the copies agree, without asking the registry, and skips the generated tables", () => { // 0.0.1 was never published, so a registry comparison would fail: the // pass proves the scan stopped before it. Both generated tables say a diff --git a/test/generators.test.ts b/test/generators.test.ts new file mode 100644 index 00000000..57b37e33 --- /dev/null +++ b/test/generators.test.ts @@ -0,0 +1,526 @@ +/** + * The generator scripts and the `verify-*` scripts, held to one staleness rule + * and to exit codes that mean something. + * + * - **One rule.** Both generators and `verify-catalog.ts` decide "stale" with + * `stale` from `scripts/generator-kit.ts`: canonical DATA differs. Key order + * and formatting never count, and every export a generator writes counts. + * The catalog generator used to compare only its two arrays, so a moved + * plane or family endpoint read as current; the limits generator compared + * bytes; `verify-catalog` compared id sets. + * - **Exit codes.** A `verify-*` script that cannot fail is decoration: + * `verify-session-band` printed "CONFIRMED" unconditionally, and + * `verify-catalog` answered an unreachable registry with a skip and exit 0. + * `verify-catalog` is spawned and made to fail; `verify-session-band`'s + * verdict is its pure half's (`bandVerdict`), made to fail on a planted + * derivation, and the script is spawned in its passing state. + * - **The generators run.** Each is spawned on a local source (`--from`, + * without `--write`): a source that disagrees with the shipped data exits + * 1, and the limits generator's known answer (a doc made from the shipped + * table) exits 0. The limits generator's `--write` runs too, from a scratch + * copy of `scripts/` and `src/` (its target is the `src/go-limits-data.ts` + * beside its own folder): it rewrites current data in text that is not + * the renderer's, and writes a moved figure. The limits renderer's text is held to the committed file + * by the same render-and-format round trip the catalog renderer has. + * + * Every spawned script is offline: each reads `--from` or runs `--offline`, + * since the suite's offline guard does not reach a child process. + * + * @module test/generators.test + */ +import { spawnSync } from "node:child_process"; +import { + cpSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + render, + shimData, + type ShimData, + shimStale, + splice, + spliceObject, + renderFamilyApi, + renderPlaneApi, +} from "../scripts/catalog-shim.ts"; +import { + canonical, + generatorArgs, + outcome, + stale, +} from "../scripts/generator-kit.ts"; +import { renderGoLimits } from "../scripts/go-limits-render.ts"; +import { + bandFacts, + bandMismatches, + bandVerdict, + inBand, + low12, +} from "../scripts/session-band.ts"; +import { payload } from "./support/catalogs.ts"; +import { + GO_MODEL_LIMITS, + OPENCODE_FAMILY_API, + OPENCODE_GO_CATALOG, + OPENCODE_PLANE_API, + OPENCODE_ZEN_CATALOG, + openCodeSessionIdFor, +} from "./support/src.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +/** What the shim ships, in the shape the rule compares. */ +const SHIPPED: ShimData = { + familyApi: OPENCODE_FAMILY_API, + go: OPENCODE_GO_CATALOG, + planeApi: OPENCODE_PLANE_API, + zen: OPENCODE_ZEN_CATALOG, +}; + +/** A script under `scripts/`, run to completion by the Node running the suite. */ +const runScript = ( + file: string, + args: readonly string[] +): { status: number | null; stderr: string; stdout: string } => { + const run = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(ROOT, "scripts", file), + ...args, + ], + { cwd: ROOT, encoding: "utf8", timeout: 60_000 } + ); + return { status: run.status, stderr: run.stderr, stdout: run.stdout }; +}; + +/** + * `text` as the nightly's formatter leaves it: written to a scratch file + * named `name`, `vp fmt --write` run on it, and read back. + */ +const formatted = (name: string, text: string): string => { + const temp = mkdtempSync(join(tmpdir(), "generated-")); + try { + const file = join(temp, name); + writeFileSync(file, text); + const fmt = spawnSync( + process.execPath, + [ + join(ROOT, "node_modules", "vite-plus", "bin", "vp"), + "fmt", + "--write", + file, + ], + { cwd: ROOT, encoding: "utf8" } + ); + expect(fmt.status, fmt.stderr).toBe(0); + return readFileSync(file, "utf8"); + } finally { + rmSync(temp, { force: true, recursive: true }); + } +}; + +/** + * The vendor's Go page, as the doc parser reads it, made from `rows`: one + * table per tier, each row's label the model's name and its last cell the + * tier's allowance (`$NaN` for a model with no figure, which parses back to + * `NaN`). + */ +const goLimitsDoc = ( + rows: Readonly<Record<string, { go: number; goPlus: number; name: string }>> +): string => + ( + [ + ["go", "Go"], + ["goPlus", "Go Plus"], + ] as const + ) + .map(([tier, label]) => + [ + `<TabItem label="${label}">`, + "", + "| Model | Input | Output | Cached | Write | Monthly limit |", + "| --- | --- | --- | --- | --- | --- |", + ...Object.values(rows).map( + (row) => + `| ${row.name} | $1 | $2 | $0.1 | - | $${String(row[tier])} |` + ), + "", + "</TabItem>", + ].join("\n") + ) + .join("\n\n"); + +/** The shipped table's first model, by id, and its row. */ +const firstLimit = (): [ + string, + { go: number; goPlus: number; name: string }, +] => { + const [entry] = Object.entries(GO_MODEL_LIMITS); + expect(entry).toBeDefined(); + return entry ?? ["", { go: 0, goPlus: 0, name: "" }]; +}; + +describe("generators · one staleness rule", () => { + it("one staleness rule: data, not order or bytes, and every generated export counts", () => { + expect(stale({ a: 1, b: [1, 2] }, { a: 1, b: [1, 2] })).toBe(false); + expect(stale({ a: 1, b: [1, 2] }, { b: [1, 2], a: 1 })).toBe(false); + expect(stale({ a: 1 }, { a: 2 })).toBe(true); + // A model the vendor's page gives no figure is NaN in the limits table. + expect(stale({ x: Number.NaN }, { x: Number.NaN })).toBe(false); + expect(canonical({ b: 1, a: { d: 2, c: 3 } })).toBe( + '{"a":{"c":3,"d":2},"b":1}' + ); + + expect(shimStale(SHIPPED, SHIPPED)).toBe(false); + // The two cases the old rule (go and zen arrays only) called current. + expect( + shimStale(SHIPPED, { + ...SHIPPED, + planeApi: { ...SHIPPED.planeApi, go: "https://example.invalid/go/v1" }, + }) + ).toBe(true); + const [family] = Object.keys(SHIPPED.familyApi.zen); + expect(family).toBeDefined(); + expect( + shimStale(SHIPPED, { + ...SHIPPED, + familyApi: { + ...SHIPPED.familyApi, + zen: { + ...SHIPPED.familyApi.zen, + [family ?? ""]: "https://example.invalid/v1", + }, + }, + }) + ).toBe(true); + // The renderer's order is not data. + expect(SHIPPED.go.length).toBeGreaterThan(1); + expect( + shimStale(SHIPPED, { ...SHIPPED, go: SHIPPED.go.toReversed() }) + ).toBe(false); + }); + + it("decides the exit and the action from staleness and --write, and refuses a --from with no path", () => { + expect(outcome({ stale: true, write: false })).toStrictEqual({ + action: "report-stale", + exit: 1, + }); + expect(outcome({ stale: false, write: true })).toStrictEqual({ + action: "current", + exit: 0, + }); + expect(outcome({ stale: false, write: false })).toStrictEqual({ + action: "current", + exit: 0, + }); + expect(outcome({ stale: true, write: true })).toStrictEqual({ + action: "write", + exit: 0, + }); + // A whole-file renderer's text: stale × write × text, every combination. + // Only current data, `--write` and text that is not the renderer's + // rewrites; staleness still decides everything else, whatever the text. + for (const isStale of [false, true]) { + for (const write of [false, true]) { + for (const textCurrent of [false, true]) { + const label = `stale=${String(isStale)} write=${String(write)} text=${String(textCurrent)}`; + const expected = + !isStale && write && !textCurrent + ? { action: "rewrite", exit: 0 } + : outcome({ stale: isStale, write }); + expect( + outcome({ stale: isStale, textCurrent, write }), + label + ).toStrictEqual(expected); + } + } + } + expect( + outcome({ stale: false, textCurrent: false, write: false }) + ).toStrictEqual({ action: "current", exit: 0 }); + expect( + outcome({ stale: false, textCurrent: false, write: true }) + ).toStrictEqual({ action: "rewrite", exit: 0 }); + expect( + outcome({ stale: true, textCurrent: false, write: true }) + ).toStrictEqual({ action: "write", exit: 0 }); + expect(generatorArgs([])).toStrictEqual({ write: false }); + expect(generatorArgs(["--write", "--from", "x.json"])).toStrictEqual({ + from: "x.json", + write: true, + }); + expect(() => generatorArgs(["--from"])).toThrow(/--from/u); + expect(() => generatorArgs(["--from", "--write"])).toThrow(/--from/u); + }); + + it("the catalog renderer reproduces the shipped shim from its own data, as the nightly formats it", () => { + // `--write` on the shipped data, then the formatter the nightly runs: + // byte-identical, so a current shim proposes no refresh. + const source = readFileSync(join(ROOT, "src", "catalog-data.ts"), "utf8"); + const data = shimData({ + api: OPENCODE_PLANE_API, + familyApi: OPENCODE_FAMILY_API, + go: OPENCODE_GO_CATALOG, + zen: OPENCODE_ZEN_CATALOG, + }); + let rewritten = splice(source, "OPENCODE_GO_CATALOG", render(data.go)); + rewritten = splice(rewritten, "OPENCODE_ZEN_CATALOG", render(data.zen)); + rewritten = spliceObject( + rewritten, + "OPENCODE_PLANE_API", + renderPlaneApi(data.planeApi) + ); + rewritten = spliceObject( + rewritten, + "OPENCODE_FAMILY_API", + renderFamilyApi(data.familyApi) + ); + // Known answer: the renderer's own text is not the formatted file, so the + // formatter below has work to do and the comparison is not vacuous. + expect(rewritten).not.toBe(source); + expect(formatted("catalog-data.ts", rewritten)).toBe(source); + expect(() => splice(source, "NO_SUCH_EXPORT", "[]")).toThrow( + /NO_SUCH_EXPORT/u + ); + }); + + it("the limits renderer reproduces the shipped table from its own data, and the formatter changes nothing", () => { + const source = readFileSync(join(ROOT, "src", "go-limits-data.ts"), "utf8"); + const rendered = renderGoLimits(GO_MODEL_LIMITS); + // Byte for byte BEFORE the formatter too: that is what lets `--write` + // compare the rendered text with the file to apply a template change. + expect(rendered).toBe(source); + expect(formatted("go-limits-data.ts", rendered)).toBe(source); + // Known answer: the text is rendered from the rows, so a moved figure shows. + const [first, row] = firstLimit(); + const moved = renderGoLimits({ + ...GO_MODEL_LIMITS, + [first]: { ...row, go: 12_345 }, + }); + expect(moved).not.toBe(source); + expect(moved).toContain("go: 12345,"); + }); +}); + +describe("generators · the generators, spawned", () => { + it("the limits generator answers current on a doc made from the shipped table, and stale when one figure moved", () => { + const temp = mkdtempSync(join(tmpdir(), "go-limits-")); + try { + const same = join(temp, "same.md"); + writeFileSync(same, goLimitsDoc(GO_MODEL_LIMITS)); + const current = runScript("generate-go-limits.ts", ["--from", same]); + expect(current.status, current.stderr).toBe(0); + expect(current.stdout).toContain("go-limits-data.ts is up to date"); + + const [first, row] = firstLimit(); + const changed = join(temp, "changed.md"); + writeFileSync( + changed, + goLimitsDoc({ ...GO_MODEL_LIMITS, [first]: { ...row, go: 12_345 } }) + ); + const behind = runScript("generate-go-limits.ts", ["--from", changed]); + expect(behind.status, behind.stdout).toBe(1); + expect(behind.stderr).toContain("STALE"); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }); + + it("the limits generator's --write rewrites a template change on current data and writes a moved figure, on a scratch copy", () => { + const temp = mkdtempSync(join(tmpdir(), "go-limits-write-")); + try { + // The generator's target is `../src/go-limits-data.ts` from its own + // file, so a copy of both folders keeps the shipped file untouched. + for (const folder of ["scripts", "src"]) { + cpSync(join(ROOT, folder), join(temp, folder), { recursive: true }); + } + symlinkSync(join(ROOT, "node_modules"), join(temp, "node_modules")); + const target = join(temp, "src", "go-limits-data.ts"); + const rendered = renderGoLimits(GO_MODEL_LIMITS); + const spawn = (args: readonly string[]) => { + const run = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(temp, "scripts", "generate-go-limits.ts"), + ...args, + ], + { cwd: temp, encoding: "utf8", timeout: 60_000 } + ); + return { status: run.status, stderr: run.stderr, stdout: run.stdout }; + }; + const same = join(temp, "same.md"); + writeFileSync(same, goLimitsDoc(GO_MODEL_LIMITS)); + // Whether a spawn wrote the target at all, bytes aside: its mtime is + // set to a fixed past first. A byte-identical rewrite is not harmless, + // since the lib freshness check in `scripts/check.ts` reads src/ mtimes. + const PAST = new Date(Date.UTC(2020, 0, 1)); + const writes = (args: readonly string[]) => { + utimesSync(target, PAST, PAST); + const run = spawn(args); + return { ...run, touched: statSync(target).mtimeMs !== PAST.getTime() }; + }; + + // Current data in text that is not the renderer's (a template change): + // left alone without --write, rewritten with it. + const reflowed = `${rendered}\n// a line the renderer does not make\n`; + writeFileSync(target, reflowed); + const report = writes(["--from", same]); + expect(report.status, report.stderr).toBe(0); + expect(report.stdout).toContain("go-limits-data.ts is up to date"); + expect(readFileSync(target, "utf8")).toBe(reflowed); + expect(report.touched).toBe(false); + const rewrite = writes(["--write", "--from", same]); + expect(rewrite.status, rewrite.stderr).toBe(0); + expect(rewrite.stdout).toContain("rewrote src/go-limits-data.ts"); + expect(readFileSync(target, "utf8")).toBe(rendered); + expect(rewrite.touched).toBe(true); + // Current in data and text: never rewritten, not even to the same bytes. + const current = writes(["--write", "--from", same]); + expect(current.status, current.stderr).toBe(0); + expect(current.stdout).toContain("go-limits-data.ts is up to date"); + expect(readFileSync(target, "utf8")).toBe(rendered); + expect(current.touched).toBe(false); + + // A moved figure: written, with the renderer's text. + const [first, row] = firstLimit(); + const moved = { ...GO_MODEL_LIMITS, [first]: { ...row, go: 12_345 } }; + const changed = join(temp, "changed.md"); + writeFileSync(changed, goLimitsDoc(moved)); + const write = writes(["--write", "--from", changed]); + expect(write.status, write.stderr).toBe(0); + expect(write.touched).toBe(true); + expect(write.stdout).toContain("wrote "); + expect(write.stdout).not.toContain("rewrote"); + expect(readFileSync(target, "utf8")).toBe(renderGoLimits(moved)); + expect(readFileSync(target, "utf8")).toContain("go: 12345,"); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + // The shipped table is the renderer's text of its own data still. + expect(readFileSync(join(ROOT, "src", "go-limits-data.ts"), "utf8")).toBe( + renderGoLimits(GO_MODEL_LIMITS) + ); + }); + + it("the catalog generator answers stale on a payload the shipped shim does not hold, and fails on one it could not read", () => { + const temp = mkdtempSync(join(tmpdir(), "catalog-shim-")); + try { + const file = join(temp, "api.json"); + writeFileSync( + file, + JSON.stringify( + payload({ go: [{ id: "made-up-go" }], zen: [{ id: "made-up-zen" }] }) + ) + ); + const behind = runScript("regenerate-catalog-shim.ts", ["--from", file]); + expect(behind.status, behind.stderr).toBe(1); + expect(behind.stdout).toContain("shim is STALE"); + expect(behind.stdout).toContain("+ 1: made-up-go"); + + const unread = runScript("regenerate-catalog-shim.ts", [ + "--from", + join(temp, "missing.json"), + ]); + expect(unread.status).not.toBe(0); + expect(unread.stdout).not.toContain("shim is current"); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }); +}); + +describe("generators · verify-catalog fails closed", () => { + it("verify-catalog exits non-zero on a stale shim and on a source it could not read, and passes offline on this tree", () => { + const temp = mkdtempSync(join(tmpdir(), "verify-catalog-")); + try { + const file = join(temp, "api.json"); + writeFileSync( + file, + JSON.stringify( + payload({ go: [{ id: "made-up-go" }], zen: [{ id: "made-up-zen" }] }) + ) + ); + const behind = runScript("verify-catalog.ts", ["--from", file]); + expect(behind.status, behind.stderr).toBe(1); + expect(behind.stdout).toContain("shim is stale"); + expect(behind.stdout).toContain("FAILED:"); + + const unread = runScript("verify-catalog.ts", [ + "--from", + join(temp, "missing.json"), + ]); + expect(unread.status, unread.stderr).toBe(1); + expect(unread.stdout).toContain("not read"); + expect(unread.stdout).toContain("FAILED:"); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + + const offline = runScript("verify-catalog.ts", ["--offline"]); + expect(offline.status, offline.stderr).toBe(0); + expect(offline.stdout).toMatch( + /^ {2}skip {2}registry not asked \(--offline\)/mu + ); + expect(offline.stdout).toMatch(/^ {2}ok {4}opencode: exactly the/mu); + expect(offline.stdout).toMatch(/^ {2}ok {4}opencode-go: exactly the/mu); + expect(offline.stdout).toMatch(/^ {2}ok {4}every catalog model \(\d+\)/mu); + expect(offline.stdout).toMatch(/^ {2}ok {4}every free model gets/mu); + expect(offline.stdout).toContain("PASS:"); + }); +}); + +describe("generators · verify-session-band fails when it should", () => { + it("verify-session-band's verdict fails when the derivation leaves the band, and the script prints it", () => { + expect(low12("ses_000000000ffe")).toBe(0xffe); + expect(inBand(0xffe)).toBe(true); + expect(inBand(0xfbf)).toBe(false); + + const facts = bandFacts({ derive: openCodeSessionIdFor, samples: 5000 }); + expect(facts.ascending.inBand).toBe(0); + expect(facts.descending).toMatchObject({ inBand: 5000, total: 5000 }); + expect(facts.withinOneMs).toMatchObject({ inBand: 63, total: 80 }); + expect(facts.ours).toMatchObject({ inBand: 5000, total: 5000 }); + expect(bandMismatches(facts)).toStrictEqual([]); + + // A derivation that leaves the band is named, and nothing else is. + const planted = bandFacts({ + derive: () => `ses_000000000001${"0".repeat(14)}`, + samples: 5000, + }); + const mismatches = bandMismatches(planted); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain("this plugin's derivation"); + + // The verdict the script prints: a mismatch fails it, and never + // confirms alongside. + const failed = bandVerdict(planted); + expect(failed.exit).toBe(1); + expect(failed.lines).toStrictEqual([`MISMATCH: ${mismatches[0] ?? ""}`]); + expect(failed.lines.join("\n")).not.toContain("CONFIRMED"); + const confirmed = bandVerdict(facts); + expect(confirmed.exit).toBe(0); + expect(confirmed.lines[0]).toMatch(/^CONFIRMED\./u); + expect(confirmed.lines.join("\n")).not.toContain("MISMATCH"); + + // The script prints that verdict and exits with it. + const run = runScript("verify-session-band.ts", []); + expect(run.status, run.stderr).toBe(0); + expect(run.stdout).toContain(confirmed.lines.join("\n")); + expect(run.stdout).not.toContain("MISMATCH"); + }); +}); diff --git a/test/harness-channel.test.ts b/test/harness-channel.test.ts index a1e7d3b7..c2fba490 100644 --- a/test/harness-channel.test.ts +++ b/test/harness-channel.test.ts @@ -41,6 +41,7 @@ import { describe, expect, it } from "vitest"; import { parse, parseAllDocuments } from "yaml"; import { channelManifest, pinFor } from "../scripts/channel-manifest.ts"; +import { README_HELD_FIELDS } from "../scripts/host-bump.ts"; import { floorOf } from "../scripts/host-drift.ts"; import { type Channel, @@ -150,11 +151,14 @@ describe("harness channel alignment · the READMEs' support policy", () => { // The paragraph that follows names the suite's installs. const installs = lines.slice(at + 1).find((line) => line !== "") ?? ""; for (const channel of Object.keys(CHANNELS) as Channel[]) { - const { dsh, piFloor, piRange } = CHANNELS[channel]; - for (const value of [dsh, piRange, piFloor]) { - expect(policy, `${channel} ${value}`).toContain(`\`${value}\``); + // The fields the nightly bump's body says this case holds. + const record = CHANNELS[channel]; + for (const field of README_HELD_FIELDS) { + expect(policy, `${channel} ${field}`).toContain( + `\`${record[field]}\`` + ); } - expect(installs, `${channel} install`).toContain(`\`${dsh}\``); + expect(installs, `${channel} install`).toContain(`\`${record.dsh}\``); } }); } diff --git a/test/host-bump.test.ts b/test/host-bump.test.ts new file mode 100644 index 00000000..204a2b13 --- /dev/null +++ b/test/host-bump.test.ts @@ -0,0 +1,863 @@ +/** + * The nightly bump proposal's rules (`scripts/host-bump.ts`): what the drift + * report's reading of the registry makes of a channel (data, a range change, + * a wait, or nothing), and the edits that move CONTRIBUTING's release-step-3 + * data in the files that hold it. The proposal's CLI against a loopback + * registry is the mock suite's (`test/mock/host-drift-cli.mock.ts`). + * + * Every view is read off the records (`test/support/host-drift-fixture.ts`), + * so the cases keep their meaning across a channel bump; every text is the + * real file's, so an edit that would miss its line fails here, not in the + * proposed pull request. + * + * @module test/host-bump.test + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import semver from "semver"; +import { describe, expect, it } from "vitest"; + +import { channelManifest } from "../scripts/channel-manifest.ts"; +import { + applyBumpTexts, + bumpBody, + bumpPlan, + type BumpPlan, + bumpTarget, + bumpTitle, + coupleSharedPi, + README_HELD_FIELDS, + withReadmePolicy, + withRecord, +} from "../scripts/host-bump.ts"; +import { hostDrift, type RegistryView } from "../scripts/host-drift.ts"; +import { + ALPHA_NEXT, + declaredBy, + NEXT_ALPHA, + nextPrerelease, + PATCHED, + registryOf, + versions, + withCut, +} from "./support/host-drift-fixture.ts"; +import { + type Channel, + CHANNELS, + RECORD_TAGS, +} from "./support/host-versions.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const read = (file: string): string => readFileSync(join(ROOT, file), "utf8"); + +const peers = ( + JSON.parse(read("package.json")) as { + peerDependencies: Record<string, string>; + } +).peerDependencies; + +const HOUR = 60 * 60 * 1000; +const NOW = Date.UTC(2026, 10, 13, 12); +/** Both the new dsh and pi, published long enough ago to install. */ +const SETTLED = { dsh: NOW - 48 * HOUR, pi: NOW - 48 * HOUR }; + +const plan = ( + channel: Channel, + registry: RegistryView, + publishedAt: Readonly<Partial<Record<"dsh" | "pi", number>>> = SETTLED +) => + bumpPlan({ + channel, + now: NOW, + peers, + publishedAt, + record: CHANNELS[channel], + registry, + tags: RECORD_TAGS, + }); + +/** `view` with alpha's dist-tag moved to `version`, its cluster declaring alpha's ranges. */ +const alphaCut = (version: string): RegistryView => { + const cut = withCut(registryOf(CHANNELS), CHANNELS.alpha, version); + return { + ...cut, + dsh: { + ...cut.dsh, + "dist-tags": { alpha: version, [CHANNELS.rc.distTag]: CHANNELS.rc.dsh }, + }, + }; +}; + +/** `view` with one more pi published. */ +const withPi = (view: RegistryView, ...more: string[]): RegistryView => ({ + ...view, + pi: { + versions: { ...view.pi.versions, ...versions(...more).versions }, + }, +}); + +/** The next pi patch inside both bridges' `^1.x` range. */ +const NEXT_PI = semver.inc(CHANNELS.alpha.pi, "patch") ?? ""; + +/** The registry with the records' dist-tags replaced by `tags`. */ +const retagged = ( + view: RegistryView, + tags: Readonly<Record<string, string>> +): RegistryView => ({ ...view, dsh: { ...view.dsh, "dist-tags": tags } }); + +/** The rc line's next build published, under `tag` alone. */ +const rcCutUnder = (tag: string): RegistryView => { + const version = nextPrerelease(CHANNELS.rc.dsh); + return retagged(withCut(registryOf(CHANNELS), CHANNELS.rc, version), { + [CHANNELS.alpha.distTag]: CHANNELS.alpha.dsh, + [tag]: version, + }); +}; + +describe("host bump · the plan", () => { + it("plans a bump as data, a range change, a wait or nothing, and never from a partial read", () => { + // (a) The registry is the records: nothing to propose. + for (const channel of ["alpha", "rc"] as const) { + const current = plan(channel, registryOf(CHANNELS)); + expect(current.kind, channel).toBe("current"); + expect(current.moved).toStrictEqual([]); + } + + // (b) The next alpha cut moves dsh and the whole family: data, and the + // floor cells run on dispatch since a family field moved. + const next = plan("alpha", ALPHA_NEXT); + expect(next.kind).toBe("data"); + expect(next.to.dsh).toBe(NEXT_ALPHA.dsh); + for (const field of [ + "dsh", + "cordis", + "cosmokit", + "group", + "include", + "loader", + "schemastery", + "timer", + ] as const) { + expect(next.moved, field).toContain(field); + expect(next.to[field], field).toBe(NEXT_ALPHA[field]); + } + expect(next.needsFloorCells).toBe(true); + expect(plan("rc", ALPHA_NEXT).kind).toBe("current"); + + // (c) A cut that moves only dsh: no floor cell needed. + const dshOnly = plan("alpha", alphaCut(NEXT_ALPHA.dsh)); + expect(dshOnly.kind).toBe("data"); + expect(dshOnly.moved).toStrictEqual(["dsh"]); + expect(dshOnly.needsFloorCells).toBe(false); + + // (d) A cut on a tuple the typert peer range does not admit opens a new + // prerelease line: a range change, for review. + const tuple = plan("alpha", alphaCut("0.2.3-alpha.1")); + expect(tuple.kind).toBe("range"); + const typert = "@deepseek-ai/dsh-typert-protocol"; + expect(tuple.notes.join("\n")).toContain(typert); + expect(tuple.notes.join("\n")).toContain(peers[typert]); + + // (e) The bridge is not published at the new version: a partial read is + // never proposed from. + const partial = alphaCut(NEXT_ALPHA.dsh); + const blocked = plan("alpha", { + ...partial, + bridge: { versions: registryOf(CHANNELS).bridge.versions }, + }); + expect(blocked.kind).toBe("blocked"); + expect(blocked.notes.join("\n")).toContain("is not published"); + + // (f) pnpm refuses a version published inside its one-day window, so a + // cut that young waits a night; one a day old is proposed. + expect( + plan("alpha", ALPHA_NEXT, { dsh: NOW - HOUR, pi: NOW - 48 * HOUR }).kind + ).toBe("waiting"); + expect( + plan("alpha", ALPHA_NEXT, { dsh: NOW - 25 * HOUR, pi: NOW - 48 * HOUR }) + .kind + ).toBe("data"); + // An unread publish time is a partial read too. + expect(plan("alpha", ALPHA_NEXT, {}).kind).toBe("blocked"); + }); +}); + +describe("host bump · pi, the cordis peer and the dist-tag", () => { + it("a pi patch moves both records as data, with no floor cells, and waits for or blocks on its own publish time", () => { + expect(NEXT_PI).not.toBe(""); + const view = withPi(registryOf(CHANNELS), NEXT_PI); + const plans = (["alpha", "rc"] as const).map((channel) => + plan(channel, view) + ); + for (const each of plans) { + expect(each.kind, each.channel).toBe("data"); + expect(each.moved, each.channel).toStrictEqual(["pi"]); + expect(each.to.pi, each.channel).toBe(NEXT_PI); + expect(each.needsFloorCells, each.channel).toBe(false); + } + // One headline for the two records, not the same version twice. + expect(bumpTitle(plans)).toBe( + `build: bump the alpha and rc harness records to pi ${NEXT_PI}` + ); + // The pi's own publish time decides: young waits, unread blocks. + expect(plan("alpha", view, { pi: NOW - HOUR }).kind).toBe("waiting"); + expect(plan("alpha", view, { dsh: NOW - 48 * HOUR }).kind).toBe("blocked"); + expect(plan("alpha", view, { pi: NOW - 25 * HOUR }).kind).toBe("data"); + + // A README value both records share moves once both plans apply, as the + // proposal applies them (`applyBumpTexts`, which the CLI calls: each + // plan against the other's PROPOSED record), and nothing is left for + // review. The real READMEs name the pi range, not the resolved pi, so the + // README here is synthetic; the records and manifest are the real files. + const marker = "**Support policy.**"; + const text = `${marker} Both channels run pi \`${CHANNELS.alpha.pi}\` today.\n`; + const texts = { + manifest: read("package.json"), + readmes: [{ file: "README.md", marker, text }], + records: read("test/support/host-versions.ts"), + }; + const both = applyBumpTexts(texts, plans, CHANNELS); + expect(both.unresolved).toStrictEqual({ alpha: [], rc: [] }); + expect(both.readmes).toStrictEqual([ + { + file: "README.md", + marker, + text: `${marker} Both channels run pi \`${NEXT_PI}\` today.\n`, + }, + ]); + // Both records moved, and alpha's pins with them. + const [alphaPlan, rcPlan] = plans as [BumpPlan, BumpPlan]; + expect(both.records).toBe( + withRecord( + withRecord(texts.records, "alpha", alphaPlan.to), + "rc", + rcPlan.to + ) + ); + expect(both.manifest).toBe(channelManifest(texts.manifest, alphaPlan.to)); + // One plan alone: the other channel's record is the CURRENT one, so the + // shared sentence is about both, a reviewer's call, and stays. The rc-only + // plan leaves package.json, which holds alpha's pins, untouched. + const alphaOnly = applyBumpTexts(texts, [alphaPlan], CHANNELS); + expect(alphaOnly.unresolved).toStrictEqual({ alpha: ["pi"] }); + expect(alphaOnly.readmes[0]?.text).toBe(text); + const rcOnly = applyBumpTexts(texts, [rcPlan], CHANNELS); + expect(rcOnly.unresolved).toStrictEqual({ rc: ["pi"] }); + expect(rcOnly.manifest).toBe(texts.manifest); + expect(rcOnly.records).toBe(withRecord(texts.records, "rc", rcPlan.to)); + }); + + it("a value both channels share and both plans move apart is left for review in both, never rewritten to one channel's", () => { + // Both bridges move their pi range on one night, to different ranges. + const apart = { + alpha: { piFloor: "1.6.0", piRange: "^1.6.0" }, + rc: { piFloor: "1.7.0", piRange: "^1.7.0" }, + } as const; + expect(CHANNELS.alpha.piRange).toBe(CHANNELS.rc.piRange); + expect(CHANNELS.alpha.piFloor).toBe(CHANNELS.rc.piFloor); + const plans = (["alpha", "rc"] as const).map((channel): BumpPlan => ({ + channel, + from: CHANNELS[channel], + kind: "range", + moved: ["piRange", "piFloor"], + needsFloorCells: true, + notes: [], + to: { ...CHANNELS[channel], ...apart[channel] }, + })); + const readmes = ( + [ + ["README.md", "**Support policy.**"], + ["README.zh-CN.md", "**支持策略。**"], + ] as const + ).map(([file, marker]) => ({ file, marker, text: read(file) })); + // Known answer: the shared values are in the policy paragraph, so the + // case below has something it could wrongly rewrite. + for (const { file, text } of readmes) { + expect(text, file).toContain(`\`${CHANNELS.alpha.piRange}\``); + } + const edited = applyBumpTexts( + { + manifest: read("package.json"), + readmes, + records: read("test/support/host-versions.ts"), + }, + plans, + CHANNELS + ); + expect(edited.readmes).toStrictEqual(readmes); + // Every shared value the paragraphs name is left for review, for each + // channel that moved it. + const named = (["piRange", "piFloor"] as const).filter((field) => + readmes.some(({ text }) => text.includes(`\`${CHANNELS.alpha[field]}\``)) + ); + expect(named).toContain("piRange"); + expect(edited.unresolved).toStrictEqual({ alpha: named, rc: named }); + // The body says the held fields are red until edited, for each channel. + const body = bumpBody(plans, { + files: [], + unresolved: edited.unresolved, + }); + expect( + body + .split("\n") + .filter( + (line) => + line.startsWith("- `piRange`") && + line.endsWith("is red until the sentence is edited.") + ) + ).toHaveLength(2); + // Moved apart in the plans' other order, the same answer. + expect( + applyBumpTexts( + { + manifest: read("package.json"), + readmes, + records: read("test/support/host-versions.ts"), + }, + plans.toReversed(), + CHANNELS + ).readmes + ).toStrictEqual(readmes); + }); + + it("a value the other channel is moving TO is left for review, in either plan order, never rewritten into both sentences", () => { + // Not shared now: alpha records `^1.5.0`, rc `^1.3.0`; rc moves to + // alpha's old value while alpha moves on. Once rc's edit lands, the text + // says `^1.5.0` twice, and alpha's rewrite would claim both. + const marker = "**Support policy.**"; + const current = { + alpha: { ...CHANNELS.alpha, piRange: "^1.5.0" }, + rc: { ...CHANNELS.rc, piRange: "^1.3.0" }, + }; + const text = `${marker} alpha admits pi \`^1.5.0\`, rc pi \`^1.3.0\`.\n`; + const texts = { + manifest: read("package.json"), + readmes: [{ file: "README.md", marker, text }], + records: read("test/support/host-versions.ts"), + }; + const moves = (rcTo: string) => + ( + [ + ["alpha", "^1.6.0"], + ["rc", rcTo], + ] as const + ).map(([channel, piRange]): BumpPlan => ({ + channel, + from: current[channel], + kind: "range", + moved: ["piRange"], + needsFloorCells: true, + notes: [], + to: { ...current[channel], piRange }, + })); + const plans = moves("^1.5.0"); + const forward = applyBumpTexts(texts, plans, current); + const backward = applyBumpTexts(texts, plans.toReversed(), current); + expect(forward.readmes[0]?.text).toBe( + `${marker} alpha admits pi \`^1.5.0\`, rc pi \`^1.5.0\`.\n` + ); + expect(backward.readmes).toStrictEqual(forward.readmes); + expect(forward.unresolved).toStrictEqual({ alpha: ["piRange"], rc: [] }); + expect(backward.unresolved).toStrictEqual(forward.unresolved); + // Known answer: rc moving anywhere else leaves alpha's value its own, so + // both are rewritten and nothing is left, in either order. + const elsewhere = moves("^1.4.0"); + const rewritten = `${marker} alpha admits pi \`^1.6.0\`, rc pi \`^1.4.0\`.\n`; + for (const order of [elsewhere, elsewhere.toReversed()]) { + const edited = applyBumpTexts(texts, order, current); + expect(edited.readmes[0]?.text).toBe(rewritten); + expect(edited.unresolved).toStrictEqual({ alpha: [], rc: [] }); + } + }); + + it("pi is one value in both lockfiles: a pi move carries every record, or is held", () => { + const view = withPi(registryOf(CHANNELS), NEXT_PI); + const settled = plan("rc", view); + expect(settled.kind).toBe("data"); + expect(settled.moved).toStrictEqual(["pi"]); + // Alpha's own cut is hours old: alone, alpha waits, and rc proposes pi. + const young = { dsh: NOW - 3 * HOUR, pi: SETTLED.pi }; + const alphaCutAndPi = withPi(alphaCut(NEXT_ALPHA.dsh), NEXT_PI); + const waiting = plan("alpha", alphaCutAndPi, young); + expect(waiting.kind).toBe("waiting"); + expect(waiting.moved).toContain("pi"); + // Coupled, alpha takes the pi move the same night and its dsh waits: + // both records end at the pi both lockfiles will hold. + const [alpha, rc] = coupleSharedPi([waiting, settled]) as [ + BumpPlan, + BumpPlan, + ]; + expect(rc).toBe(settled); + expect(alpha.kind).toBe("data"); + expect(alpha.moved).toStrictEqual(["pi"]); + expect(alpha.to).toStrictEqual({ ...CHANNELS.alpha, pi: NEXT_PI }); + expect(alpha.needsFloorCells).toBe(false); + expect(alpha.notes.join(" ")).toContain("pi moves with rc"); + expect(alpha.notes.join(" ")).toContain(NEXT_ALPHA.dsh); + // The mirror: rc waiting on its own cut while alpha moves pi. + const rcYoung = plan( + "rc", + withPi(rcCutUnder(CHANNELS.rc.distTag), NEXT_PI), + young + ); + expect(rcYoung.kind).toBe("waiting"); + const mirrored = coupleSharedPi([plan("alpha", view), rcYoung]); + expect(mirrored.map((each) => [each.kind, each.to.pi])).toStrictEqual([ + ["data", NEXT_PI], + ["data", NEXT_PI], + ]); + + // A channel that cannot take it holds the pi move: a partial read ... + const partial = plan("alpha", view, { dsh: SETTLED.dsh }); + expect(partial.kind).toBe("blocked"); + const heldByPartial = coupleSharedPi([partial, settled]); + expect(heldByPartial.map((each) => each.kind)).toStrictEqual([ + "blocked", + "blocked", + ]); + expect(heldByPartial[1]?.notes.join(" ")).toMatch( + /alpha records .* its read was partial/u + ); + // ... or a channel current at the old pi (its bridge stops short of it). + const current = plan("alpha", registryOf(CHANNELS)); + expect(current.kind).toBe("current"); + const heldByCurrent = coupleSharedPi([current, settled]); + expect(heldByCurrent[1]?.kind).toBe("blocked"); + expect(heldByCurrent[1]?.notes.join(" ")).toContain( + `its read found pi ${CHANNELS.alpha.pi}` + ); + // Nothing proposed moves pi, so either lockfile's pi stays. + expect( + heldByCurrent.filter( + (each) => + (each.kind === "data" || each.kind === "range") && + each.moved.includes("pi") + ) + ).toStrictEqual([]); + // No plan moves pi: nothing changes. Both moving pi together: left as + // it is. (A dsh-only proposal NEXT TO a pi mover is the next case's.) + const dshOnly = plan("alpha", alphaCut(NEXT_ALPHA.dsh)); + expect(coupleSharedPi([dshOnly, plan("rc", registryOf(CHANNELS))])[0]).toBe( + dshOnly + ); + const together = [plan("alpha", view), settled]; + expect(coupleSharedPi(together)).toStrictEqual(together); + }); + + it("a pi move is held, and nothing moves pi, when another record cannot end at it: a range that stops short, a dsh-only proposal, two pis, a third channel", () => { + const view = withPi(registryOf(CHANNELS), NEXT_PI); + const settled = plan("rc", view); + expect(settled.kind).toBe("data"); + const young = { dsh: NOW - 3 * HOUR, pi: SETTLED.pi }; + const waiting = plan( + "alpha", + withPi(alphaCut(NEXT_ALPHA.dsh), NEXT_PI), + young + ); + expect(waiting.kind).toBe("waiting"); + expect(waiting.to.pi).toBe(NEXT_PI); + /** Proposed plans that would move a lockfile's pi. */ + const piMoves = (plans: readonly BumpPlan[]) => + plans.filter( + (each) => + (each.kind === "data" || each.kind === "range") && + each.moved.includes("pi") + ); + + // Known answer: alpha's recorded range admits the new pi, so it couples. + expect(semver.satisfies(NEXT_PI, CHANNELS.alpha.piRange)).toBe(true); + expect(coupleSharedPi([waiting, settled])[0]?.kind).toBe("data"); + // The waiting channel's RECORDED range (its installed bridge's, which a + // pi-only move keeps) stops short of the new pi, though its young cut's + // admits it: a pi-only move would record a pi its own range refuses, so + // the pi move is held and alpha keeps its wait. + const bounded = `>=${CHANNELS.alpha.piFloor} <${NEXT_PI}`; + expect(semver.satisfies(CHANNELS.alpha.pi, bounded)).toBe(true); + const stopsShort: BumpPlan = { + ...waiting, + from: { ...waiting.from, piRange: bounded }, + }; + const heldByRange = coupleSharedPi([stopsShort, settled]); + expect(heldByRange[0]).toBe(stopsShort); + expect(heldByRange[1]?.kind).toBe("blocked"); + expect(heldByRange[1]?.notes.join(" ")).toContain( + `alpha records ${CHANNELS.alpha.pi} and cannot move with it tonight: its recorded pi range ${bounded} does not admit ${NEXT_PI}` + ); + expect(piMoves(heldByRange)).toStrictEqual([]); + + // A dsh-only proposal next to a pi mover: its record keeps the old pi, + // so it holds the pi move, and is itself proposed unchanged. + const dshOnly = plan("alpha", alphaCut(NEXT_ALPHA.dsh)); + expect(dshOnly.kind).toBe("data"); + expect(dshOnly.moved).toStrictEqual(["dsh"]); + const heldByDsh = coupleSharedPi([dshOnly, settled]); + expect(heldByDsh[0]).toBe(dshOnly); + expect(heldByDsh[1]?.kind).toBe("blocked"); + expect(heldByDsh[1]?.notes.join(" ")).toContain( + `alpha records ${CHANNELS.alpha.pi} and cannot move with it tonight: its proposal does not move pi` + ); + expect(piMoves(heldByDsh)).toStrictEqual([]); + + // Two movers that resolve different pis: both held. + const furtherPi = semver.inc(NEXT_PI, "patch") ?? ""; + const rcFurther: BumpPlan = { + ...settled, + to: { ...settled.to, pi: furtherPi }, + }; + const apart = coupleSharedPi([plan("alpha", view), rcFurther]); + expect(apart.map((each) => each.kind)).toStrictEqual([ + "blocked", + "blocked", + ]); + for (const each of apart) { + expect(each.notes.join(" "), each.channel).toContain( + `alpha and rc resolve different pis (${NEXT_PI} and ${furtherPi})` + ); + } + + // A third channel that cannot take the pi holds it: the waiting plan + // converted beside it goes back to its wait, so nothing moves pi. + const third: BumpPlan = { + ...plan("alpha", registryOf(CHANNELS)), + channel: "third", + }; + expect(third.kind).toBe("current"); + const heldByThird = coupleSharedPi([waiting, settled, third]); + expect(heldByThird[0]).toBe(waiting); + expect(heldByThird[1]?.kind).toBe("blocked"); + expect(heldByThird[1]?.notes.join(" ")).toContain("third records"); + expect(heldByThird[2]).toBe(third); + expect(piMoves(heldByThird)).toStrictEqual([]); + }); + + it("names a proposal's target by every proposed record in full, not by its title", () => { + const view = withPi(registryOf(CHANNELS), NEXT_PI); + const dsh = plan("alpha", alphaCut(NEXT_ALPHA.dsh)); + const dshAndPi = plan("alpha", withPi(alphaCut(NEXT_ALPHA.dsh), NEXT_PI)); + // The title is the dsh headline either way; the target is not. + expect(bumpTitle([dshAndPi])).toBe(bumpTitle([dsh])); + expect(bumpTarget([dshAndPi])).not.toBe(bumpTarget([dsh])); + // One line, an HTML comment, the body's last. + const target = bumpTarget([dsh, plan("rc", view)]); + expect(target).toMatch(/^<!-- host-bump-target \{.*\} -->$/u); + expect(target).not.toContain("\n"); + expect( + bumpBody([dsh, plan("rc", view)], { files: [], unresolved: {} }) + .split("\n") + .at(-1) + ).toBe(target); + // Order-free, and a plan that is not proposed is no part of it. + expect(bumpTarget([plan("rc", view), dsh])).toBe(target); + expect(bumpTarget([dsh, plan("rc", registryOf(CHANNELS))])).toBe( + bumpTarget([dsh]) + ); + const parsed = JSON.parse( + target.slice("<!-- host-bump-target ".length, -" -->".length) + ) as Record<string, Record<string, string>>; + expect(Object.keys(parsed)).toStrictEqual(["alpha", "rc"]); + expect(parsed.alpha?.dsh).toBe(NEXT_ALPHA.dsh); + expect(parsed.rc?.pi).toBe(NEXT_PI); + }); + + it("a bridge that declares a new pi range is a reviewed change that needs the floor cells", () => { + const range = `^${semver.inc(CHANNELS.alpha.pi, "minor") ?? ""}`; + const floor = semver.minVersion(range)?.version ?? ""; + const version = nextPrerelease(CHANNELS.alpha.dsh); + const view = withPi( + retagged( + withCut( + registryOf(CHANNELS), + { ...CHANNELS.alpha, piRange: range }, + version + ), + { alpha: version, [CHANNELS.rc.distTag]: CHANNELS.rc.dsh } + ), + floor + ); + const moved = plan("alpha", view); + expect(moved.kind).toBe("range"); + for (const field of ["dsh", "piRange", "piFloor", "pi"] as const) { + expect(moved.moved, field).toContain(field); + } + expect(moved.to.piFloor).toBe(floor); + expect(moved.needsFloorCells).toBe(true); + expect(moved.notes.join("\n")).toContain("pi range moved"); + }); + + it("a cordis cut outside the declared cordis peer is a range change naming that peer", () => { + const cordis = "4.2.0-alpha.0"; + const declared = peers["@deepseek-ai/cordis"] ?? ""; + expect(semver.satisfies(cordis, declared)).toBe(false); + const record = { ...CHANNELS.alpha, cordis }; + const version = nextPrerelease(CHANNELS.alpha.dsh); + const cut = withCut(registryOf(CHANNELS), record, version); + const view = retagged( + { + ...cut, + cordis: { + versions: { + ...cut.cordis.versions, + ...declaredBy(record).cordis, + }, + }, + }, + { alpha: version, [CHANNELS.rc.distTag]: CHANNELS.rc.dsh } + ); + const moved = plan("alpha", view); + expect(moved.kind).toBe("range"); + expect(moved.moved).toContain("cordis"); + expect(moved.notes.join("\n")).toContain( + `@deepseek-ai/cordis ${declared} does not admit ${cordis}` + ); + expect(moved.needsFloorCells).toBe(true); + }); + + it("a dist-tag never moves alone: a tag-only change is current, a dsh move carries its tag, and a tag the record type does not admit is reviewed", () => { + // `next` retired once `latest` catches up: the build did not move, so + // the report prints nothing and nothing is proposed. + const tagOnly = retagged(registryOf(CHANNELS), { + [CHANNELS.alpha.distTag]: CHANNELS.alpha.dsh, + latest: CHANNELS.rc.dsh, + }); + expect(hostDrift({ rc: CHANNELS.rc }, tagOnly)).toStrictEqual([]); + const still = plan("rc", tagOnly); + expect(still.kind).toBe("current"); + expect(still.to.distTag).toBe(CHANNELS.rc.distTag); + + // The next rc build under `latest`, `next` gone: data, and the tag moves + // with the build. The report names the tag in the dsh line's label. + const underLatest = plan("rc", rcCutUnder("latest")); + expect(underLatest.kind).toBe("data"); + expect(underLatest.moved).toStrictEqual(["distTag", "dsh"]); + expect(underLatest.to.distTag).toBe("latest"); + expect(hostDrift({ rc: CHANNELS.rc }, rcCutUnder("latest"))).toContain( + `rc: @deepseek-ai/dsh@latest is ${underLatest.to.dsh} on the registry; the record says ${CHANNELS.rc.dsh}` + ); + + // Under a tag `ChannelRecord.distTag` does not admit: writing it would + // fail the type, so it is a reviewed change, named. + const unknown = plan("rc", rcCutUnder("rc")); + expect(unknown.kind).toBe("range"); + expect(unknown.to.distTag).toBe("rc"); + expect(unknown.notes.join("\n")).toContain( + "the newest build sits under the `rc` dist-tag" + ); + + // The README's tag words are prose: a moved tag is left for review, + // never rewritten. + for (const [file, marker] of [ + ["README.md", "**Support policy.**"], + ["README.zh-CN.md", "**支持策略。**"], + ] as const) { + const text = read(file); + const edited = withReadmePolicy( + text, + marker, + CHANNELS.rc, + { ...CHANNELS.rc, distTag: "latest" }, + { now: CHANNELS.alpha, proposed: CHANNELS.alpha } + ); + expect(edited.unresolved, file).toStrictEqual(["distTag"]); + expect(edited.text, file).toBe(text); + } + }); + + it("the plan is current exactly when the report has no line for the channel", () => { + const registry = registryOf(CHANNELS); + const bridge = registry.bridge.versions?.[CHANNELS.rc.dsh]; + const views: RegistryView[] = [ + registry, + ALPHA_NEXT, + PATCHED, + withPi(registry, NEXT_PI), + rcCutUnder("latest"), + rcCutUnder("rc"), + retagged(registry, { + [CHANNELS.alpha.distTag]: CHANNELS.alpha.dsh, + latest: CHANNELS.rc.dsh, + }), + { appBoot: {}, bridge: {}, cordis: {}, dsh: {}, pi: {} }, + { ...registry, bridge: {} }, + { + ...registry, + bridge: { + versions: { [CHANNELS.rc.dsh]: { ...bridge, dependencies: {} } }, + }, + }, + { ...registry, cordis: {} }, + ]; + let drifted = 0; + for (const [index, view] of views.entries()) { + for (const channel of ["alpha", "rc"] as const) { + const lines = hostDrift({ [channel]: CHANNELS[channel] }, view); + drifted += lines.length > 0 ? 1 : 0; + expect( + plan(channel, view).kind === "current", + `${index} ${channel}` + ).toBe(lines.length === 0); + } + } + // Known answer: both outcomes occur, so the equivalence compared something. + expect(drifted).toBeGreaterThan(0); + expect(drifted).toBeLessThan(views.length * 2); + }); +}); + +describe("host bump · the edits", () => { + const source = read("test/support/host-versions.ts"); + /** The lines of one channel's record block in the source. */ + const block = (text: string, channel: Channel): string => { + const start = text.indexOf(` ${channel}: {\n`); + return text.slice(start, text.indexOf("\n },", start)); + }; + + it("moves exactly release step 3's data in the files that hold it", () => { + // The record: one line, in the alpha block, and nothing else. + const moved = withRecord(source, "alpha", { + ...CHANNELS.alpha, + dsh: NEXT_ALPHA.dsh, + }); + const before = source.split("\n"); + const after = moved.split("\n"); + expect(after).toHaveLength(before.length); + const changed = before.flatMap((line, index) => + line === after[index] ? [] : [index] + ); + expect(changed).toHaveLength(1); + expect(after[changed[0] ?? 0]).toBe(` dsh: "${NEXT_ALPHA.dsh}",`); + expect(block(moved, "alpha")).toContain(NEXT_ALPHA.dsh); + expect(block(moved, "rc")).toBe(block(source, "rc")); + expect(withRecord(source, "alpha", CHANNELS.alpha)).toBe(source); + // A record the edit cannot find a field in is refused, never half-written. + const missing = source.replace(` dsh: "${CHANNELS.alpha.dsh}",\n`, ""); + expect(missing).not.toBe(source); + expect(() => + withRecord(missing, "alpha", { ...CHANNELS.alpha, dsh: NEXT_ALPHA.dsh }) + ).toThrow(/dsh/u); + + // The manifest: alpha's exact pins move with the record; pi's range, + // which is the union of both channels', does not. + const manifest = read("package.json"); + const next = JSON.parse(channelManifest(manifest, NEXT_ALPHA)) as { + devDependencies: Record<string, string>; + }; + const dshPins = Object.entries(next.devDependencies).filter(([name]) => + name.startsWith("@deepseek-ai/dsh-") + ); + expect(dshPins.length).toBeGreaterThan(3); + for (const [name, pin] of dshPins) { + expect(pin, name).toBe(NEXT_ALPHA.dsh); + } + expect(next.devDependencies["@earendil-works/pi-ai"]).toBe( + (JSON.parse(manifest) as { devDependencies: Record<string, string> }) + .devDependencies["@earendil-works/pi-ai"] + ); + + // The README pair's "today" sentences. + for (const [file, marker] of [ + ["README.md", "**Support policy.**"], + ["README.zh-CN.md", "**支持策略。**"], + ] as const) { + const text = read(file); + const lines = (body: string): [string, string] => { + const all = body.split("\n"); + const at = all.findIndex((line) => line.startsWith(marker)); + return [ + all[at] ?? "", + all.slice(at + 1).find((line) => line !== "") ?? "", + ]; + }; + const to = { ...CHANNELS.alpha, dsh: NEXT_ALPHA.dsh }; + const edited = withReadmePolicy(text, marker, CHANNELS.alpha, to, { + now: CHANNELS.rc, + proposed: CHANNELS.rc, + }); + expect(edited.unresolved, file).toStrictEqual([]); + const [policy, installs] = lines(edited.text); + for (const line of [policy, installs]) { + expect(line, file).toContain(`\`${NEXT_ALPHA.dsh}\``); + expect(line, file).not.toContain(`\`${CHANNELS.alpha.dsh}\``); + } + // A value both channels record is one sentence about both: which half + // moved is a reviewer's call, so it is left and named. + const range = withReadmePolicy( + text, + marker, + CHANNELS.alpha, + { ...CHANNELS.alpha, piRange: "^1.6.0" }, + { now: CHANNELS.rc, proposed: CHANNELS.rc } + ); + expect(CHANNELS.rc.piRange).toBe(CHANNELS.alpha.piRange); + expect(range.unresolved, file).toStrictEqual(["piRange"]); + expect(range.text, file).toBe(text); + } + }); + + it("titles and describes the proposal as release step 3 does", () => { + const data = plan("alpha", ALPHA_NEXT); + const range = plan("alpha", alphaCut("0.2.3-alpha.1")); + const dshOnly = plan("alpha", alphaCut(NEXT_ALPHA.dsh)); + expect(bumpTitle([data])).toMatch( + /^build: bump the alpha harness record to /u + ); + expect(bumpTitle([range])).toMatch( + /^build: propose .* \(peer range change, needs review\)$/u + ); + // A 1.x minor at most: no breaking-change marker in either title. + for (const title of [bumpTitle([data]), bumpTitle([range])]) { + expect(title).not.toContain("!"); + } + + const body = bumpBody([data], { + files: ["test/support/host-versions.ts", "package.json"], + unresolved: { alpha: ["piRange"] }, + }); + for (const table of [ + "WHOLE_CATALOG", + "PI_GAPS_BY_CELL", + "PI_AI_DEFAULTS", + ]) { + expect(body, table).toContain(table); + } + expect(body).toContain(CHANNELS.alpha.dsh); + expect(body).toContain(NEXT_ALPHA.dsh); + expect(body).toContain("test/support/host-versions.ts"); + expect(body).toContain("piRange"); + expect(body).toContain("floor cells were dispatched"); + expect(bumpBody([dshOnly], { files: [], unresolved: {} })).not.toContain( + "floor cells were dispatched" + ); + expect(bumpBody([range], { files: [], unresolved: {} })).toContain( + "@deepseek-ai/dsh-typert-protocol" + ); + }); + + it("says which README fields left for review a test turns red, and which nothing reads", () => { + const rc = plan("rc", rcCutUnder("latest")); + // A held field: harness-channel is red until the sentence is edited. + const held = bumpBody([rc], { + files: [], + unresolved: { rc: ["piRange"] }, + }); + expect(held).toContain( + "- `piRange`: `test/harness-channel.test.ts` is red until the sentence is edited." + ); + expect(held).not.toContain("no test reads this wording"); + // The dist-tag: nothing reads it, so the body promises no red check. + const tag = bumpBody([rc], { files: [], unresolved: { rc: ["distTag"] } }); + expect(tag).toContain( + "- `distTag`: no test reads this wording, so nothing turns red; edit the policy and installs paragraphs of both READMEs before merging." + ); + expect(tag).not.toContain("is red until"); + // Both at once: each named under its own sentence. + const mixed = bumpBody([rc], { + files: [], + unresolved: { rc: ["distTag", "piRange"] }, + }); + expect(mixed).toContain("- `piRange`: `test/harness-channel.test.ts`"); + expect(mixed).toContain("- `distTag`: no test reads"); + // The held list is the one harness-channel loops over (it imports it). + expect(README_HELD_FIELDS).toStrictEqual(["dsh", "piRange", "piFloor"]); + }); +}); diff --git a/test/host-drift.test.ts b/test/host-drift.test.ts index cccef935..ade21217 100644 --- a/test/host-drift.test.ts +++ b/test/host-drift.test.ts @@ -6,7 +6,7 @@ * * @module test/host-drift */ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import semver from "semver"; @@ -14,10 +14,12 @@ import { describe, expect, it } from "vitest"; import { parse } from "yaml"; import { + BUMP_FIELDS, type DriftRecord, floorOf, hostDrift, newestIn, + readChannel, REGISTRY_PACKAGES, type RegistryView, } from "../scripts/host-drift.ts"; @@ -30,9 +32,117 @@ import { withCut, } from "./support/host-drift-fixture.ts"; import { type Channel, CHANNELS } from "./support/host-versions.ts"; +import { + execute, + loadWorkflow, + type Job as WorkflowJob, + type Step, + stepEnv, +} from "./support/workflows.ts"; const ROOT = join(import.meta.dirname, ".."); +/** + * The wholesale approval keys: the allow-all, then the allow-list, its file and + * the deny-list (which builds every dependency it does not list, an empty one + * included). The pinned pnpm (12.16.1, measured with a dependency whose + * install script writes a marker) acts on the allow-all in pnpm-workspace.yaml + * and on none of the other three anywhere, nor on anything in package.json's + * `pnpm` field, `.npmrc` or `npm_config_*`; they stay named so a pnpm that + * reads them again cannot reopen the boundary unseen. + */ +const APPROVING_KEYS = [ + "dangerouslyAllowAllBuilds", + "neverBuiltDependencies", + "onlyBuiltDependencies", + "onlyBuiltDependenciesFile", +] as const; + +/** + * What turns builds on from inside a script the step runs: the allow-all flag, + * a build setting passed as `--config.`, or a `PNPM_CONFIG_` variable the + * script sets for the pnpm it spawns. + */ +const APPROVING_IN_SCRIPT = + /--(?:config\.)?dangerously-allow-all-builds|--config\.(?:allow-builds|permissions|only-built-dependencies|never-built-dependencies)|\bpnpm_config_\w+/iu; + +/** An environment variable pnpm reads as a build setting. */ +const APPROVING_ENV = + /^pnpm_config_|^npm_config_(?:dangerously_allow_all_builds|only_built_dependencies|never_built_dependencies)/iu; + +/** + * Every place the plan step's installs could be told to run a dependency's + * build script. What the pinned pnpm reads, each measured: pnpm-workspace.yaml + * (an `allowBuilds` entry set to `true`, a `permissions` entry granting + * `build`, `dangerouslyAllowAllBuilds`), a `PNPM_CONFIG_*` variable in the + * step's environment (which overrides the workspace's `allowBuilds`), and a + * CLI flag or `PNPM_CONFIG_` variable in anything the step runs pnpm from + * (its own shell, the proposer and the rc relock the proposer spawns, + * `scripts/use-channel.ts`). Then what only older pnpm reads: the rest of + * {@link APPROVING_KEYS} in the workspace, every approval in package.json's + * `pnpm` field, and the npmrc and `npm_config_*` spellings. + * + * @param sources - each source, already read (absent ones empty). + * @returns one line per approval found, empty when nothing approves. + */ +const buildApprovals = (sources: { + readonly env: Readonly<Record<string, unknown>>; + readonly manifest: Readonly<Record<string, unknown>>; + readonly npmrc: string; + readonly scripts: Readonly<Record<string, string>>; + readonly workspace: Readonly<Record<string, unknown>>; +}): string[] => { + const found: string[] = []; + const pnpmField = (sources.manifest.pnpm ?? {}) as Record<string, unknown>; + for (const [where, config] of [ + ["pnpm-workspace.yaml", sources.workspace], + ["package.json pnpm", pnpmField], + ] as const) { + const allowBuilds = (config.allowBuilds ?? {}) as Record<string, unknown>; + for (const [name, allowed] of Object.entries(allowBuilds)) { + if (allowed === true) { + found.push(`${where} allowBuilds approves ${name}`); + } + } + for (const key of APPROVING_KEYS) { + if (config[key] !== undefined) { + found.push(`${where} sets ${key}`); + } + } + } + const permissions = (sources.workspace.permissions ?? {}) as Record< + string, + unknown + >; + for (const [name, granted] of Object.entries(permissions)) { + const build = (granted as { build?: unknown } | null)?.build; + if (build !== undefined && build !== false) { + found.push(`pnpm-workspace.yaml permissions grant ${name} build`); + } + } + for (const key of [ + "dangerously-allow-all-builds", + "never-built-dependencies", + "only-built-dependencies", + ]) { + if (new RegExp(`^\\s*${key}`, "mu").test(sources.npmrc)) { + found.push(`.npmrc sets ${key}`); + } + } + for (const key of Object.keys(sources.env)) { + if (APPROVING_ENV.test(key)) { + found.push(`the step's environment sets ${key}`); + } + } + for (const [script, source] of Object.entries(sources.scripts)) { + const approval = APPROVING_IN_SCRIPT.exec(source); + if (approval !== null) { + found.push(`${script} carries ${approval[0]}`); + } + } + return found; +}; + describe("host drift · the rules", () => { it("resolves a range as npm does: the newest admitted, a prerelease only on its own tuple", () => { const cordis = versions("4.0.5", "4.0.6-alpha.1", "4.0.6-alpha.2", "4.1.0"); @@ -374,6 +484,41 @@ describe("host drift · the rules", () => { }); }); +describe("host drift · one reading per channel", () => { + // Whether the bump plan agrees with this report is a plan-side cross-check + // (`test/host-bump.test.ts`, "the plan is current exactly when the report + // has no line for the channel"), never a copy of `hostDrift` compared with + // itself. + it("readChannel reads every bump field exactly once, and nothing is unread on the records' registry", () => { + const registry = registryOf(CHANNELS); + for (const record of Object.values(CHANNELS)) { + const findings = readChannel(record, registry); + expect( + findings.filter((finding) => finding.kind === "unread") + ).toStrictEqual([]); + expect( + findings + .map((finding) => (finding.kind === "value" ? finding.field : "")) + .toSorted() + ).toStrictEqual([...BUMP_FIELDS].toSorted()); + } + expect(BUMP_FIELDS).toHaveLength(12); + // Known answer: a drifted view is read as values, not as unread. + const drifted = readChannel(CHANNELS.alpha, ALPHA_NEXT); + expect(drifted.filter((finding) => finding.kind === "unread")).toHaveLength( + 0 + ); + expect( + drifted.some( + (finding) => + finding.kind === "value" && + finding.field === "dsh" && + finding.found === NEXT_ALPHA.dsh + ) + ).toBe(true); + }); +}); + describe("host drift · the CI wiring, parsed", () => { interface Job { readonly if?: string; @@ -408,4 +553,586 @@ describe("host drift · the CI wiring, parsed", () => { expect(runsOn(job, "pull_request")).toBe(false); expect(runsOn(job, "push")).toBe(false); }); + + it("the bump is planned with no token in its step's environment, then pushed as the token's owner, and can dispatch the floor cells without keys", () => { + const workflow = loadWorkflow("ci.yml"); + const job = jobs["host-drift"] as unknown as WorkflowJob; + const steps = job.steps ?? []; + const planning = steps.filter((step) => + (step.run ?? "").includes("scripts/propose-host-bump.ts") + ); + expect(planning).toHaveLength(1); + const [plan] = planning; + expect(plan?.id).toBe("plan"); + expect(plan?.if).toBe("github.event_name == 'schedule'"); + // The proposer resolves and installs a cut nobody has reviewed: no + // secret, and no token at all, is in the environment the step runs with, + // which is the workflow's, then the job's, then its own. + const tokenIn = (env: Readonly<Record<string, unknown>>): boolean => + /secrets\.|token/iu.test(JSON.stringify(env)); + expect(tokenIn(stepEnv(workflow, job, plan))).toBe(false); + // Known answer, through the same composition: the real job and step with + // a token set at the job's level is named, so a composition that skipped + // the job's layer would turn this red. + const tokenAtJob = { + ...job, + env: { + ...job.env, + GH_TOKEN: ["$", "{{ secrets.RELEASE_PLEASE_TOKEN }}"].join(""), + }, + }; + expect(tokenIn(stepEnv(workflow, tokenAtJob, plan))).toBe(true); + // The step is tokenless only because the install runs none of the cut's + // code: pnpm runs a dependency's build script only when something + // approves it, and nothing does in any source buildApprovals reads. The + // scripts are every one the step runs pnpm from: the proposer's own + // installs, the rc relock it spawns, and the step's own shell. + const read = (file: string): string => + readFileSync(join(ROOT, file), "utf8"); + const scripts = { + "scripts/propose-host-bump.ts": read("scripts/propose-host-bump.ts"), + "scripts/use-channel.ts": read("scripts/use-channel.ts"), + "the plan step": plan?.run ?? "", + }; + // Each source was read: the step runs the proposer, the proposer spawns + // the relock, and the relock runs an install. + expect(scripts["scripts/propose-host-bump.ts"]).toContain( + '"scripts/use-channel.ts"' + ); + expect(scripts["scripts/use-channel.ts"]).toContain('"install"'); + expect(scripts["the plan step"]).toContain("propose-host-bump.ts"); + const npmrc = join(ROOT, ".npmrc"); + const workspace = parse( + readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8") + ) as Record<string, unknown>; + expect(workspace.allowBuilds).toBeDefined(); + expect( + buildApprovals({ + env: stepEnv(workflow, job, plan), + manifest: JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf8") + ) as Record<string, unknown>, + npmrc: existsSync(npmrc) ? readFileSync(npmrc, "utf8") : "", + scripts, + workspace, + }) + ).toStrictEqual([]); + // Known answers: every source is named. The approvals the pinned pnpm + // acts on (an `allowBuilds` entry, a `build` permission, the allow-all, + // a PNPM_CONFIG_ variable in the environment or set by a script, both CLI + // spellings, in either script), then + // the spellings only older pnpm reads (the deny-list, empty included, + // the allow-list, package.json's field, the npmrc line, npm_config_). + expect( + buildApprovals({ + env: { + NODE_OPTIONS: "--max-old-space-size=4096", + npm_config_dangerously_allow_all_builds: "true", + PNPM_CONFIG_DANGEROUSLY_ALLOW_ALL_BUILDS: "true", + }, + manifest: { + pnpm: { + allowBuilds: { esbuild: true }, + onlyBuiltDependencies: ["esbuild"], + }, + }, + npmrc: "dangerously-allow-all-builds=true\n", + scripts: { + "scripts/propose-host-bump.ts": + 'run("pnpm", ["install", "--dangerously-allow-all-builds"]);', + "scripts/use-channel.ts": + 'pnpm("install", "--config.dangerously-allow-all-builds=true");', + "the plan step": + "export PNPM_CONFIG_ALLOW_BUILDS='{\"esbuild\":true}'", + }, + workspace: { + allowBuilds: { esbuild: true, protobufjs: false }, + dangerouslyAllowAllBuilds: true, + neverBuiltDependencies: [], + permissions: { + "@google/genai": { build: false }, + esbuild: { build: true }, + }, + }, + }) + ).toStrictEqual([ + "pnpm-workspace.yaml allowBuilds approves esbuild", + "pnpm-workspace.yaml sets dangerouslyAllowAllBuilds", + "pnpm-workspace.yaml sets neverBuiltDependencies", + "package.json pnpm allowBuilds approves esbuild", + "package.json pnpm sets onlyBuiltDependencies", + "pnpm-workspace.yaml permissions grant esbuild build", + ".npmrc sets dangerously-allow-all-builds", + "the step's environment sets npm_config_dangerously_allow_all_builds", + "the step's environment sets PNPM_CONFIG_DANGEROUSLY_ALLOW_ALL_BUILDS", + "scripts/propose-host-bump.ts carries --dangerously-allow-all-builds", + "scripts/use-channel.ts carries --config.dangerously-allow-all-builds", + "the plan step carries PNPM_CONFIG_ALLOW_BUILDS", + ]); + const pushing = steps.filter((step) => + (step.run ?? "").includes("git push") + ); + expect(pushing).toHaveLength(1); + const [push] = pushing; + expect(push?.if).toBe( + "github.event_name == 'schedule' && steps.plan.outputs.proposed == 'true'" + ); + const at = (wanted: Step | undefined): number => + wanted === undefined ? -1 : steps.indexOf(wanted); + expect(at(push)).toBeGreaterThan(at(plan)); + expect(at(plan)).toBeGreaterThan(-1); + expect(push?.run).toContain( + `x-access-token:\${GH_TOKEN}@github.com/\${GITHUB_REPOSITORY}.git` + ); + expect(push?.run).not.toContain("propose-host-bump.ts"); + expect(push?.run).toContain( + "gh workflow run ci.yml --ref chore/host-bump -f keyed=none" + ); + // The dispatch's `keyed` input exists, and `none` is one of its values. + const dispatch = loadWorkflow("ci.yml").on?.workflow_dispatch as + | { inputs?: { keyed?: { default?: string; options?: string[] } } } + | undefined; + expect(dispatch?.inputs?.keyed?.options).toStrictEqual(["full", "none"]); + expect(dispatch?.inputs?.keyed?.default).toBe("full"); + // No credential persisted by the checkout: git would send it ahead of the + // token the push names, and the proposal's CI would wait for a human. + const checkout = steps.find((candidate) => + (candidate.uses ?? "").startsWith("actions/checkout@") + ); + expect(checkout?.with?.["persist-credentials"]).toBe(false); + expect(job.permissions?.actions).toBe("write"); + expect(job.permissions?.contents).toBe("write"); + expect(job.permissions?.["pull-requests"]).toBe("write"); + }); +}); + +describe("host drift · the proposal steps, executed", () => { + const job = loadWorkflow("ci.yml").jobs["host-drift"]; + const plan = (job?.steps ?? []).find((step) => step.id === "plan"); + const push = (job?.steps ?? []).find((step) => + (step.run ?? "").includes("git push") + ); + /** A literal value of the push step's `env`. */ + const envOf = (key: string): string => { + const value = push?.env?.[key]; + return typeof value === "string" ? value : ""; + }; + const BOT_EMAIL = envOf("BOT_EMAIL"); + + /** + * Shadows the builtins bash 4 added that a step could reach for, so a step + * that needs them fails here as it would under macOS's bash 3.2 (the + * runner's bash is 5, and the suite runs with whatever bash is local). A + * function, not `enable -n`: defining one works on every bash, while + * `enable -n` on a name bash 3.2 lacks fails, and under `-e` that would + * end the prelude before the step ran. + */ + const BASH_3 = [ + 'mapfile() { echo "mapfile: bash 4 only" >&2; return 127; }', + 'readarray() { mapfile "$@"; }', + ].join("\n"); + + it("the bash 3.2 stand-in refuses mapfile and readarray, and the steps' read loop runs under it", () => { + for (const builtin of ["mapfile", "readarray"]) { + const refused = execute(`${builtin} -t lines < /dev/null`, BASH_3); + expect(refused.status, builtin).not.toBe(0); + expect(refused.stderr, builtin).toContain("bash 4 only"); + } + // Without the stand-in the refusal is not there, so the refusal above is + // the stand-in's (on a bash 4 or later the line runs; on 3.2 bash itself + // refuses it, in its own words). + const bare = execute("mapfile -t lines < /dev/null", ""); + expect(bare.stderr).not.toContain("bash 4 only"); + const loop = execute( + [ + "files=()", + `while IFS= read -r file; do files+=("$file"); done < <(printf 'a b\\nc\\n')`, + `printf "%s|" "\${files[@]}" >> "$GITHUB_OUTPUT"`, + ].join("\n"), + BASH_3 + ); + expect(loop.status, loop.stderr).toBe(0); + expect(loop.output).toBe("a b|c|"); + }); + + /** `node` stands in for the proposer: it writes what `--out` would. */ + const proposer = ( + title: string, + files: readonly string[], + dispatch: boolean + ) => + [ + BASH_3, + 'node() { echo "node $*" >> "$CALLS"', + ...(title === "" + ? [] + : [ + ' out="$RUNNER_TEMP/host-bump"; mkdir -p "$out"', + ` printf '%s\\n' ${JSON.stringify(title)} > "$out/title"`, + ` printf '%s\\n' ${files.map((file) => JSON.stringify(file)).join(" ")} > "$out/files"`, + ...(dispatch ? [' echo floor > "$out/dispatch"'] : []), + ]), + "}", + 'pnpm() { echo "pnpm $*" >> "$CALLS"; }', + ].join("\n"); + + it("the plan step formats only the sources it wrote, and says whether there is a proposal", () => { + expect(plan?.run).toBeDefined(); + const none = execute(plan?.run ?? "", proposer("", [], false)); + expect(none.status, none.stderr).toBe(0); + expect(none.calls).toMatch( + /^node --experimental-strip-types scripts\/propose-host-bump\.ts --write --out \S+\/host-bump\n$/u + ); + expect(none.calls).not.toContain("pnpm"); + expect(none.output).toBe(""); + + const some = execute( + plan?.run ?? "", + proposer( + "build: bump", + ["test/support/host-versions.ts", "package.json", "pnpm-lock.yaml"], + false + ) + ); + expect(some.status, some.stderr).toBe(0); + expect(some.calls.split("\n")[1]).toBe( + "pnpm exec vp fmt --write test/support/host-versions.ts package.json" + ); + expect(some.output).toBe("proposed=true\n"); + }); + + /** The marker line a proposal's body ends with (`bumpTarget`), for `dsh`. */ + const targetOf = (dsh: string): string => + `<!-- host-bump-target ${JSON.stringify({ alpha: { dsh } })} -->`; + const TITLE = "build: bump the alpha harness record to X"; + const TARGET = targetOf("X"); + const OLDER = targetOf("W"); + + /** One open pull request from `chore/host-bump`, as the stand-in holds it. */ + interface OpenPr { + /** Its body's line ends, CRLF as a body edited on the web has them. */ + readonly crlf?: boolean; + readonly number: number; + readonly owner: string; + /** The marker line its body carries. */ + readonly target: string; + readonly title: string; + } + + /** + * The push step's stand-ins, as a prelude: `gh` and `git` as functions. + * `gh api` stands in for GitHub: its `pulls?` arm holds the open pull + * requests from `chore/host-bump` in every repository (`prs`) and answers, + * as the API does, only those whose head owner the query's `head=<owner>:` + * names, one `<number> <title>` line each (what the step's `--jq` prints), + * while `gh pr list --head`, which has no owner filter, would answer them + * all; its `pulls/<number>` arm answers that pull request's body, a line of + * prose and its marker line (or fails); its `compare` arm answers each + * commit's author email, and its committer's when the `--jq` asks for it + * (or fails); `gh pr create` succeeds unless told otherwise. + */ + const standIn = ({ + apiFails = false, + bodyFails = false, + commits = [], + createFails = false, + dispatch = true, + listFails = false, + prs = [], + target = TARGET, + title = TITLE, + }: { + readonly apiFails?: boolean; + readonly bodyFails?: boolean; + /** Each commit's author and committer email. */ + readonly commits?: readonly (readonly [string, string])[]; + readonly createFails?: boolean; + readonly dispatch?: boolean; + readonly listFails?: boolean; + readonly prs?: readonly OpenPr[]; + /** The night's marker line, as `$out/target` holds it. */ + readonly target?: string; + readonly title?: string; + }): string => { + /** One `case` arm's body: fail, answer nothing, or print each line. */ + const answer = (fails: boolean, lines: readonly string[]): string => { + if (fails) { + return "return 1"; + } + if (lines.length === 0) { + return "return 0"; + } + return `printf '%s\\n' ${lines.map((line) => JSON.stringify(line)).join(" ")}`; + }; + /** The pulls arm: each pull request answered only for its own owner. */ + const pullsArm = listFails + ? [" return 1"] + : [ + ' case "$2" in', + ...[...new Set(prs.map((pr) => pr.owner))].map( + (owner) => + ` *"head=${owner}:chore/host-bump"*) ${answer( + false, + prs + .filter((pr) => pr.owner === owner) + .map((pr) => `${pr.number} ${pr.title}`) + )} ;;` + ), + " esac", + ]; + /** The body arm: each pull request's body, by its number. */ + const bodyArm = [ + ' case "$2" in', + ...prs.map((pr) => { + const lines = ["The nightly proposes.", "", pr.target] + .map((line) => JSON.stringify(line)) + .join(" "); + const each = pr.crlf === true ? "%s\\r\\n" : "%s\\n"; + return ` */pulls/${pr.number}) ${ + bodyFails ? "return 1" : `printf '${each}' ${lines}` + } ;;`; + }), + " esac", + ]; + return [ + BASH_3, + 'out="$RUNNER_TEMP/host-bump"; mkdir -p "$out"', + `printf '%s\\n' ${JSON.stringify(title)} > "$out/title"`, + `printf '%s\\n' ${JSON.stringify(target)} > "$out/target"`, + `printf 'body\\n%s\\n' ${JSON.stringify(target)} > "$out/body.md"`, + `printf 'test/support/host-versions.ts\\npackage.json\\n' > "$out/files"`, + ...(dispatch ? ['echo floor > "$out/dispatch"'] : []), + 'git() { echo "git $*" >> "$CALLS"; }', + "gh() {", + ' echo "gh $* [token $GH_TOKEN]" >> "$CALLS"', + ' case "$1 $2" in', + ' "api repos/owner/repo/pulls?"*)', + ...pullsArm, + " ;;", + ' "api repos/owner/repo/pulls/"*)', + ...bodyArm, + " ;;", + // The compare arm prints what the step's `--jq` selects: the + // committer's email only when the expression names it. + ' "api repos/owner/repo/compare/"*)', + ' case "$*" in', + ` *".commit.committer.email"*) ${answer(apiFails, commits.flat())} ;;`, + ` *) ${answer( + apiFails, + commits.map(([author]) => author) + )} ;;`, + " esac", + " ;;", + // gh's own `pr list --head` has no owner filter: every pull request + // from a branch of that name, by title. + ` "pr list") ${answer( + listFails, + prs.map((pr) => pr.title) + )} ;;`, + ` "pr create") ${createFails ? "return 1" : "return 0"} ;;`, + " esac", + "}", + ].join("\n"); + }; + const STEP_ENV = { + BOT_EMAIL, + BOT_NAME: envOf("BOT_NAME"), + DISPATCH_TOKEN: "dispatch-token", + GH_TOKEN: "pat", + GITHUB_REF_NAME: "main", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_REPOSITORY_OWNER: "owner", + }; + /** The push step, run against `standIn`; `open` is #7 from this repository. */ + const pushWith = ( + options: Parameters<typeof standIn>[0] & { + /** Shorthand for one pull request, #7, from this repository. */ + readonly open?: Partial<OpenPr>; + } + ) => + execute( + push?.run ?? "", + standIn({ + ...options, + prs: [ + ...(options.open === undefined + ? [] + : [ + { + number: 7, + owner: "owner", + target: OLDER, + title: "build: bump the alpha harness record to W", + ...options.open, + }, + ]), + ...(options.prs ?? []), + ], + }), + STEP_ENV + ); + + it("an open proposal with the same target is left alone: no push, no edit, no dispatch", () => { + for (const crlf of [false, true]) { + const same = pushWith({ open: { crlf, target: TARGET, title: TITLE } }); + expect(same.status, same.stderr).toBe(0); + expect(same.stdout, String(crlf)).toContain("left alone"); + // The question asked is this repository's own branch, by owner, and + // the target is read from the body of the pull request it answered. + expect(same.calls).toContain( + "gh api repos/owner/repo/pulls?state=open&head=owner:chore/host-bump" + ); + expect(same.calls).toContain( + "gh api repos/owner/repo/pulls/7 --jq .body" + ); + expect(same.calls).not.toContain("git "); + expect(same.calls).not.toMatch(/gh (pr create|pr edit|workflow run)/u); + } + }); + + it("the same title over a different target is a newer target: rebuilt while every commit is the nightly's", () => { + // A pi that moved after the dsh cut was proposed leaves the title as it + // was; the marker is what changed. + const newer = pushWith({ + commits: [[BOT_EMAIL, BOT_EMAIL]], + open: { target: OLDER, title: TITLE }, + }); + expect(newer.status, newer.stderr).toBe(0); + expect(newer.stdout).not.toContain("left alone"); + expect(newer.calls).toContain("git push --force"); + expect(newer.calls).toContain("gh pr edit 7 --title"); + // A different title over the same target is the same target. + const retitled = pushWith({ + open: { target: TARGET, title: "build: a reviewer's title" }, + }); + expect(retitled.status, retitled.stderr).toBe(0); + expect(retitled.stdout).toContain("left alone"); + expect(retitled.calls).not.toContain("git "); + }); + + it("with none open it pushes, opens the pull request and dispatches the floor cells with the keyed tier off", () => { + const fresh = pushWith({}); + expect(fresh.status, fresh.stderr).toBe(0); + const calls = fresh.calls.split("\n"); + expect(calls).toContain("git checkout -B chore/host-bump"); + // Each file is its own argument, read back from the list. + expect(calls).toContain( + "git add -- test/support/host-versions.ts package.json" + ); + expect(calls).toContain( + "git push --force https://x-access-token:pat@github.com/owner/repo.git chore/host-bump" + ); + expect(fresh.calls).toContain("gh pr create --head chore/host-bump"); + expect(fresh.calls).not.toContain("gh pr edit"); + expect(calls).toContain( + "gh workflow run ci.yml --ref chore/host-bump -f keyed=none [token dispatch-token]" + ); + // No dispatch file, no dispatch. + const quiet = pushWith({ dispatch: false }); + expect(quiet.status, quiet.stderr).toBe(0); + expect(quiet.calls).not.toContain("gh workflow run"); + // A pull request that cannot be opened fails the run by name. + const refused = pushWith({ createFails: true }); + expect(refused.status).toBe(1); + expect(refused.stdout).toContain( + "::error::could not open the bump pull request" + ); + }); + + it("a fork's pull request from a branch of the same name is not the proposal", () => { + const forkOnly: readonly OpenPr[] = [ + { number: 9, owner: "someone", target: TARGET, title: TITLE }, + ]; + // Known answer first: the stand-in answers the fork's pull request to a + // query that names the fork's owner, so the case below can only pass by + // the step's own filter. + const asked = execute( + 'gh api "repos/owner/repo/pulls?state=open&head=someone:chore/host-bump" --jq x', + standIn({ prs: forkOnly }), + STEP_ENV + ); + expect(asked.status, asked.stderr).toBe(0); + expect(asked.stdout).toBe(`9 ${TITLE}\n`); + // Its target equals the night's: read as the proposal, it would suppress + // the bump. It is not read, so the proposal is opened. + const sameTarget = pushWith({ prs: forkOnly }); + expect(sameTarget.status, sameTarget.stderr).toBe(0); + expect(sameTarget.stdout).not.toContain("left alone"); + expect(sameTarget.calls).not.toContain("pulls/9"); + expect(sameTarget.calls).not.toContain("compare/"); + expect(sameTarget.calls).toContain("gh pr create --head chore/host-bump"); + // Beside the repository's own pull request it changes nothing: the own + // one is compared, rebuilt and edited by its number. + const beside = pushWith({ + commits: [[BOT_EMAIL, BOT_EMAIL]], + open: {}, + prs: [{ number: 9, owner: "someone", target: OLDER, title: TITLE }], + }); + expect(beside.status, beside.stderr).toBe(0); + expect(beside.calls).toContain("git push --force"); + expect(beside.calls).toContain("gh pr edit 7 --title"); + expect(beside.calls).not.toContain("gh pr edit 9"); + }); + + it("a newer target rebuilds an open proposal only while every commit on it is the nightly's own, as author and as committer", () => { + const ours = pushWith({ + commits: [[BOT_EMAIL, BOT_EMAIL]], + open: {}, + }); + expect(ours.status, ours.stderr).toBe(0); + expect(ours.calls).toContain( + "gh api repos/owner/repo/compare/main...chore/host-bump" + ); + expect(ours.calls).toContain("git push --force"); + expect(ours.calls).toContain("gh pr edit 7 --title"); + expect(ours.calls).not.toContain("gh pr create"); + + for (const [label, commits] of [ + // A reviewer's own commit on the branch. + [ + "authored", + [ + [BOT_EMAIL, BOT_EMAIL], + ["reviewer@example.com", "reviewer@example.com"], + ], + ], + // A reviewer's amend, rebase or fixup squash of the nightly's commit: + // the bot stays the author, the reviewer is the committer. + ["amended", [[BOT_EMAIL, "reviewer@example.com"]]], + ] as const) { + const reviewed = pushWith({ commits, open: {} }); + expect(reviewed.status, label + reviewed.stderr).toBe(0); + expect(reviewed.stdout, label).toMatch( + /^::warning::the open bump pull request carries commits the nightly did not make/mu + ); + expect(reviewed.calls, label).not.toContain("git "); + expect(reviewed.calls, label).not.toMatch( + /gh (pr create|pr edit|workflow run)/u + ); + } + // Known answer: the bot address the step compares with is the one it + // commits as. + expect(BOT_EMAIL).toMatch( + /github-actions\[bot\]@users\.noreply\.github\.com$/u + ); + }); + + it("a question gh could not answer pushes nothing", () => { + for (const failing of [ + pushWith({ listFails: true }), + pushWith({ bodyFails: true, open: {} }), + pushWith({ apiFails: true, open: {} }), + // Two of this repository's own: which one is the proposal is unknown. + pushWith({ + open: {}, + prs: [ + { number: 8, owner: "owner", target: OLDER, title: "build: another" }, + ], + }), + ]) { + expect(failing.status).toBe(1); + expect(failing.stdout).toMatch(/^::error::/mu); + expect(failing.calls).not.toContain("git "); + } + }); }); diff --git a/test/keyed-probes.test.ts b/test/keyed-probes.test.ts index 1f08cab6..7af4a829 100644 --- a/test/keyed-probes.test.ts +++ b/test/keyed-probes.test.ts @@ -70,6 +70,7 @@ import { SESSION_HEADER, } from "./support/src.ts"; import { blankCommentLines } from "./support/strip-comments.ts"; +import { execute, loadWorkflow } from "./support/workflows.ts"; const spec = (id: string, provider_npm?: string): CatalogModelSpec => ({ context_window: 1000, @@ -1236,6 +1237,8 @@ describe("keyed probes · the CI wiring", () => { "test/support/keyless-matrix.ts", "test/support/e2e-env.ts", ".github/workflows/ci.yml", + // The install the job runs through. + ".github/actions/setup/action.yml", ]) { expect(armed.test(path), path).toBe(true); } @@ -1253,13 +1256,18 @@ describe("keyed probes · the CI wiring", () => { "test/keyed-probes.test.ts", "test/support/keyed-probes.ts.bak", "x/src/host/a.ts", + ".github/dependabot.yml", ]) { expect(armed.test(path), path).toBe(false); } }); - it("a match arms `probes` and a miss `none`; schedule and dispatch arm `full`", () => { - expect(tierStep).toContain("schedule|workflow_dispatch) tier=full"); + it("the tier step's text: a match arms `probes` and a miss `none`; schedule arms `full`, a dispatch its `keyed` input (`full` when empty)", () => { + expect(tierStep).toContain("schedule) tier=full ;;"); + expect(tierStep).toContain( + ['workflow_dispatch) tier="$', '{KEYED_INPUT:-full}" ;;'].join("") + ); + expect(tierStep).toContain(`KEYED_INPUT: ${expression("inputs.keyed")}`); // The arms in order: the grep, then `probes`, then `else`, then `none`. // grep reads a here-string: piped, `grep -q` exiting on an early match // can kill the writer with SIGPIPE, which pipefail reads as a miss. @@ -1269,11 +1277,70 @@ describe("keyed probes · the CI wiring", () => { expect(tierStep).not.toMatch(/\|\s*grep/u); }); + it("the tier step, executed: schedule arms `full`, a dispatch its `keyed` input (`full` when empty), a push `none`, a pull request `probes` on a match and `none` on a miss", () => { + const parsed = (loadWorkflow("ci.yml").jobs.e2e?.steps ?? []).find( + (step) => step.id === "tier" + ); + // The env the step reads is the three lines CI sets on every event (an + // input or a base ref that does not apply is empty, never unset). + expect(Object.keys(parsed?.env ?? {}).toSorted()).toStrictEqual([ + "BASE_REF", + "EVENT", + "KEYED_INPUT", + ]); + /** The step run for one event, with `git diff` answering `changed`. */ + const tierFor = ( + event: string, + { changed = [], keyed = "" }: { changed?: string[]; keyed?: string } = {} + ) => { + const result = execute(parsed?.run ?? "", gitAnswering(changed), { + BASE_REF: event === "pull_request" ? "main" : "", + EVENT: event, + KEYED_INPUT: event === "workflow_dispatch" ? keyed : "", + }); + expect(result.status, `${event} ${result.stderr}`).toBe(0); + return result; + }; + const tier = (...args: Parameters<typeof tierFor>): string => + tierFor(...args).output; + expect(tier("schedule")).toBe("keyed=full\n"); + // The nightly bump's floor-cell dispatch passes `none`: it spends no key. + expect(tier("workflow_dispatch", { keyed: "none" })).toBe("keyed=none\n"); + expect(tier("workflow_dispatch", { keyed: "" })).toBe("keyed=full\n"); + expect(tier("workflow_dispatch", { keyed: "full" })).toBe("keyed=full\n"); + expect(tier("push")).toBe("keyed=none\n"); + const matched = tierFor("pull_request", { + changed: ["README.md", "src/host/route-claim.ts"], + }); + expect(matched.output).toBe("keyed=probes\n"); + // Known answer: the diff it read is the pull request's own. + expect(matched.calls).toBe("git diff --name-only origin/main...HEAD\n"); + expect(tier("pull_request", { changed: ["README.md"] })).toBe( + "keyed=none\n" + ); + // Only a pull request asks git. + expect(tierFor("schedule").calls).toBe(""); + // A diff that cannot be computed fails the step: it never arms `none`. + const failed = execute(parsed?.run ?? "", GIT_FAILS, { + BASE_REF: "main", + EVENT: "pull_request", + KEYED_INPUT: "", + }); + expect(failed.calls).toBe("git diff --name-only origin/main...HEAD\n"); + expect(failed.status).not.toBe(0); + expect(failed.output).toBe(""); + }); + it("every event the job runs on but schedule, dispatch and a pull request arms `none`: a push to main spends no key", () => { const arms = [...tierStep.matchAll(/^\s+([^\s()]+)\)/gmu)].map( (match) => match[1] ); - expect(arms).toEqual(["schedule|workflow_dispatch", "pull_request", "*"]); + expect(arms).toEqual([ + "schedule", + "workflow_dispatch", + "pull_request", + "*", + ]); expect(tierStep).toMatch(/\n\s+\*\) tier=none ;;\n\s+esac/u); }); @@ -1389,6 +1456,24 @@ describe("keyed probes · the CI wiring", () => { }); }); +/** `git` as a function: records its call and prints `changed`, one per line. */ +const gitAnswering = (changed: readonly string[]): string => + [ + "git() {", + ' echo "git $*" >> "$CALLS"', + ` printf '%s\\n' ${changed.map((path) => JSON.stringify(path)).join(" ")}`, + "}", + ].join("\n"); + +/** `git` as a function that fails, as a diff against an unfetched base does. */ +const GIT_FAILS = [ + "git() {", + ' echo "git $*" >> "$CALLS"', + ' echo "fatal: bad revision" >&2', + " return 128", + "}", +].join("\n"); + describe("the e2e-host job · its pull-request scope", () => { const ci = readFileSync( new URL("../.github/workflows/ci.yml", import.meta.url), @@ -1416,6 +1501,7 @@ describe("the e2e-host job · its pull-request scope", () => { "test/support/e2e-env.ts", "scripts/host-drift.ts", ".github/workflows/ci.yml", + ".github/actions/setup/action.yml", "vitest.e2e.config.ts", "vite.config.ts", ]) { @@ -1429,10 +1515,41 @@ describe("the e2e-host job · its pull-request scope", () => { "test/support/e2e-env.ts.bak", "test/e2e-env.test.ts", "patches/pi.patch", + ".github/dependabot.yml", ]) { expect(runs.test(path), path).toBe(false); } }); + + it("the scope step, executed: anything but a pull request runs, a pull request runs on a match and not on a miss, and a diff that cannot be computed fails the step", () => { + const parsed = (loadWorkflow("ci.yml").jobs["e2e-host"]?.steps ?? []).find( + (step) => step.id === "scope" + ); + expect(parsed?.run).toBeDefined(); + const scopeFor = (event: string, prelude: string) => + execute(parsed?.run ?? "", prelude, { + BASE_REF: event === "pull_request" ? "main" : "", + EVENT: event, + }); + const nightly = scopeFor("schedule", gitAnswering([])); + expect(nightly.status, nightly.stderr).toBe(0); + expect(nightly.output).toBe("run=true\n"); + expect(nightly.calls).toBe(""); + const matched = scopeFor( + "pull_request", + gitAnswering(["README.md", "src/host/route-claim.ts"]) + ); + expect(matched.status, matched.stderr).toBe(0); + expect(matched.output).toBe("run=true\n"); + expect(matched.calls).toBe("git diff --name-only origin/main...HEAD\n"); + const missed = scopeFor("pull_request", gitAnswering(["README.md"])); + expect(missed.status, missed.stderr).toBe(0); + expect(missed.output).toBe("run=false\n"); + const failed = scopeFor("pull_request", GIT_FAILS); + expect(failed.calls).toBe("git diff --name-only origin/main...HEAD\n"); + expect(failed.status).not.toBe(0); + expect(failed.output).toBe(""); + }); }); describe("keyed probes · the variants on the default completions cell (L4, L5, L9, L10)", () => { diff --git a/test/mock/cli-version.mock.ts b/test/mock/cli-version.mock.ts new file mode 100644 index 00000000..c5172594 --- /dev/null +++ b/test/mock/cli-version.mock.ts @@ -0,0 +1,145 @@ +/** + * The CLI-version gate's registry half (`scripts/check-vendor-version.ts`), + * run against a loopback registry in a throwaway tree: which document it asks + * for, what it says when the pin is the published version, and how it names + * a `latest` that left the pinned line. The rules it applies are + * `test/vendor-version.test.ts`'s; this binds a socket, so it is the mock + * suite's. + * + * @module test/mock/cli-version.mock + */ +import { execFile } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +const ROOT = join(import.meta.dirname, "..", ".."); +const SCRIPT = join(ROOT, "scripts", "check-vendor-version.ts"); + +/** + * The pin the throwaway tree declares. Built from parts: this file is in the + * real copy scan's path, so a spelled-out version here would be a copy. + */ +const PIN = ["0", "0", "1"].join("."); + +/** A loopback registry answering every request with `answer`, recording paths. */ +const serve = async ( + answer: Readonly<Record<string, string>> | number +): Promise<{ asked: string[]; server: Server; url: string }> => { + const asked: string[] = []; + const server = createServer((request, response) => { + asked.push(request.url ?? ""); + if (typeof answer === "number") { + response.writeHead(answer); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(answer)); + }); + await new Promise<void>((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const { port } = server.address() as AddressInfo; + return { asked, server, url: `http://127.0.0.1:${port}` }; +}; + +/** The script, in a tree whose pin and one doc copy agree, asked `url`. */ +const check = async ( + url: string +): Promise<{ status: number; stderr: string; stdout: string }> => { + const root = mkdtempSync(join(tmpdir(), "cli-version-registry-")); + try { + for (const [path, text] of Object.entries({ + "README.md": "# fixture\n", + "docs/x.md": `User-Agent: opencode/${PIN}\n`, + "src/identity.ts": `export const OPENCODE_CLI_VERSION = "${PIN}";\n`, + })) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); + } + return await new Promise((resolve) => { + execFile( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + SCRIPT, + "--registry", + url, + ], + { cwd: root }, + (error, stdout, stderr) => { + resolve({ + status: error === null ? 0 : Number(error.code ?? 1), + stderr, + stdout, + }); + } + ); + }); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}; + +describe("cli:version · the registry's dist-tags", () => { + let server: Server | undefined; + afterEach(() => { + server?.close(); + server = undefined; + }); + + it("asks the registry for dist-tags, passes on the pinned latest, and names a bump and a generation change apart", async () => { + const snapshot = ["0.0.0", "dev", "1"].join("-"); + const current = await serve({ dev: snapshot, latest: PIN }); + ({ server } = current); + const ok = await check(current.url); + expect(current.asked, JSON.stringify(ok)).toStrictEqual([ + "/-/package/opencode-ai/dist-tags", + ]); + expect(ok.stderr).toBe(""); + expect(ok.status).toBe(0); + const [first, second] = ok.stdout.split("\n"); + expect(first).toMatch( + new RegExp( + `^ok {3}opencode/${PIN.replaceAll(".", "\\.")} is the published version`, + "u" + ) + ); + expect(second).toContain(`dev ${snapshot}`); + expect(second).toContain("not claimed"); + // A snapshot tag says nothing of its build's generation: most are 1.x + // feature branches, so no User-Agent format is asserted for them. + expect(second).not.toMatch(/opencode\/<channel>|2\.x/u); + server.close(); + + // A `latest` on the pinned major, one patch on: a bump, not a + // generation change. Built from parts, as PIN is. + const next = await serve({ latest: ["0", "0", "2"].join(".") }); + ({ server } = next); + const bump = await check(next.url); + expect(bump.status).toBe(1); + const [bumpLine = ""] = bump.stderr.split("\n"); + expect(bumpLine).toMatch(/^error: .*bump OPENCODE_CLI_VERSION/u); + expect(bumpLine).not.toMatch(/generation/u); + server.close(); + + const moved = await serve({ latest: "2.0.0" }); + ({ server } = moved); + const generation = await check(moved.url); + expect(generation.status).toBe(1); + expect(generation.stderr.split("\n")[0]).toMatch(/^error: .*generation/u); + server.close(); + + const refusing = await serve(500); + ({ server } = refusing); + const refused = await check(refusing.url); + expect(refused.status).toBe(1); + expect(refused.stderr).toMatch(/^error: the registry answered 500/u); + }); +}); diff --git a/test/mock/host-drift-cli.mock.ts b/test/mock/host-drift-cli.mock.ts index 501d808a..2f5e0a1b 100644 --- a/test/mock/host-drift-cli.mock.ts +++ b/test/mock/host-drift-cli.mock.ts @@ -2,15 +2,35 @@ * The nightly host-drift report's CLI (`scripts/report-host-drift.ts`), run * as CI runs it against a loopback registry: what it prints, that it asked * for every package, and that a refusing registry still exits 0. The rules it - * prints are `test/host-drift.test.ts`'s. + * prints are `test/host-drift.test.ts`'s. The bump proposal's CLI + * (`scripts/propose-host-bump.ts`) runs the same way: what it plans for each + * channel, that a refusing registry or a cut inside pnpm's release-age window + * is a warning and exit 0, the `--out` files CI's push step reads (with no + * tracked file touched without `--write`), that a pi move carries a channel + * waiting on its own cut (`coupleSharedPi`), and, on a scratch copy + * (`--root`), that a successful `--write` lists exactly the files it changed + * for the push step to `git add`, and one whose pnpm step fails puts every + * file back and exits 1. Its rules are `test/host-bump.test.ts`'s. * * @module test/mock/host-drift-cli.mock */ import { execFile } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; -import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import semver from "semver"; import { describe, expect, it } from "vitest"; import { @@ -19,7 +39,12 @@ import { REGISTRY_PACKAGES, type RegistryView, } from "../../scripts/host-drift.ts"; -import { ALPHA_NEXT, registryOf } from "../support/host-drift-fixture.ts"; +import { + ALPHA_NEXT, + NEXT_ALPHA, + registryOf, + withCut, +} from "../support/host-drift-fixture.ts"; import { CHANNELS } from "../support/host-versions.ts"; const ROOT = join(import.meta.dirname, "..", ".."); @@ -128,3 +153,382 @@ describe("host drift · the CLI", () => { } }); }); + +describe("host bump · the proposal's CLI", () => { + const HOUR = 60 * 60 * 1000; + + /** + * The view by package name, with `time` on dsh and pi as a full packument + * has it: every version published `age` ago, or as `ages` says by version. + */ + const published = ( + view: RegistryView, + age: number, + ages: Readonly<Record<string, number>> = {} + ): Record<string, Packument & { time?: Record<string, string> }> => { + const at = (version: string) => + new Date(Date.now() - (ages[version] ?? age)).toISOString(); + const timed = (packument: Packument) => ({ + ...packument, + time: Object.fromEntries( + Object.keys(packument.versions ?? {}).map((version) => [ + version, + at(version), + ]) + ), + }); + return Object.fromEntries( + Object.entries(REGISTRY_PACKAGES).map(([role, name]) => { + const packument = view[role as keyof RegistryView]; + return [ + name, + role === "dsh" || role === "pi" ? timed(packument) : packument, + ]; + }) + ); + }; + + const propose = async (url: string, ...more: string[]) => + run( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(ROOT, "scripts", "propose-host-bump.ts"), + "--registry", + url, + ...more, + ], + { cwd: ROOT, env: { ...process.env, DSH_CHANNEL: "" } } + ); + + /** The files a bump may move, from the repository root. */ + const MOVABLE = [ + "test/support/host-versions.ts", + "package.json", + "README.md", + "README.zh-CN.md", + ...new Set(Object.values(CHANNELS).map((record) => record.lockfile)), + ]; + const snapshot = (root: string): Map<string, string> => + new Map( + MOVABLE.map((file) => [file, readFileSync(join(root, file), "utf8")]) + ); + + /** Alpha's next cut moving dsh alone: no family field, so no floor cells. */ + const DSH_ONLY: RegistryView = (() => { + const cut = withCut(registryOf(CHANNELS), CHANNELS.alpha, NEXT_ALPHA.dsh); + return { + ...cut, + dsh: { + ...cut.dsh, + "dist-tags": { + alpha: NEXT_ALPHA.dsh, + [CHANNELS.rc.distTag]: CHANNELS.rc.dsh, + }, + }, + }; + })(); + + /** The next pi patch, which both bridges' `^1.x` range admits. */ + const NEXT_PI = semver.inc(CHANNELS.alpha.pi, "patch") ?? ""; + /** + * Alpha's next cut published hours ago, and the next pi two days ago: + * alpha waits on its own cut while rc moves pi. + */ + const PI_WHILE_ALPHA_WAITS = published( + { + ...DSH_ONLY, + pi: { + versions: { + ...DSH_ONLY.pi.versions, + [NEXT_PI]: {}, + }, + }, + }, + 48 * HOUR, + { [NEXT_ALPHA.dsh]: 3 * HOUR } + ); + + it("moves a channel waiting on its own cut to the pi the other proposes, since both lockfiles hold one pi", async () => { + expect(NEXT_PI).not.toBe(""); + const { server, url } = await serve(PI_WHILE_ALPHA_WAITS); + try { + const { stdout } = await propose(url); + const lines = stdout.split("\n").filter((line) => line !== ""); + expect(lines.find((line) => line.startsWith("alpha: "))).toMatch( + new RegExp( + `^alpha: data: pi ${CHANNELS.alpha.pi} -> ${NEXT_PI}; pi moves with rc`, + "u" + ) + ); + expect(lines.find((line) => line.startsWith("rc: "))).toBe( + `rc: data: pi ${CHANNELS.rc.pi} -> ${NEXT_PI}` + ); + expect(stdout).not.toContain("::warning::"); + } finally { + server.close(); + } + }); + + it("a successful --write lists exactly the files it changed, for the push step to add, and the body and target name the records", async () => { + const temp = mkdtempSync(join(tmpdir(), "host-bump-write-")); + try { + // A scratch copy of the files a bump moves, a pnpm that succeeds and a + // relock script that succeeds, each recording its call. + const root = join(temp, "root"); + for (const file of MOVABLE) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + copyFileSync(join(ROOT, file), join(root, file)); + } + const before = snapshot(root); + const calls = join(temp, "calls"); + mkdirSync(join(root, "scripts")); + writeFileSync( + join(root, "scripts", "use-channel.ts"), + [ + 'import { appendFileSync } from "node:fs";', + 'appendFileSync(process.env.CALLS ?? "", "use-channel " + process.argv.slice(2).join(" ") + "\\n");', + "", + ].join("\n") + ); + const bin = join(temp, "bin"); + mkdirSync(bin); + writeFileSync( + join(bin, "pnpm"), + '#!/bin/sh\necho "pnpm $*" >> "$CALLS"\n' + ); + chmodSync(join(bin, "pnpm"), 0o755); + const out = join(temp, "out"); + const { server, url } = await serve(PI_WHILE_ALPHA_WAITS); + try { + await run( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(ROOT, "scripts", "propose-host-bump.ts"), + "--registry", + url, + "--write", + "--out", + out, + "--root", + root, + ], + { + cwd: ROOT, + env: { + ...process.env, + CALLS: calls, + DSH_CHANNEL: "", + PATH: `${bin}:${process.env.PATH ?? ""}`, + }, + } + ); + } finally { + server.close(); + } + // It ran the installs it promises, pi's update included, in the root. + expect(readFileSync(calls, "utf8")).toBe( + [ + "pnpm install --no-frozen-lockfile", + "pnpm update @earendil-works/pi-ai --no-save", + "use-channel rc --relock", + "", + ].join("\n") + ); + const after = snapshot(root); + const changed = MOVABLE.filter( + (file) => after.get(file) !== before.get(file) + ); + // Known answer: the records moved, both to the one pi. + expect(changed).toContain("test/support/host-versions.ts"); + const records = after.get("test/support/host-versions.ts") ?? ""; + expect(records.match(new RegExp(`pi: "${NEXT_PI}",`, "gu"))).toHaveLength( + 2 + ); + // `files` is exactly what changed on disk, one per line. + expect(readFileSync(join(out, "files"), "utf8")).toBe( + changed.map((file) => `${file}\n`).join("") + ); + const body = readFileSync(join(out, "body.md"), "utf8"); + expect(body).toContain("**Files moved:**"); + for (const file of changed) { + expect(body).toContain(`- \`${file}\``); + } + expect(body).not.toContain("Left for review"); + // The target is the body's last line, and names both records. + const target = readFileSync(join(out, "target"), "utf8").trimEnd(); + expect(body.trimEnd().split("\n").at(-1)).toBe(target); + expect(target).toContain(`"alpha":{`); + expect(target).toContain(`"rc":{`); + expect(readFileSync(join(out, "title"), "utf8")).toBe( + `build: bump the alpha and rc harness records to pi ${NEXT_PI}\n` + ); + expect(existsSync(join(out, "dispatch"))).toBe(false); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }); + + it("leaves what CI's push step reads in --out, and touches no tracked file without --write", async () => { + const before = snapshot(ROOT); + const temp = mkdtempSync(join(tmpdir(), "host-bump-out-")); + try { + for (const [label, view, dispatch] of [ + ["family", ALPHA_NEXT, true], + ["dsh-only", DSH_ONLY, false], + ] as const) { + const out = join(temp, label); + const { server, url } = await serve(published(view, 48 * HOUR)); + try { + await propose(url, "--out", out); + } finally { + server.close(); + } + expect(readFileSync(join(out, "title"), "utf8"), label).toMatch( + /^build: bump the alpha harness record to /u + ); + expect(readFileSync(join(out, "body.md"), "utf8"), label).toContain( + NEXT_ALPHA.dsh + ); + // Nothing was written, so nothing is listed to commit. + expect(readFileSync(join(out, "files"), "utf8"), label).toBe(""); + expect(existsSync(join(out, "dispatch")), label).toBe(dispatch); + } + // Nothing to propose: no title, so the push step never runs. + const quiet = join(temp, "current"); + const { server, url } = await serve( + published(registryOf(CHANNELS), 48 * HOUR) + ); + try { + await propose(url, "--out", quiet); + } finally { + server.close(); + } + expect(existsSync(join(quiet, "title"))).toBe(false); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + expect(snapshot(ROOT)).toStrictEqual(before); + }); + + it("a --write whose pnpm step fails puts every file back and exits 1, proposing nothing", async () => { + const temp = mkdtempSync(join(tmpdir(), "host-bump-root-")); + try { + // A scratch copy of the files a bump moves, and a pnpm that fails. + const root = join(temp, "root"); + for (const file of MOVABLE) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + copyFileSync(join(ROOT, file), join(root, file)); + } + const before = snapshot(root); + const bin = join(temp, "bin"); + mkdirSync(bin); + writeFileSync( + join(bin, "pnpm"), + '#!/bin/sh\necho "stub pnpm: $*" >&2\nexit 1\n' + ); + chmodSync(join(bin, "pnpm"), 0o755); + const out = join(temp, "out"); + const { server, url } = await serve(published(ALPHA_NEXT, 48 * HOUR)); + const result = await new Promise<{ + code: number | null; + stderr: string; + stdout: string; + }>((resolve) => { + execFile( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(ROOT, "scripts", "propose-host-bump.ts"), + "--registry", + url, + "--write", + "--out", + out, + "--root", + root, + ], + { + cwd: ROOT, + env: { + ...process.env, + DSH_CHANNEL: "", + PATH: `${bin}:${process.env.PATH ?? ""}`, + }, + }, + (error, stdout, stderr) => { + let code: number | null = 0; + if (error !== null) { + code = typeof error.code === "number" ? error.code : null; + } + resolve({ code, stderr, stdout }); + } + ); + }).finally(() => { + server.close(); + }); + expect(result.code, result.stdout).toBe(1); + // Known answer: it got as far as the pnpm step, having written. + expect(result.stderr).toContain( + "stub pnpm: install --no-frozen-lockfile" + ); + expect(result.stdout).toMatch( + /^::error::the bump was not proposed: pnpm install --no-frozen-lockfile exited with 1; every tracked file it wrote is put back$/mu + ); + expect(snapshot(root)).toStrictEqual(before); + expect(existsSync(join(out, "title"))).toBe(false); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }); + + it("plans the next alpha cut as data and leaves rc current, writing nothing", async () => { + const { server, url } = await serve(published(ALPHA_NEXT, 48 * HOUR)); + try { + const { stdout, stderr } = await propose(url); + expect(stderr).toBe(""); + const lines = stdout.split("\n").filter((line) => line !== ""); + expect(lines.find((line) => line.startsWith("alpha: "))).toMatch( + new RegExp( + `^alpha: data: dsh ${CHANNELS.alpha.dsh} -> ${NEXT_ALPHA.dsh}`, + "u" + ) + ); + expect(lines).toContain( + `rc: current (dsh@${CHANNELS.rc.dsh}, pi ${CHANNELS.rc.pi})` + ); + expect( + lines.filter((line) => line.startsWith("::warning::")) + ).toStrictEqual([]); + } finally { + server.close(); + } + }); + + it("waits a night for a cut inside pnpm's release-age window", async () => { + const { server, url } = await serve(published(ALPHA_NEXT, HOUR)); + try { + const { stdout } = await propose(url); + expect(stdout).toMatch(/^::warning::alpha: waiting: /mu); + expect(stdout).not.toMatch(/^alpha: data:/mu); + } finally { + server.close(); + } + }); + + it("warns, and still exits 0, when the registry refuses", async () => { + const { server, url } = await serve(503); + try { + const { stdout } = await propose(url); + expect(stdout).toMatch( + /^::warning::host bump not proposed: the registry answered 503 for @/u + ); + } finally { + server.close(); + } + }); +}); diff --git a/test/no-local-paths.test.ts b/test/no-local-paths.test.ts index b96215af..9d10e8f3 100644 --- a/test/no-local-paths.test.ts +++ b/test/no-local-paths.test.ts @@ -19,12 +19,26 @@ import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; +import { secretsIn } from "../scripts/package-checks.ts"; import { CARD_FIELDS } from "./support/src.ts"; const ROOT = new URL("..", import.meta.url).pathname; -/** Directories a third party can read: the tree, plus the build output. */ -const SCANNED = ["src", "test", "docs", "scripts", "lib", "channels"]; +/** + * Directories a third party can read: the tree, the workflows that run on a + * public runner and print their own text into a public log, plus the build + * output. + */ +const SCANNED = [ + "src", + "test", + "docs", + "scripts", + "lib", + "channels", + "locale", + ".github", +]; const SCANNED_FILES = [ "AGENTS.md", "CONTRIBUTING.md", @@ -38,6 +52,14 @@ const SCANNED_FILES = [ const LOCAL_PATH = /(?:\/Users\/|\/home\/|C:\\Users\\|C:\/Users\/)(?!you(?:\/|\\|["'`\s]|$)|<name>)[A-Za-z0-9._-]+/gu; +/** + * A directory that exists on one kind of machine only: Homebrew's prefixes. + * Not a home directory, so it leaks no name, but a script that puts one on + * PATH works on the machine it was written on and nowhere else, and says + * which machine that was. + */ +const MACHINE_PATH = /\/opt\/homebrew\b|\/usr\/local\/Cellar\b/gu; + const walk = async (dir: string): Promise<string[]> => { let entries: string[]; try { @@ -55,19 +77,30 @@ const walk = async (dir: string): Promise<string[]> => { const info = await stat(full); if (info.isDirectory()) { found.push(...(await walk(full))); - } else if (/\.(?:ts|tsx|js|mjs|md|json|ya?ml)$/u.test(entry)) { + } else if (/\.(?:ts|tsx|js|mjs|md|json|ya?ml|sh)$/u.test(entry)) { found.push(full); } } return found; }; +/** Every file the scans read: the named root files and each walked directory. */ +const scannedFiles = async (): Promise<string[]> => { + const files = SCANNED_FILES.map((name) => join(ROOT, name)); + for (const dir of SCANNED) { + files.push(...(await walk(join(ROOT, dir)))); + } + return files; +}; + describe("the hard rule: no local path leaves the machine", () => { - it("finds no home directory in the tree or the build output", async () => { - const files = SCANNED_FILES.map((name) => join(ROOT, name)); - for (const dir of SCANNED) { - files.push(...(await walk(join(ROOT, dir)))); - } + it("finds no home directory or machine path in the tree, .github and shell scripts included", async () => { + const files = await scannedFiles(); + // Known answers: the walk reaches the workflows and the shell scripts, + // so a scan that skipped either could not pass by reading nothing. + const walked = files.map((file) => relative(ROOT, file)); + expect(walked).toContain(".github/workflows/ci.yml"); + expect(walked).toContain("scripts/capture-ui.sh"); const offenders: string[] = []; for (const file of files) { @@ -83,8 +116,10 @@ describe("the hard rule: no local path leaves the machine", () => { } catch { continue; } - for (const match of text.matchAll(LOCAL_PATH)) { - offenders.push(`${relative(ROOT, file)}: ${match[0]}`); + for (const pattern of [LOCAL_PATH, MACHINE_PATH]) { + for (const match of text.matchAll(pattern)) { + offenders.push(`${relative(ROOT, file)}: ${match[0]}`); + } } } @@ -113,6 +148,66 @@ describe("the hard rule: no local path leaves the machine", () => { for (const sample of forbidden) { expect(sample.match(LOCAL_PATH), sample).not.toBeNull(); } + // Built from parts: this file is scanned for the machine paths too. + expect( + ["", "opt", "homebrew", "bin"].join("/").match(MACHINE_PATH) + ).not.toBeNull(); + expect("/usr/local/bin".match(MACHINE_PATH)).toBeNull(); + }); +}); + +describe("no credential in the tree (moved from check.ts)", () => { + it("names each key shape and nothing that only resembles one", () => { + // Assembled at run time: a whole canary in this file would be a credential + // the scan below has to find. + const parts = { + a: "sk-ant-", + g: "ghp_", + m: "monid_live_", + t: "sk-tinyfish-", + }; + const found = [ + `${parts.m}AbCdEf1234567890`, + `${parts.t}Pm-QkiaUloPEyNN`, + `${parts.g}abcdefghijklmnopqrstuvwxyz0123456789`, + `${parts.a}api03-AbCdEf1234567890`, + ]; + for (const sample of found) { + expect(secretsIn(sample), sample.slice(0, 6)).toHaveLength(1); + } + for (const sample of [ + parts.m, + parts.t, + "$OPENCODE_GO_API_KEY", + "Authorization: Bearer <key>", + ]) { + expect(secretsIn(sample), sample).toStrictEqual([]); + } + }); + + it("finds none in the tree, the root files included", async () => { + const files = await scannedFiles(); + for (const entry of await readdir(ROOT)) { + const full = join(ROOT, entry); + const info = await stat(full); + if ( + info.isFile() && + /\.(?:ts|js|mjs|md|json|ya?ml)$/u.test(entry) && + !files.includes(full) + ) { + files.push(full); + } + } + // Non-vacuous: the walk read the tree, not an empty directory. + expect(files.length).toBeGreaterThan(100); + const hits: string[] = []; + for (const file of files) { + const text = await readFile(file, "utf8"); + for (const what of secretsIn(text)) { + hits.push(`${relative(ROOT, file)}: ${what}`); + } + } + expect(hits).toStrictEqual([]); }); }); diff --git a/test/node-engine.test.ts b/test/node-engine.test.ts index 918cbf10..c59d7a9b 100644 --- a/test/node-engine.test.ts +++ b/test/node-engine.test.ts @@ -11,6 +11,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import setup, { engineRefusal, floorMajorOf } from "./support/node-engine.ts"; +import { type Job, loadYaml, workflowFiles } from "./support/workflows.ts"; describe("node-engine guard", () => { it("reads a single >= floor and nothing else", () => { @@ -48,16 +49,40 @@ describe("node-engine guard", () => { }).not.toThrow(); }); - it("names the Node CI runs in .node-version, at or above the floor", () => { + it("names the Node CI runs in .node-version, which the setup action reads", () => { + const pkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8") + ) as { engines: { node: string } }; const pinned = Number( readFileSync(new URL("../.node-version", import.meta.url), "utf8").trim() ); - const ci = readFileSync( - new URL("../.github/workflows/ci.yml", import.meta.url), + expect(engineRefusal(`${pinned}.0.0`, pkg.engines.node)).toBeUndefined(); + // One source: the setup action reads the file, and no job restates it. + const action = readFileSync( + new URL("../.github/actions/setup/action.yml", import.meta.url), "utf8" ); - expect(engineRefusal(`${pinned}.0.0`, ">=24")).toBeUndefined(); - expect(ci).toContain(`node-version: ${pinned}`); + expect(action).toContain("cat .node-version"); + // The one job that passes its own Node is the engines floor's, at the + // floor `engines.node` declares, so raising one without the other fails. + const passed = workflowFiles() + .filter((file) => file.startsWith(".github/workflows/")) + .flatMap((file) => + Object.entries( + (loadYaml(file) as { jobs: Record<string, Job> }).jobs + ).flatMap(([name, job]) => + (job.steps ?? []) + .filter( + (step) => + step.uses === "./.github/actions/setup" && + step.with?.["node-version"] !== undefined + ) + .map((step) => [name, step.with?.["node-version"]]) + ) + ); + expect(passed).toStrictEqual([ + ["engine-floor", floorMajorOf(pkg.engines.node)], + ]); }); }); diff --git a/test/package-checks.test.ts b/test/package-checks.test.ts index 49e9de3f..a9c51a48 100644 --- a/test/package-checks.test.ts +++ b/test/package-checks.test.ts @@ -12,24 +12,36 @@ * is either a publish nobody checks or a check that can never pass. * * The functions are pure (`scripts/package-checks.ts`), so the fixtures below - * prove each one answers a case whose answer is known, and the real-tree cases - * run the same functions `scripts/check.ts` runs. + * prove each one answers a case whose answer is known before the real-tree + * cases trust it. The manifest checks `scripts/check.ts` used to run (peer + * ranges npm can read, the bundle fields, the harness surfaces, locale parity) + * live here too; the gate keeps only what needs a build or a registry: the + * freshness of `lib/` (`libFreshness`), the consumer install and the size + * tripwire. * * @module test/package-checks.test */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; import { + clientBundleProblems, coveredByFiles, exportsOutsideFiles, exportTargets, + libFreshness, + localeKeyPaths, + peerRangeProblems, publishListMismatch, releasePendingTargets, } from "../scripts/package-checks.ts"; +import * as packageChecks from "../scripts/package-checks.ts"; import { SCOPED_TARGETS } from "../scripts/scoped-targets.ts"; +import { en as cardCopy } from "./support/src.ts"; const ROOT = join(import.meta.dirname, ".."); @@ -44,6 +56,17 @@ const readPackage = (): { files?: readonly string[]; }; +/** + * A script's code with its comment lines dropped, line by line: a + * block-comment regex would eat code between a glob such as `src/*` and the + * next `*\/`. + */ +const scriptCode = (file: string): string => + readFileSync(join(ROOT, "scripts", file), "utf8") + .split("\n") + .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/u.test(line)) + .join("\n"); + describe("package checks · exports inside files", () => { it("flattens an exports map to its targets, conditions included", () => { expect( @@ -147,18 +170,12 @@ describe("package checks · the publish list and the verify list agree", () => { expect(pending).toContain("@viztor/dsh-opencode"); }); - it("the gate and the publish script read those lists rather than keeping their own", () => { - // Structural, because running either script from the suite would copy the - // package per target (publish-scoped) or install it (check). The pure - // helpers are exercised above; this pins that the scripts still use them. - // Comment lines dropped line by line: a block-comment regex would eat - // code between a glob such as `src/*` and the next `*/`. - const code = (file: string): string => - readFileSync(join(ROOT, "scripts", file), "utf8") - .split("\n") - .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/u.test(line)) - .join("\n"); - const publish = code("publish-scoped.ts"); + it("the publish script reads the list rather than keeping its own", () => { + // Structural, because running the script from the suite would copy the + // package per target. The pure helpers are exercised above, and the + // real-tree cases here are the whole check: the gate (scripts/check.ts) + // no longer repeats them. + const publish = scriptCode("publish-scoped.ts"); expect(publish).toMatch( /import \{[^}]*\bSCOPED_TARGETS\b[^}]*\} from "\.\/scoped-targets\.ts";/u ); @@ -168,9 +185,209 @@ describe("package checks · the publish list and the verify list agree", () => { // The wrapper branch names the deprecated alias on purpose: it is the one // target whose manifest is rewritten rather than copied. expect(publish).toContain('"@viztor/dsh-opencode"'); - const check = code("check.ts"); - expect(check).toMatch(/\bexportsOutsideFiles\(/u); - expect(check).toMatch(/\bpublishListMismatch\(/u); - expect(check).toMatch(/\breleasePendingTargets\(/u); + }); +}); + +describe("package checks · lib/ is current", () => { + it("libFreshness: one rule for a missing, half-built or stale lib", () => { + expect( + libFreshness({ + artifacts: { "client.js": 10, "index.mjs": 10 }, + hasClientCjs: false, + newestSource: 5, + }) + ).toBeUndefined(); + // The rename step skipped: vp pack left lib/client.cjs, not the served + // lib/client.js, and the message names the step that makes it. + const halfBuilt = libFreshness({ + artifacts: { "client.js": undefined, "index.mjs": 10 }, + hasClientCjs: true, + newestSource: 5, + }); + expect(halfBuilt).toContain("client.js"); + expect(halfBuilt).toContain("name-client-bundle.ts"); + expect( + libFreshness({ + artifacts: { "client.js": 10, "index.mjs": 12 }, + hasClientCjs: false, + newestSource: 11, + }) + ).toContain("OLDER than src/"); + expect( + libFreshness({ + artifacts: { "client.js": undefined, "index.mjs": undefined }, + hasClientCjs: false, + newestSource: 5, + }) + ).toContain("pnpm run build"); + }); + + it("check.ts asks the rule once, and keeps only freshness, the consumer install and the size tripwire", () => { + // Structural, because running the gate from the suite would pack and + // install the package. The pure rule is exercised above. + const check = scriptCode("check.ts"); + expect([...check.matchAll(/\blibFreshness\(/gu)]).toHaveLength(1); + // The workflow rules are unit tests now (test/ci-wiring.test.ts). + expect(check).not.toContain(".github"); + // The only rules it reaches: freshness, imported, and the client half's + // loader, imported into the consumer's child. A pure rule moved back + // would need an import, and would fail here. + const relative = [ + ...check.matchAll(/^import\s+(?:[^;]*?)\s+from\s+"(\.[^"]+)";/gmsu), + ].map((match) => match[1]); + expect(relative).toStrictEqual(["./package-checks.ts"]); + expect(check).toMatch( + /^import \{ libFreshness \} from "\.\/package-checks\.ts";$/mu + ); + expect([...check.matchAll(/await import\(/gu)]).toHaveLength(2); + expect(check).toMatch( + /const \{ clientBundleProblems \} = await import\(\$\{JSON\.stringify\(pathToFileURL\(join\(ROOT, "scripts", "package-checks\.ts"\)\)\.href\)\}\);/u + ); + const moved = Object.keys(packageChecks).filter( + (name) => name !== "libFreshness" && name !== "clientBundleProblems" + ); + // Known answer: the rules that moved to vitest are among the exports. + for (const rule of ["peerRangeProblems", "localeKeyPaths", "secretsIn"]) { + expect(moved, rule).toContain(rule); + } + for (const name of moved) { + expect(check, name).not.toMatch(new RegExp(`\\b${name}\\b`, "u")); + } + for (const kept of [ + "CLIENT_BUDGET", + '"pack", "--json"', + '"install",', + "/client", + "clientBundleProblems(", + ]) { + expect(check, kept).toContain(kept); + } + }); + + it("the client half's rule loads lib/client.js as the host does, and names a bundle that registers the wrong id or answers another NS", () => { + const pkg = readPackage(); + const expected = { + ids: [pkg.name, "@viztor/dsh-opencode-patch"], + ns: pkg.name, + }; + const built = clientBundleProblems( + readFileSync(join(ROOT, "lib", "client.js"), "utf8"), + expected, + runInNewContext + ); + expect(built.problems).toStrictEqual([]); + // Known answer: the factory ran and asked the host for its modules. + expect(built.asked.length).toBeGreaterThan(0); + + /** A bundle registering `ids`, whose factory answers `ns`. */ + const planted = (ids: readonly string[], ns: string): string => + ids + .map( + (id) => + `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => { require("react"); return { NS: ${JSON.stringify(ns)}, apply() {} }; } });` + ) + .join("\n"); + expect( + clientBundleProblems( + planted(expected.ids, pkg.name), + expected, + runInNewContext + ).problems + ).toStrictEqual([]); + expect( + clientBundleProblems( + planted([pkg.name, "@someone/else"], pkg.name), + expected, + runInNewContext + ).problems + ).toStrictEqual([ + `the client bundle did not register @viztor/dsh-opencode-patch (it registered ${pkg.name}, @someone/else)`, + ]); + expect( + clientBundleProblems( + planted(expected.ids, "other"), + expected, + runInNewContext + ).problems + ).toStrictEqual(["the client factory answered NS other"]); + expect( + clientBundleProblems("throw new Error('boom')", expected, runInNewContext) + .problems + ).toStrictEqual(["the client bundle threw while loading: boom"]); + }); +}); + +describe("package checks · the manifest (moved from check.ts)", () => { + const manifest = JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf8") + ) as { + dsh?: { bundle?: { patch?: string } }; + main?: string; + peerDependencies?: Record<string, string>; + types?: string; + }; + + it("peerRangeProblems names a peer npm cannot read or a pin that orphans the plugin", () => { + const problems = peerRangeProblems({ a: "workspace:*", b: "1.2.3", c: "" }); + expect(problems).toHaveLength(3); + for (const [index, name] of ["a", "b", "c"].entries()) { + expect(problems[index]).toContain(`peer ${name} `); + } + expect( + peerRangeProblems({ d: "^1.0.0", e: ">=1 <2 || ~3.1.0" }) + ).toStrictEqual([]); + // Non-vacuous on the real manifest: it declares peers to check. + const peers = manifest.peerDependencies ?? {}; + expect(Object.keys(peers).length).toBeGreaterThanOrEqual(2); + expect(peerRangeProblems(peers)).toStrictEqual([]); + }); + + it("is a DSH bundle with an entry and types", () => { + const patch = manifest.dsh?.bundle?.patch; + expect(patch).toBeDefined(); + expect(existsSync(join(ROOT, patch ?? "missing"))).toBe(true); + expect(manifest.main).toBeTruthy(); + expect(manifest.types).toBeTruthy(); + }); + + it("the harness surfaces this package depends on are still exported", () => { + // Read from the INSTALLED declarations, as check.ts read them: the + // contract the plugin compiles against, not a copy of it. + const require = createRequire(join(ROOT, "package.json")); + const dir = dirname( + require.resolve("@deepseek-ai/dsh-typert-protocol/package.json") + ); + const typesDir = join(dir, "lib", "types"); + const files = existsSync(typesDir) + ? readdirSync(typesDir) + .filter((file) => file.endsWith(".d.ts")) + .map((file) => join(typesDir, file)) + : [join(dir, "lib", "index.d.ts")].filter((file) => existsSync(file)); + expect(files.length).toBeGreaterThan(0); + const dts = files.map((file) => readFileSync(file, "utf8")).join("\n"); + expect(dts).toMatch( + /(?:export declare class RemoteError|export \{[^}]*\bRemoteError\b)/u + ); + expect(dts).toMatch( + /export declare (?:abstract )?class TypertRemoteService/u + ); + expect(dts).toMatch(/interface TypertRemoteContribution\b/u); + }); + + it("the locales hold the same keys, and the bundle shows one display name", () => { + const [en, zh] = ["en", "zh"].map( + (lang) => + JSON.parse( + readFileSync(join(ROOT, "locale", `${lang}.json`), "utf8") + ) as { meta?: { title?: unknown } } + ); + // The reader bites: a nested key is a dotted path, a leaf is itself. + expect( + localeKeyPaths({ a: { b: 1, c: { d: "x" } }, e: [] }).toSorted() + ).toStrictEqual(["a.b", "a.c.d", "e"]); + const enKeys = localeKeyPaths(en).toSorted(); + expect(enKeys.length).toBeGreaterThan(0); + expect(localeKeyPaths(zh).toSorted()).toStrictEqual(enKeys); + expect(en?.meta?.title).toBe(cardCopy.title); }); }); diff --git a/test/support/host-versions.ts b/test/support/host-versions.ts index 11767b2f..11cfd740 100644 --- a/test/support/host-versions.ts +++ b/test/support/host-versions.ts @@ -50,6 +50,13 @@ import { join } from "node:path"; /** The two harness distribution channels, by their npm dist-tags' role. */ export type Channel = "alpha" | "rc"; +/** + * The dist-tags a record may name. The nightly bump proposal reads this list: + * a channel whose newest build moved under any other tag is a reviewed change + * (`range`), since writing the tag would fail this type. + */ +export const RECORD_TAGS = ["alpha", "latest", "next"] as const; + export interface ChannelRecord { /** `@deepseek-ai/cordis`, the floor of the bridge's `~` range. */ readonly cordis: string; @@ -61,7 +68,7 @@ export interface ChannelRecord { * drift report reads the newest build on the record's line under any tag * (`channelBuild` in `scripts/host-drift.ts`), this one included. */ - readonly distTag: "alpha" | "latest" | "next"; + readonly distTag: (typeof RECORD_TAGS)[number]; /** The `@deepseek-ai/dsh-*` cluster's version. */ readonly dsh: string; /** diff --git a/test/support/workflows.ts b/test/support/workflows.ts new file mode 100644 index 00000000..6c003d60 --- /dev/null +++ b/test/support/workflows.ts @@ -0,0 +1,367 @@ +/** + * The workflows under `.github/`, read as data: parsed, never matched as text. + * + * GitHub Actions cannot run here, so what a workflow DOES is decided by + * reading it the way the runner does: the YAML parsed, a step's `if:` + * evaluated for an event and a matrix cell, and a `run:` reduced to the + * commands it actually invokes. A step whose shell holds logic is run, with + * the commands it calls stood in for by shell functions (`execute`). Shared by + * `test/ci-wiring.test.ts`, `test/freshness-scope.test.ts`, + * `test/node-engine.test.ts` and `test/host-drift.test.ts`, so one reading of + * a workflow serves every rule that is about one. + * + * @module test/support/workflows + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; + +import { parse } from "yaml"; + +const ROOT = join(import.meta.dirname, "..", ".."); + +/** One step of a job or of a composite action, as parsed. */ +export interface Step { + readonly env?: Readonly<Record<string, unknown>>; + readonly id?: string; + readonly if?: string; + readonly name?: string; + readonly run?: string; + readonly shell?: string; + readonly uses?: string; + readonly with?: Readonly<Record<string, unknown>>; +} + +/** One job, as parsed. */ +export interface Job { + readonly env?: Readonly<Record<string, unknown>>; + readonly if?: string; + readonly name?: string; + readonly permissions?: Readonly<Record<string, string>>; + readonly steps?: readonly Step[]; + readonly strategy?: { + readonly matrix?: Readonly<Record<string, unknown>>; + }; + readonly "timeout-minutes"?: number; +} + +/** One workflow, as parsed. */ +export interface Workflow { + readonly concurrency?: Readonly<Record<string, unknown>>; + readonly env?: Readonly<Record<string, unknown>>; + readonly jobs: Readonly<Record<string, Job>>; + readonly on?: Readonly<Record<string, unknown>>; +} + +/** + * A YAML file under the repository root, parsed. Throws on a syntax error, + * which is the local stand-in for the runner refusing the workflow. + * + * @param relativePath - the file, from the repository root. + * @returns the parsed document. + */ +export const loadYaml = (relativePath: string): unknown => + parse(readFileSync(join(ROOT, relativePath), "utf8")); + +/** A workflow under `.github/workflows/`, parsed. */ +export const loadWorkflow = (name: string): Workflow => + loadYaml(join(".github", "workflows", name)) as Workflow; + +/** + * The environment a step runs with, as the runner composes it: the + * workflow's `env`, then the job's, then the step's own, each later layer + * winning. One expression, so a rule about a step's environment and that + * rule's known answer read the same layers. + * + * @param workflow - the workflow, for its `env`. + * @param job - the job, for its `env`. + * @param step - the step, for its `env`. + * @returns the composed variables. + */ +export const stepEnv = ( + workflow: Pick<Workflow, "env">, + job: Pick<Job, "env">, + step: Pick<Step, "env"> | undefined +): Readonly<Record<string, unknown>> => ({ + ...workflow.env, + ...job.env, + ...step?.env, +}); + +/** + * Every YAML file under `.github/`, from the repository root, sorted: the + * workflows, the composite actions and Dependabot's configuration. + * + * @returns the relative paths. + */ +export const workflowFiles = (): string[] => { + const found: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (/\.ya?ml$/u.test(entry.name)) { + found.push(relative(ROOT, full)); + } + } + }; + walk(join(ROOT, ".github")); + return found.toSorted(); +}; + +/** + * A GitHub Actions `if:` expression, evaluated over the few names the job + * uses: `==`, `!=`, `&&`, `||`, `!`, parentheses, quoted strings and the + * names given. Anything else throws, so an expression this cannot read is + * never answered by a guess. + * + * @param expression - the condition, without `${{ }}`. + * @param names - the value of each name it reads. + * @returns whether the step (or job) runs. + */ +export const evaluateIf = ( + expression: string, + names: Readonly<Record<string, string>> +): boolean | string => { + const tokens = + expression.match(/'[^']*'|==|!=|&&|\|\||[()!]|[A-Za-z_][\w.-]*/gu) ?? []; + const consumed = tokens.join(""); + if (consumed !== expression.replaceAll(/\s+/gu, "")) { + throw new Error(`unparsed text in: ${expression}`); + } + let at = 0; + const truthy = (value: boolean | string): boolean => + typeof value === "boolean" ? value : value !== ""; + const primary = (): boolean | string => { + const token = tokens[at]; + at += 1; + if (token === "(") { + const value = or(); + const closing = tokens[at]; + at += 1; + if (closing !== ")") throw new Error(`unbalanced: ${expression}`); + return value; + } + if (token === "!") return !truthy(primary()); + if (token?.startsWith("'")) return token.slice(1, -1); + if (token !== undefined && Object.hasOwn(names, token)) { + return names[token] ?? ""; + } + throw new Error(`unknown name ${token} in: ${expression}`); + }; + const comparison = (): boolean | string => { + const left = primary(); + const op = tokens[at]; + if (op === "==" || op === "!=") { + at += 1; + const right = primary(); + return op === "==" ? left === right : left !== right; + } + return left; + }; + const and = (): boolean | string => { + let value = comparison(); + while (tokens[at] === "&&") { + at += 1; + const right = comparison(); + value = truthy(value) && truthy(right); + } + return value; + }; + const or = (): boolean | string => { + let value = and(); + while (tokens[at] === "||") { + at += 1; + const right = and(); + value = truthy(value) || truthy(right); + } + return value; + }; + const result = or(); + if (at !== tokens.length) throw new Error(`trailing tokens: ${expression}`); + return truthy(result); +}; + +/** + * Whether a step runs, given the names its `if:` reads. `always()` is the + * one status function a step here uses; it reads as a name that is set. + * + * @param step - the step. + * @param names - the event, matrix and step outputs the condition reads. + * @returns `true` when the step has no condition or its condition holds. + */ +export const stepRuns = ( + step: Step, + names: Readonly<Record<string, string>> +): boolean => + step.if === undefined + ? true + : evaluateIf(step.if.replaceAll("always()", "always"), { + always: "true", + ...names, + }) === true; + +/** + * The commands a step's `run:` invokes, one per line, in order. + * + * A step that fails prints the very command it ran, so a command NAMED in a + * workflow is not a command RUN by it: comment lines and `echo` lines are + * dropped, and a command counts only as the first thing on its line (an + * `echo "… run 'pnpm run X'"` line never qualifies). + * + * @param step - the step; one with no `run:` invokes nothing. + * @returns the trimmed command lines. + */ +export const runCommands = (step: Pick<Step, "run">): string[] => + (step.run ?? "") + .split("\n") + .map((line) => line.trim()) + .filter( + (line) => line !== "" && !line.startsWith("#") && !/^echo\b/u.test(line) + ); + +/** + * Whether a command line invokes `command`: the line is the command, or the + * command followed by its arguments. `pnpm run test` is therefore not run by + * `pnpm run test:coverage`, and `pnpm run test:e2e` not by + * `pnpm run test:e2e:host`. A pattern is tested against the line instead. + */ +export const invokes = (line: string, command: RegExp | string): boolean => + typeof command === "string" + ? line === command || + (line.startsWith(command) && /^[\s;|&]/u.test(line.slice(command.length))) + : command.test(line); + +/** + * The steps of a job that run `command`, by index. + * + * @param job - the job. + * @param command - a command (matched as {@link invokes} does) or a pattern. + * @returns each running step with its index in the job. + */ +export const stepsRunning = ( + job: Pick<Job, "steps">, + command: RegExp | string +): { readonly index: number; readonly step: Step }[] => + (job.steps ?? []).flatMap((step, index) => + runCommands(step).some((line) => invokes(line, command)) + ? [{ index, step }] + : [] + ); + +/** One cell of the `test` job's matrix, with the names its steps' `if:` read. */ +export interface MatrixCell { + readonly channel: string; + readonly names: Readonly<Record<string, string>>; + readonly pi: string; +} + +/** + * The `test` job's matrix for one event: every channel crossed with the pi + * cells the event's expression selects. The `pi` axis is + * `fromJSON((<condition>) && '<array>' || '<array>')`; the condition is + * evaluated, never assumed. + * + * @param event - `github.event_name`. + * @param workflow - the parsed `ci.yml` (read from disk when omitted). + * @returns the cells, channel-major. + */ +export const matrixCells = ( + event: string, + workflow: Workflow = loadWorkflow("ci.yml") +): MatrixCell[] => { + const matrix = workflow.jobs.test?.strategy?.matrix ?? {}; + const channels = matrix.channel as readonly string[] | undefined; + const piExpression = typeof matrix.pi === "string" ? matrix.pi : ""; + const shape = + /^\$\{\{\s*fromJSON\(\((.+)\) && '(\[[^']*\])' \|\| '(\[[^']*\])'\)\s*\}\}$/u.exec( + piExpression + ); + if (channels === undefined || shape === null) { + throw new Error(`the test job's matrix is not readable: ${piExpression}`); + } + const [, condition = "", chosen = "[]", otherwise = "[]"] = shape; + const pis = JSON.parse( + evaluateIf(condition, { "github.event_name": event }) === true + ? chosen + : otherwise + ) as string[]; + return channels.flatMap((channel) => + pis.map((pi) => ({ + channel, + names: { + "github.event_name": event, + "matrix.channel": channel, + "matrix.pi": pi, + }, + pi, + })) + ); +}; + +/** + * The test process's environment without any `GIT_*` variable. A git hook, + * or `git rebase --exec` in a linked worktree, exports an absolute `GIT_DIR` + * and `GIT_INDEX_FILE`, which git honours before the working directory: a + * child that inherited them would commit, branch and configure the + * repository they name instead of the one it runs in. `GIT_CONFIG_PARAMETERS` + * goes too, since it carries `-c` settings such as signing. + */ +export const inheritedWithoutGit = (): NodeJS.ProcessEnv => + Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")) + ); + +/** + * One step's `run` text, executed the way the runner executes it (`bash -e`, + * GitHub's default shell, which has no pipefail), in the repository, with a + * shell function named in `prelude` standing in for a command the step calls. + * It runs in the repository unless `cwd` names another directory, and + * inherits no `GIT_*` variable (`inheritedWithoutGit`). + */ +export const execute = ( + run: string, + prelude: string, + env: Readonly<Record<string, string>> = {}, + cwd: string = ROOT +): { + status: number | null; + stdout: string; + stderr: string; + output: string; + calls: string; +} => { + const temp = mkdtempSync(join(tmpdir(), "ci-step-")); + try { + const result = spawnSync("bash", ["-e", "-c", `${prelude}\n${run}`], { + cwd, + encoding: "utf8", + env: { + ...inheritedWithoutGit(), + ...env, + CALLS: join(temp, "calls"), + GITHUB_OUTPUT: join(temp, "github-output"), + PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: temp, + }, + }); + const readOr = (path: string): string => { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } + }; + return { + calls: readOr(join(temp, "calls")), + output: readOr(join(temp, "github-output")), + status: result.status, + stderr: result.stderr, + stdout: result.stdout, + }; + } finally { + rmSync(temp, { force: true, recursive: true }); + } +}; diff --git a/test/toolchain.test.ts b/test/toolchain.test.ts new file mode 100644 index 00000000..6f0336d7 --- /dev/null +++ b/test/toolchain.test.ts @@ -0,0 +1,84 @@ +/** + * The toolchain is Vite+, and its lint config is the one that runs. + * + * Moved from `scripts/check.ts`, where it ran once per gate: a lint block that + * is never loaded lists every rule and enforces none, and a script that calls + * a tool past `vp` skips the config `vp` reads. Both look healthy from the + * outside, so each is asserted on what the toolchain actually answers, with a + * fixture whose answer is known first. + * + * @module test/toolchain.test + */ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + scriptsBypassingVp, + TOOLCHAIN_SCRIPTS, +} from "../scripts/package-checks.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + devDependencies?: Record<string, string>; + scripts: Record<string, string>; +}; + +describe("toolchain · Vite+", () => { + it("the lint config is live: the vite.config.ts block is loaded, type-aware, with this package's own gates", () => { + // `vp` itself, through the Node running the suite: no shell, no PATH. + const run = spawnSync( + process.execPath, + [ + join(ROOT, "node_modules", "vite-plus", "bin", "vp"), + "lint", + "--print-config", + ], + { cwd: ROOT, encoding: "utf8" } + ); + expect(run.status, run.stderr).toBe(0); + const effective = JSON.parse(run.stdout.slice(run.stdout.indexOf("{"))) as { + options?: { typeAware?: unknown; typeCheck?: unknown }; + rules?: Record<string, unknown>; + }; + expect(Object.keys(effective.rules ?? {}).length).toBeGreaterThanOrEqual( + 300 + ); + expect(effective.options?.typeAware).toBe(true); + // CI type-checks through `pnpm run check` (`vp check`), which runs the + // type check only while this is on: off, no test file is type-checked in + // CI, whatever tsconfig includes. + expect(effective.options?.typeCheck).toBe(true); + expect(effective.rules?.["typescript/no-floating-promises"]).toBeDefined(); + }); + + it("every toolchain script goes through vp", () => { + expect(pkg.devDependencies?.["vite-plus"]).toBeDefined(); + // The rule bites: a script that calls oxlint directly is named, and so is + // one that is missing. + expect(scriptsBypassingVp({ lint: "oxlint ." }, ["lint"])).toStrictEqual([ + "the lint script does not go through vp: oxlint .", + ]); + expect( + scriptsBypassingVp({ test: "vp test && vitest run" }, ["test"]) + ).toHaveLength(1); + expect(scriptsBypassingVp({}, ["build"])).toStrictEqual([ + "no build script", + ]); + // `tsc` is the one tool vp does not wrap, so a vp script may name it. + expect( + scriptsBypassingVp({ check: "vp check && tsc --noEmit" }, ["check"]) + ).toStrictEqual([]); + expect(TOOLCHAIN_SCRIPTS).toStrictEqual([ + "build", + "check", + "format", + "lint", + "test", + ]); + expect(scriptsBypassingVp(pkg.scripts)).toStrictEqual([]); + }); +}); diff --git a/test/usage-panel.test.tsx b/test/usage-panel.test.tsx index 7fb8732d..85fd337c 100644 --- a/test/usage-panel.test.tsx +++ b/test/usage-panel.test.tsx @@ -282,7 +282,9 @@ describe("UsageTrigger", () => { ); const [tooltip] = findAllOf(tree, new Set(["Tooltip"])); - expect(tooltip).toBeDefined(); + if (tooltip === undefined) { + throw new Error("no Tooltip in the trigger's tree"); + } expect(tooltip.props.label).toBe("90% of Weekly used"); // The clone's target: the button, not this component. expect(childrenOf(tooltip)).toContain(firstOf(tree, "button")); diff --git a/test/vendor-version.test.ts b/test/vendor-version.test.ts index e951dd96..45ec672c 100644 --- a/test/vendor-version.test.ts +++ b/test/vendor-version.test.ts @@ -15,10 +15,15 @@ import { describe, expect, it } from "vitest"; import { bareLiteral, + CLI_GENERATION_LITERAL, + CLI_LITERAL, disagreements, GENERATED_TABLES, pinnedVersion, + pinVerdict, + publishedCli, scansFile, + unpinnedGenerationClaims, } from "../scripts/vendor-version.ts"; /** @@ -138,3 +143,91 @@ describe("which files the copy scan reads", () => { expect(scansFile("lib/client.js")).toBe(false); }); }); + +describe("the registry's dist-tags", () => { + /** Snapshot versions as the registry carries them: `0.0.0-<tag>-<stamp>`. */ + const DEV = ["0.0.0", "dev", "202611092046"].join("-"); + const NEXT = ["0.0.0", "next", "202606270058"].join("-"); + + it("reads the registry's dist-tags: the pinned line's latest, the snapshot builds (listed, never classified), and a latest that left the line", () => { + expect( + publishedCli({ + dev: DEV, + latest: PINNED, + "latest-1": "1.1.4", + next: NEXT, + }) + ).toStrictEqual({ snapshots: { dev: DEV, next: NEXT }, stable: PINNED }); + // No usable `latest`: nothing published is read as current. + expect(publishedCli({})).toBeUndefined(); + expect( + publishedCli({ latest: ["0.0.0", "dev", "1"].join("-") }) + ).toBeUndefined(); + expect(publishedCli(null)).toBeUndefined(); + expect(publishedCli("1.2.3")).toBeUndefined(); + // A tag is registry data, never a pattern: one with regex metacharacters + // neither throws nor matches loosely. + const plusPlus = ["0.0.0", "c++", "202611092046"].join("-"); + const paren = ["0.0.0", "a(b", "1"].join("-"); + expect( + publishedCli({ + // As a pattern, `a.b` would read `axb`'s snapshot as its own. + "a.b": ["0.0.0", "axb", "1"].join("-"), + "a(b": paren, + "c++": plusPlus, + latest: PINNED, + }) + ).toStrictEqual({ + snapshots: { "a(b": paren, "c++": plusPlus }, + stable: PINNED, + }); + + expect( + pinVerdict(PINNED, { snapshots: {}, stable: PINNED }) + ).toBeUndefined(); + const bump = pinVerdict(PINNED, { snapshots: {}, stable: stale("6") }); + expect(bump).toMatch(/bump OPENCODE_CLI_VERSION/u); + // `latest` left the pinned major: the format the User-Agent claims moved + // with it, so the answer is not a version bump. + const generation = pinVerdict(PINNED, { snapshots: {}, stable: "2.0.0" }); + expect(generation).toMatch(/generation/u); + expect(generation).not.toMatch(/bump OPENCODE_CLI_VERSION/u); + }); + + it("sees the 2.x User-Agent form, once, and calls it an unpinned claim", () => { + // Assembled: a concrete 2.x-form literal in this file would be a claim the + // copy scan fails on. + const ua = ["opencode", "dev", DEV, "cli"].join("/"); + const generation = [...ua.matchAll(CLI_GENERATION_LITERAL)]; + expect(generation).toHaveLength(1); + expect(generation[0]?.slice(1)).toStrictEqual(["dev", DEV, "cli"]); + expect([...ua.matchAll(CLI_LITERAL)]).toHaveLength(0); + // The bare scan of a series never reads a version inside the 2.x form. + const series = ["0", "0", "1"].join("."); + expect([...ua.matchAll(bareLiteral(series))]).toHaveLength(0); + + const legacy = `opencode/${PINNED}`; + expect([...legacy.matchAll(CLI_LITERAL)]).toHaveLength(1); + expect([...legacy.matchAll(CLI_GENERATION_LITERAL)]).toHaveLength(0); + + // A stale 1.x copy followed by another segment is still a 1.x copy, and + // the gate reports it. + const segmented = `ua opencode/${stale("3")}/cli and opencode/${stale("2")}/`; + const found = [...segmented.matchAll(CLI_LITERAL)].map((match) => match[1]); + expect(found).toStrictEqual([stale("3"), stale("2")]); + expect([...segmented.matchAll(CLI_GENERATION_LITERAL)]).toHaveLength(0); + expect( + disagreements(PINNED, new Map([[stale("3"), ["docs/x.md"]]]), new Map()) + ).toStrictEqual([{ files: ["docs/x.md"], version: stale("3") }]); + + const placeholder = "opencode/<channel>/<version>/<client>"; + expect([...placeholder.matchAll(CLI_LITERAL)]).toHaveLength(0); + expect([...placeholder.matchAll(CLI_GENERATION_LITERAL)]).toHaveLength(0); + + const claims = unpinnedGenerationClaims(new Map([[ua, ["docs/x.md"]]])); + expect(claims).toHaveLength(1); + expect(claims[0]).toContain("docs/x.md"); + expect(claims[0]).toContain("no constant pins"); + expect(unpinnedGenerationClaims(new Map())).toStrictEqual([]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 2007b594..f8daf4e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,11 @@ "jsx": "react-jsx", "types": ["node"] }, - "include": ["src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts", "test/**/*.ts"] + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "scripts/**/*.ts", + "test/**/*.ts", + "test/**/*.tsx" + ] }