From fed39d19cf3cdbfc695e50045a0055cc743c66d1 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 12:39:23 +0000 Subject: [PATCH] Run all linting in Docker via Dockerfile.lint (closes #30) Linting now happens in one place only: a new root Dockerfile.lint copies the repo into the digest-pinned node image already used by Dockerfile and runs eslint and prettier as build steps, so a successful build is a clean lint. script/lint is reduced to building it, which also works where the docker daemon is remote and bind mounts are impossible. No host lint path survives: the "lint" script is gone from package.json, so there is no second, unpinned way to get a lint verdict. Caching is waived for lint, because a lint build over an unchanged tree returns success in well under a second having linted nothing. LINT_EPOCH is the cache buster and it fails closed exactly as CHECK_EPOCH does: an unset ARG is the empty string, which is a perfectly stable cache key, so the guard rejects it and a bare `docker build -f Dockerfile.lint .` errors out instead of serving a green it did not earn. Both linters sit below the guard, so a fresh epoch forces them to execute while the bootstrap and dependency layers above stay cached. That makes script/lint a docker build, which nothing inside a container may call. script/check calls script/lint, so the Dockerfile image can no longer run make check: the lint stage and its COPY --from=lint ordering hack are deleted, and the remaining stage runs make test and make build under the existing CHECK_EPOCH guard. script/cibuild is now the composite gate and builds the lint image first, so a lint failure is reported before the slower suite runs. The .dockerignore exclusions are unchanged and still apply to the lint build, including the .claude/ exclusion (eslint's flat config does not ignore dot-directories, so a nested worktree in the context would be linted) and the deliberate exception that keeps .gitignore in the context for prettier. A new test asserts no per-Dockerfile ignore file shadows the root one for either image, and test/packaging/lint-docker.test.ts asserts the whole shape: the docker-only lint path, the digest pin, manifests copied before sources, the fail-closed guard with both linters below it, the absence of a lint stage or make check in Dockerfile, and the build order in script/cibuild. --- Dockerfile | 27 ++-- Dockerfile.lint | 35 +++++ README.md | 70 ++++++---- TODO.md | 11 ++ package.json | 1 - script/check | 4 + script/cibuild | 21 ++- script/docker | 3 +- script/lint | 17 ++- script/precommit | 7 +- test/packaging/build-context.test.ts | 17 ++- test/packaging/lint-docker.test.ts | 184 +++++++++++++++++++++++++++ 12 files changed, 342 insertions(+), 55 deletions(-) create mode 100644 Dockerfile.lint create mode 100644 test/packaging/lint-docker.test.ts diff --git a/Dockerfile b/Dockerfile index 0caabf2..a194ffd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,36 +1,27 @@ -# Lint stage — fast feedback on formatting and lint issues -# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09 -FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint -WORKDIR /app -COPY script/ script/ -COPY package.json yarn.lock ./ -RUN script/bootstrap -COPY . . -RUN make fmt-check -RUN make lint - -# Check stage — the full suite and the build +# Test and build image: the suite, then the compile. +# +# Linting deliberately does not happen here. `script/lint` is a build of +# Dockerfile.lint, and `script/check` calls `script/lint`, so running +# `make check` in this image would mean running `docker build` inside a +# container. Lint runs exactly once, in Dockerfile.lint; script/cibuild +# builds that first and this second. # node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS check WORKDIR /app -# Force BuildKit to run the lint stage before proceeding. Without this the -# two stages run in parallel and a lint failure can lose the race. -COPY --from=lint /app/yarn.lock /dev/null - COPY script/ script/ COPY package.json yarn.lock ./ RUN script/bootstrap COPY . . -# CHECK_EPOCH is a cache buster: without it Docker serves `make check` from +# CHECK_EPOCH is a cache buster: without it Docker serves the test layer from # cache on an unchanged tree, the suite never executes, and the build still # exits 0. The guard makes an absent argument a hard failure — an unset ARG # is the empty string, which is a perfectly stable cache key, so a plain # `docker build .` would otherwise still get the false green. Fail closed. ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 -RUN make check +RUN make test ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..0ee2a9a --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,35 @@ +# Lint image: every lint run happens here, and nowhere else. The repo is +# COPYed into a digest-pinned image and the linters run as build steps, so a +# successful build IS a clean lint. `script/lint` does nothing but build this +# file, which also works where the docker daemon is remote and bind mounts are +# impossible. Nothing that runs inside a container may call `script/lint`: +# that is why Dockerfile no longer runs `make check`. +# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09 +FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint +WORKDIR /app + +# Manifests before sources, so the dependency install layer stays cached +# until package.json or yarn.lock changes. script/bootstrap ends in +# `yarn install --frozen-lockfile`; the lint steps below are deliberately +# not cached. +COPY script/ script/ +COPY package.json yarn.lock ./ +RUN script/bootstrap + +COPY . . + +# LINT_EPOCH is a cache buster, with the same fail-closed contract as +# CHECK_EPOCH in Dockerfile. No lint cache is wanted: on an unchanged tree +# Docker serves the linter layers in well under a second, having linted +# nothing, and the build still exits 0. The guard makes an absent argument a +# hard failure — an unset ARG is the empty string, which is a perfectly +# stable cache key, so a plain `docker build -f Dockerfile.lint .` would +# otherwise get exactly that false green. Every layer below this one is a +# child of the guard, so a fresh epoch forces all of them to execute. +ARG LINT_EPOCH +RUN [ -n "$LINT_EPOCH" ] || exit 1 + +# The linters are invoked directly rather than through `make lint`, because +# `make lint` is the build of this file. +RUN yarn run eslint . +RUN yarn run prettier --check . diff --git a/README.md b/README.md index 8df251c..89d5533 100644 --- a/README.md +++ b/README.md @@ -86,15 +86,17 @@ alpine. We provide: files the compiler wrote, and make the CLI executable (our own extension) - `script/test` — run the test suite (vitest, hard-capped at 30s where `timeout` is available, verbose rerun on failure) -- `script/lint` — run eslint and a prettier check +- `script/lint` — run eslint and a prettier check, by building + `Dockerfile.lint`; requires docker (see Linting below) - `script/fmt` — format all files with prettier (writes) - `script/fmt-check` — check formatting (read-only) - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own extension) -- `script/docker` — build the Docker image, tagged via `script/projectname` -- `script/cibuild` — cd to the repo root and run the image build (what CI runs; - the build runs `make fmt-check` and `make lint` in a first stage, then - `make check` and `make build` in a second) +- `script/docker` — build the test and build image, tagged via + `script/projectname` +- `script/cibuild` — cd to the repo root and build both images (what CI runs): + `script/lint` first, then the `Dockerfile` image, which runs `make test` and + `make build` - `script/precommit` — run by the git pre-commit hook (our own extension); runs `script/lint` and `script/fmt-check` but deliberately not the tests, so the TDD red-phase commit can land @@ -103,14 +105,34 @@ alpine. We provide: `make hooks` installs the pre-commit hook that runs `script/precommit`. -Both `script/docker` and `script/cibuild` pass -`--build-arg CHECK_EPOCH="$(date +%s)"`. The Dockerfile refuses to build without -it. This is deliberate: on an unchanged tree Docker would otherwise serve the -`make check` layer from cache, so the suite would never run and the build would -still exit 0. A changing epoch invalidates the check and build layers on every -invocation while leaving the dependency layers below them cached, and the -missing-argument guard means a bare `docker build .` fails loudly instead of -quietly reporting a green it did not earn. +### Linting + +Linting runs in a container, one way, everywhere. `script/lint` builds +`Dockerfile.lint`, which copies the repo into a digest-pinned node image and +runs eslint and prettier as build steps, so a successful build is a clean lint. +There is no host lint path: docker is required to lint, and that also works +where the docker daemon is remote and bind mounts are impossible. + +Lint happens in exactly one place, which constrains the rest of the build. +`script/check` calls `script/lint`, so `make check` cannot run inside a +container without asking for docker inside docker. The image built from +`Dockerfile` therefore runs `make test` and `make build` and does not lint; +`script/cibuild` builds `Dockerfile.lint` first and that image second, so CI +gets both verdicts. + +### Build epochs + +`script/lint` passes `--build-arg LINT_EPOCH="$(date +%s)"`, and `script/docker` +and `script/cibuild` pass `--build-arg CHECK_EPOCH="$(date +%s)"`. Both +Dockerfiles refuse to build without their argument. This is deliberate: on an +unchanged tree Docker would otherwise serve the linter and test layers from +cache, so nothing would run and the build would still exit 0 — a lint build over +an untouched tree returns success in well under a second, having linted nothing. +A changing epoch invalidates every layer below the guard on every invocation +while leaving the dependency layers above them cached, and the missing-argument +guard means a bare `docker build .` fails loudly instead of quietly reporting a +green it did not earn: an unset build argument is the empty string, which is a +perfectly stable cache key. ## Rationale @@ -145,8 +167,10 @@ All work on quak is test-driven. No exceptions. 3. Subsequent commits add the implementation and any refactors needed to make the tests pass. 4. A feature branch can only be merged into `main` when `make check` is green. - `main` is always green. The Dockerfile runs `make check` and `make build`, so - neither a red branch nor one that does not compile can pass CI. + `main` is always green. CI runs `script/cibuild`, which lints via + `Dockerfile.lint` and then runs `make test` and `make build` in the + `Dockerfile` image, so neither a red branch nor one that does not compile can + pass CI. 5. Tests are the canonical API documentation for this library. Every test file is commented thoroughly enough that a reader who has never seen quak can learn how to use it from the tests alone. Comments explain why a behavior @@ -162,9 +186,8 @@ All work on quak is test-driven. No exceptions. 8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which runs the lint and format checks but not the full `make check`. This is deliberate so the TDD red-phase commit (failing tests, no implementation yet) - can land. The full `make check` runs as part of the image build, which is - what CI executes via `script/cibuild`, so a red branch still cannot reach - `main`. + can land. The suite runs as part of the image build, which is what CI + executes via `script/cibuild`, so a red branch still cannot reach `main`. ## Design @@ -191,7 +214,8 @@ quak/ quak.ts CLI entrypoint (commander.js) test/ unit + integration tests (vitest) Makefile - Dockerfile + Dockerfile test suite and compile + Dockerfile.lint eslint and prettier, as build steps package.json tsconfig.json ``` @@ -478,9 +502,11 @@ documents: implementation. Tests are the canonical API documentation and must be commented thoroughly. `main` is always green. -- **Required checks before every commit:** `make lint` (eslint + prettier check) - and `make fmt-check` must pass. The pre-commit hook enforces this. - `make check` (which also runs tests) must pass before merging to `main`. +- **Required checks before every commit:** `make lint` (eslint + prettier check, + which builds `Dockerfile.lint` and therefore needs docker) and + `make fmt-check` must pass. The pre-commit hook enforces this. `make check` + (which also runs tests) must pass before merging to `main`. Never invoke + eslint or prettier directly; linting runs in the container only. - **Formatting:** prettier with 4-space indents and `proseWrap: always` for markdown. Use `make fmt` to format. Use `yarn` not `npm`. diff --git a/TODO.md b/TODO.md index 5671aff..78b257f 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,17 @@ Update the README API reference section to match the current implementation. # Completed Steps +- 2026-08-10: Moved all linting into Docker. `script/lint` builds a new root + `Dockerfile.lint`, which copies the repo into the digest-pinned node image and + runs eslint and prettier as build steps, so a successful build is a clean + lint; no host lint path remains and `yarn lint` is gone from `package.json`. A + fail-closed `LINT_EPOCH` guard stops Docker serving the linter layers from + cache, which is how a lint build returns success in under a second having + linted nothing. The lint stage inside `Dockerfile` and its `COPY --from=lint` + ordering hack are gone: that image now runs `make test` and `make build` only, + because `script/check` calls `script/lint` and running it in a container would + mean docker inside docker. `script/cibuild` builds the lint image first, then + the test and build image. - 2026-08-09: Made `make docker` green and policy-conformant. Multi-stage Dockerfile: a lint stage runs `make fmt-check` and `make lint`, and the check stage takes a `COPY --from=lint` dependency on it before running `make check` diff --git a/package.json b/package.json index 746ba14..423fad4 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "build": "script/build", "quak": "node ./dist/bin/quak.js", "test": "vitest run", - "lint": "eslint .", "fmt": "prettier --write .", "fmt-check": "prettier --check ." }, diff --git a/script/check b/script/check index 3e1778c..74baf3c 100755 --- a/script/check +++ b/script/check @@ -1,6 +1,10 @@ #!/bin/sh # script/check: run all checks (test, lint, fmt-check). Our own # extension to scripts-to-rule-them-all. Must not modify any files. +# +# script/lint builds Dockerfile.lint, so this script requires docker and +# must never be run from inside a container: that is why the Dockerfile +# image runs script/test and script/build rather than this. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/script/cibuild b/script/cibuild index 51224da..dd1f7b5 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,16 +1,23 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs script/check and -# script/build, and CHECK_EPOCH differs on every invocation, so those two -# layers cannot be served from Docker's cache: a green build here means -# the checks ran now, not that a previous run was remembered. The layers -# below the epoch (bootstrap, yarn install) are unaffected and stay -# cached. A build that omits the argument fails by design. +# script/cibuild: run the CI build, which is both images in a defined order. +# +# First script/lint, which builds Dockerfile.lint and is the one and only +# place linting happens — it goes first so a lint failure is reported before +# the slower suite runs. Then the Dockerfile image, which runs script/test +# and script/build. CHECK_EPOCH and LINT_EPOCH differ on every invocation, so +# neither the linters nor the suite can be served from Docker's cache: a +# green build here means the checks ran now, not that a previous run was +# remembered. The layers below the epochs (bootstrap, yarn install) are +# unaffected and stay cached. A build that omits the arguments fails by +# design. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" + "$SCRIPT_DIR/lint" docker build --build-arg CHECK_EPOCH="$(date +%s)" . } diff --git a/script/docker b/script/docker index 0bd2a83..c691f2c 100755 --- a/script/docker +++ b/script/docker @@ -3,7 +3,8 @@ # Identical in all repos; the tag comes from script/projectname. # CHECK_EPOCH is passed for the same reason script/cibuild passes it: the # Dockerfile refuses to build without it, so that no path to an image can -# quietly serve the check and build layers from cache. +# quietly serve the test and build layers from cache. This builds the test +# and build image only; linting is a separate image, built by script/lint. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/script/lint b/script/lint index 3ede02b..4a602f2 100755 --- a/script/lint +++ b/script/lint @@ -1,13 +1,24 @@ #!/bin/sh -# script/lint: run the linter (eslint plus a prettier check). +# script/lint: run the linters. eslint and prettier are never run against +# the working tree from here: linting runs via docker only, one way, +# everywhere — script/lint builds Dockerfile.lint, which COPYs the repo into +# the pinned node image and runs the linters as build steps. That works even +# when the docker daemon is remote and bind mounts are impossible. +# +# LINT_EPOCH is passed on every invocation because no lint cache is wanted: +# on an unchanged tree Docker would otherwise serve the linter layers, having +# linted nothing, and still exit 0. Dockerfile.lint refuses to build without +# the argument, so no path to a lint result can quietly come from cache. +# +# Nothing that runs inside a container may call this script; see the header +# of Dockerfile. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - yarn run eslint . - yarn run prettier --check . + docker build --build-arg LINT_EPOCH="$(date +%s)" -f Dockerfile.lint . } main "$@" diff --git a/script/precommit b/script/precommit index 07b9e7e..9344d32 100755 --- a/script/precommit +++ b/script/precommit @@ -4,8 +4,11 @@ # # Runs lint and fmt-check but deliberately NOT the tests, so the TDD # red-phase commit (failing tests, no implementation yet) can land. CI -# runs make check via docker build, which catches any branch that -# ships red. +# runs script/cibuild, which builds both images and so catches any +# branch that ships red. +# +# script/lint is a docker build (Dockerfile.lint); docker is required to +# commit, which is the point of linting one way, everywhere. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/test/packaging/build-context.test.ts b/test/packaging/build-context.test.ts index 9ffa5a1..056fb57 100644 --- a/test/packaging/build-context.test.ts +++ b/test/packaging/build-context.test.ts @@ -12,7 +12,7 @@ // // Neither shows up as a build failure, so they are asserted here. import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; @@ -46,4 +46,19 @@ describe(".dockerignore", () => { it("leaves .gitignore in the build context for prettier", () => { expect(dockerignore).not.toContain(".gitignore"); }); + + // Both images are built from this same context, and the lint image runs + // eslint and prettier across it. BuildKit lets a `.dockerignore` + // shadow the root one for a single build; such a file would silently give + // the lint build a different, unreviewed context — and eslint's flat config + // does not ignore dot-directories, so a stray `.claude/` worktree would be + // linted. + it.each(["Dockerfile", "Dockerfile.lint"])( + "is not shadowed by a per-Dockerfile ignore file for %s", + (name) => { + expect(existsSync(join(repoRoot, `${name}.dockerignore`))).toBe( + false, + ); + }, + ); }); diff --git a/test/packaging/lint-docker.test.ts b/test/packaging/lint-docker.test.ts new file mode 100644 index 0000000..fa561f2 --- /dev/null +++ b/test/packaging/lint-docker.test.ts @@ -0,0 +1,184 @@ +// Linting runs in Docker, one way, everywhere: `script/lint` builds +// `Dockerfile.lint`, which COPYs the repo into a digest-pinned image and runs +// eslint and prettier as build steps, so a successful build IS a clean lint. +// +// Three things can quietly undo that, and none of them shows up as a build +// failure, which is why they are asserted here: +// +// 1. Recursion. `script/check` calls `script/lint`, and `script/lint` is now a +// `docker build`. Anything that runs `make check` inside a container is +// therefore asking for Docker inside Docker, and CI breaks. The image built +// from `Dockerfile` runs the suite and the compile only; lint happens once, +// in `Dockerfile.lint`. +// 2. Cache. A lint build over an unchanged tree returns success in well under a +// second having linted nothing. The `LINT_EPOCH` guard is what forces the +// linter layers to execute, and it has to fail closed: an unset build +// argument is the empty string, which is a perfectly stable cache key, so an +// invocation that omits it must be rejected rather than served a cached +// green. +// 3. A host lint path surviving alongside the container one, which would let a +// lint result come from an unpinned local toolchain. +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +const read = (name: string): string => + readFileSync(join(repoRoot, name), "utf-8"); + +// The executable lines of a shell script or Dockerfile: comments carry the +// reasoning and frequently name the very commands these tests forbid, so they +// would otherwise trigger every assertion below. +const instructions = (name: string): string[] => + read(name) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== "" && !line.startsWith("#")); + +const lintScript = instructions("script/lint"); +const dockerfileLint = instructions("Dockerfile.lint"); +const dockerfile = instructions("Dockerfile"); +const cibuild = instructions("script/cibuild"); + +const has = (lines: string[], pattern: RegExp): boolean => + lines.some((line) => pattern.test(line)); + +describe("script/lint", () => { + it("lints by building Dockerfile.lint", () => { + expect(has(lintScript, /docker build .*-f Dockerfile\.lint/)).toBe( + true, + ); + }); + + // The whole point of the ruling: no invocation of a linter against the + // working tree survives, so a lint verdict can only come from the pinned + // image. + it("runs no linter on the host", () => { + expect(has(lintScript, /eslint|prettier/)).toBe(false); + }); + + // Without a fresh epoch the build is served from cache in under a second, + // having linted nothing, and still exits 0. + it("passes a fresh LINT_EPOCH on every run", () => { + expect( + has(lintScript, /--build-arg LINT_EPOCH="\$\(date \+%s\)"/), + ).toBe(true); + }); +}); + +describe("Dockerfile.lint", () => { + // Tag references are server-mutable, so they are remote code execution. + it("pins its base image by digest", () => { + expect(has(dockerfileLint, /^FROM \S+@sha256:[0-9a-f]{64}/)).toBe(true); + }); + + it("runs eslint as a build step", () => { + expect(has(dockerfileLint, /^RUN .*eslint \./)).toBe(true); + }); + + it("runs prettier as a build step", () => { + expect(has(dockerfileLint, /^RUN .*prettier --check \./)).toBe(true); + }); + + // An unset ARG is the empty string, and an empty string is a perfectly + // stable cache key. Rejecting it is what stops a bare + // `docker build -f Dockerfile.lint .` from reporting a green it did not + // earn. + it("refuses to build without LINT_EPOCH", () => { + expect(has(dockerfileLint, /^ARG LINT_EPOCH$/)).toBe(true); + expect( + has(dockerfileLint, /^RUN \[ -n "\$LINT_EPOCH" \] \|\| exit 1$/), + ).toBe(true); + }); + + // The guard only forces execution of the layers below it, so both linters + // have to sit after it. Layer order is the mechanism, not a style choice. + it("puts both linters below the epoch guard", () => { + const guard = dockerfileLint.findIndex((line) => + /^RUN \[ -n "\$LINT_EPOCH" \]/.test(line), + ); + const linters = dockerfileLint + .map((line, index) => ({ line, index })) + .filter(({ line }) => /^RUN .*(eslint|prettier)/.test(line)); + + expect(linters.length).toBeGreaterThan(0); + for (const { line, index } of linters) { + expect( + index, + `${line} must run below the LINT_EPOCH guard`, + ).toBeGreaterThan(guard); + } + }); + + // Dependency installation is the slow layer and has nothing to do with the + // sources, so it caches separately: manifests first, sources afterwards. + it("copies the manifests before the sources", () => { + const manifests = dockerfileLint.findIndex((line) => + /^COPY package\.json yarn\.lock/.test(line), + ); + const sources = dockerfileLint.findIndex((line) => + /^COPY \. \.$/.test(line), + ); + + expect(manifests).toBeGreaterThanOrEqual(0); + expect(sources).toBeGreaterThan(manifests); + }); + + // script/lint is a docker build; a lint step that shelled out to it would + // recurse. + it("does not call script/lint or make lint", () => { + expect(has(dockerfileLint, /make lint|script\/lint/)).toBe(false); + }); +}); + +describe("Dockerfile", () => { + // `make check` runs script/lint, which is a docker build, so an image that + // ran it would need a Docker daemon inside the container. + it("does not run make check, make lint or script/lint", () => { + expect( + has(dockerfile, /make check|make lint|script\/(check|lint)/), + ).toBe(false); + }); + + // The replaced lint stage took a `COPY --from=lint` dependency to order + // itself before the check stage. Dockerfile.lint is that stage now, and + // two definitions of how to lint is one too many. + it("has no lint stage", () => { + expect(has(dockerfile, /AS lint\b|--from=lint\b/)).toBe(false); + }); + + it("still runs the suite and the build under the epoch guard", () => { + expect(has(dockerfile, /^RUN make test$/)).toBe(true); + expect(has(dockerfile, /^RUN make build$/)).toBe(true); + expect( + has(dockerfile, /^RUN \[ -n "\$CHECK_EPOCH" \] \|\| exit 1$/), + ).toBe(true); + }); +}); + +describe("script/cibuild", () => { + // CI has to get both verdicts. Lint goes first so the fast failure is + // reported before the suite runs. + it("builds the lint image before the test and build image", () => { + const lint = cibuild.findIndex((line) => /\/lint"/.test(line)); + const check = cibuild.findIndex((line) => + /docker build .*CHECK_EPOCH/.test(line), + ); + + expect(lint).toBeGreaterThanOrEqual(0); + expect(check).toBeGreaterThan(lint); + }); +}); + +describe("package.json", () => { + // `yarn lint` was a second, unpinned way to get a lint verdict, from + // whatever eslint the working tree happened to have installed. + it("exposes no host lint script", () => { + const pkg = JSON.parse(read("package.json")) as { + scripts: Record; + }; + expect(pkg.scripts.lint).toBeUndefined(); + }); +});