test(lint-once): match RUN case-insensitively, follow buildx builds
All checks were successful
check / check (push) Successful in 1m8s

WIP round 9: items 1-3. Verification matrix still to re-run.
This commit is contained in:
user
2026-09-04 12:04:24 +00:00
parent ffc817522e
commit f67a1c4d92

View File

@@ -317,6 +317,21 @@ const scripts = packageScripts();
// the rest of the file, for the same reason every other dead end here is. // the rest of the file, for the same reason every other dead end here is.
const HEREDOC_OPEN = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/; const HEREDOC_OPEN = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/;
// Dockerfile instruction keywords are case-insensitive and are separated from
// their arguments by any run of whitespace, so `run yarn run prettier ...` and
// a tab-separated `RUN\tyarn run prettier ...` are both instructions the image
// really executes. A literal `line.startsWith("RUN ")` saw neither, and either
// one appended to the Dockerfile put a second prettier pass into the image
// that `script/cibuild` builds while this file still reported green. The
// separator is consumed by the match rather than by a fixed-width `slice`, so
// the captured command is the same string for a space and for a tab, and the
// heredoc terminator is read off that command exactly as before.
//
// Leading whitespace before the keyword — which a Dockerfile also permits — is
// already gone by this point: `joinContinuations` trims every line, so an
// indented instruction arrives here flush and needs nothing further.
const DOCKER_RUN = /^RUN\s+(.*)$/i;
const dockerRunCommands = (text: string): string[] => { const dockerRunCommands = (text: string): string[] => {
const commands: string[] = []; const commands: string[] = [];
let terminator: string | null = null; let terminator: string | null = null;
@@ -326,8 +341,9 @@ const dockerRunCommands = (text: string): string[] => {
else commands.push(line); else commands.push(line);
continue; continue;
} }
if (!line.startsWith("RUN ")) continue; const run = DOCKER_RUN.exec(line);
const command = line.slice("RUN ".length); if (run === null) continue;
const command = run[1] as string;
terminator = HEREDOC_OPEN.exec(command)?.[2] ?? null; terminator = HEREDOC_OPEN.exec(command)?.[2] ?? null;
commands.push(command); commands.push(command);
} }
@@ -346,8 +362,10 @@ const resolve = (node: string): string[] => {
} }
// The `run:` steps of a workflow, in file order. `uses:` steps are actions, // The `run:` steps of a workflow, in file order. `uses:` steps are actions,
// not commands, and have no edges into this repo's graph. A `run: |` block // not commands, and have no edges into this repo's graph. A `run: |` block
// would resolve to the bare `|`, which reaches nothing and therefore fails // resolves to the bare `|`, which reaches nothing — so the count is exactly
// the count rather than passing quietly. // what such a step does NOT move, and it is the pinned resolved list, not
// the count, that turns it red. See the exact-equality test at the bottom
// of this file.
if (node.startsWith("workflow:")) { if (node.startsWith("workflow:")) {
return executable(read(node.slice("workflow:".length))) return executable(read(node.slice("workflow:".length)))
.filter((line) => /^-?\s*run:\s*\S/.test(line)) .filter((line) => /^-?\s*run:\s*\S/.test(line))
@@ -390,6 +408,28 @@ const commandsOf = (node: string): string[] => {
return commands; return commands;
}; };
// Which invocation shapes read as a build, and which do not.
//
// RECOGNISED: `docker build`, `docker buildx build`, and either of those
// reached through global flags between the command and the subcommand —
// `docker --context ci build`, `docker -H tcp://host:2375 build`,
// `docker --debug buildx build`. A global flag's separate argument is stepped
// over (`--context ci`), and an `=`-joined one is a single token
// (`--context=ci`). `docker buildx build` was the concrete miss: `\bdocker\s+
// build\b` cannot reach across `buildx`, so `docker buildx build -f
// Dockerfile.extra .` in script/cibuild emitted no edge at all — not even the
// default `Dockerfile` one — and a tree running prettier twice reported green.
//
// NOT RECOGNISED, deliberately: `docker compose build`, which builds compose
// services out of a compose file this parser does not read, so resolving it
// against a Dockerfile path would be a wrong answer rather than a missing one;
// `docker builder build`; and any spelling that puts a non-flag word other
// than `buildx` between `docker` and `build`. Only `-`-prefixed tokens (plus
// their arguments) and the single literal `buildx` are stepped over, so
// `docker run -f build` and `docker image build`-style shapes do not match.
const DOCKER_BUILD =
/\bdocker(?:\s+-{1,2}[A-Za-z][\w-]*(?:=\S+)?(?:\s+[^-\s]\S*)?)*(?:\s+buildx)?\s+build\b/g;
const edgesOf = (line: string): string[] => { const edgesOf = (line: string): string[] => {
const edges: string[] = []; const edges: string[] = [];
@@ -469,7 +509,7 @@ const edgesOf = (line: string): string[] => {
// separators and nothing more: a separator inside quotes or a `$(...)` // separators and nothing more: a separator inside quotes or a `$(...)`
// substitution ends the slice anyway, and a newline-separated command list // substitution ends the slice anyway, and a newline-separated command list
// is already one line per invocation by the time it gets here. // is already one line per invocation by the time it gets here.
for (const match of line.matchAll(/\bdocker\s+build\b/g)) { for (const match of line.matchAll(DOCKER_BUILD)) {
const invocation = line const invocation = line
.slice(match.index) .slice(match.index)
.split(/&&|\|\||;|\|/)[0] as string; .split(/&&|\|\||;|\|/)[0] as string;