Revert the 16 commits pushed to next on 2026-09-04 by an agent outside the managed fleet
All checks were successful
check / check (push) Successful in 1m40s
All checks were successful
check / check (push) Successful in 1m40s
sneak, 2026-09-05: "inference instance stopped. undo its rogue work." The reverted commits stay in history; nothing else on next is touched. Model: fable-5-1
This commit is contained in:
38
README.md
38
README.md
@@ -108,14 +108,11 @@ alpine. We provide:
|
||||
|
||||
### Linting
|
||||
|
||||
Linting runs in a container, one way, in every path that produces a lint verdict
|
||||
for `make check` or the pre-commit hook. `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. No lint path
|
||||
reachable from `script/check` or `script/precommit` runs on the host: every lint
|
||||
verdict those two produce comes from the container. Docker is therefore required
|
||||
to lint, and the container route also works where the docker daemon is remote
|
||||
and bind mounts are impossible.
|
||||
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.
|
||||
|
||||
The formatting check is part of that, not a step beside it. `script/check` and
|
||||
`script/precommit` therefore call `script/lint` and stop; neither calls
|
||||
@@ -127,20 +124,15 @@ in each. `test/packaging/lint-once.test.ts` asserts that count by walking the
|
||||
invocation graph, so a second pass cannot creep back in unnoticed.
|
||||
|
||||
`script/fmt-check` remains as a standalone entrypoint for asking the formatting
|
||||
question on its own, without docker and without the rest of lint. It is not the
|
||||
last formatting path on the host — `script/fmt` writes with the host prettier,
|
||||
and `package.json` exposes `fmt` and `fmt-check` as direct prettier calls — it
|
||||
is the one of them that the check graph could plausibly have reached, and
|
||||
nothing reaches it: neither `script/check` nor `script/precommit` calls it, so
|
||||
it never contributes to their verdicts. Its own verdict cannot drift from the
|
||||
container's: prettier is pinned to an exact version, installed from `yarn.lock`
|
||||
under `--frozen-lockfile` in both places, and reads `.gitignore` as its default
|
||||
ignore file — which is why `.dockerignore` deliberately keeps `.gitignore` in
|
||||
the build context.
|
||||
question on its own, without docker and without the rest of lint. Its verdict
|
||||
cannot drift from the container's: prettier is pinned to an exact version,
|
||||
installed from `yarn.lock` under `--frozen-lockfile` in both places, and reads
|
||||
`.gitignore` as its default ignore file — which is why `.dockerignore`
|
||||
deliberately keeps `.gitignore` in the build context.
|
||||
|
||||
Lint happens in exactly one place in that graph, 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
|
||||
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.
|
||||
@@ -534,8 +526,8 @@ documents:
|
||||
runs the tests) must pass before merging to `main`. `make fmt-check` is
|
||||
available for a host-side formatting check on its own, but it is not a
|
||||
separate requirement: `make lint` already covers it, and running both would
|
||||
check formatting twice. Never invoke eslint or prettier directly; every lint
|
||||
verdict `make check` and the pre-commit hook produce comes from the container.
|
||||
check formatting twice. 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`.
|
||||
|
||||
@@ -16,56 +16,10 @@
|
||||
// against one file. Starting from an entrypoint, it follows every edge the repo
|
||||
// actually uses to reach another command — `run:` steps in the CI workflow,
|
||||
// `"$SCRIPT_DIR/<name>"` and `script/<name>` into other scripts, `make <target>`
|
||||
// and `$(MAKE) <target>` through the Makefile shims, the prerequisites of a
|
||||
// Makefile target, `yarn run <name>` through the `package.json` scripts, and
|
||||
// `docker build` into the `RUN` steps of the Dockerfile it names, heredoc
|
||||
// bodies included — and counts the prettier invocations it finds.
|
||||
//
|
||||
// What that covers is worth stating exactly rather than as "anywhere", because
|
||||
// a comment claiming more than the code delivers is the same defect as an
|
||||
// assertion that cannot fail — and an earlier draft of this header did claim
|
||||
// it, while `check: fmt-check` and `@$(MAKE) fmt-check` both walked straight
|
||||
// past it. Command names are read as `[a-z][a-z0-9_-]*` for Makefile targets
|
||||
// and `script/` files, and `[A-Za-z][A-Za-z0-9_:-]*` for `package.json`
|
||||
// scripts. A command reached by some other means — `sh -c`, a shell alias, an
|
||||
// `include`d makefile, a name outside those charsets, a generated file — is
|
||||
// not followed. Recipe lines reached through a make conditional are followed,
|
||||
// without evaluating the condition, and so is a recipe written on the target
|
||||
// line after a `;`; a recipe built by an `include`d makefile, a pattern rule,
|
||||
// or a target name outside that charset is not. A variable given a literal
|
||||
// value on one line — `VAR := ...`, `VAR = ...`, `VAR ?= ...` — is substituted
|
||||
// wherever the Makefile spells it `$(VAR)` or `${VAR}`, because this Makefile
|
||||
// writes its recipes that way (`@$(YARN) tsc --watch`); the substitution is
|
||||
// one pass over literals, so a value that itself names another variable, a
|
||||
// value built by a make function (`$(shell ...)`, `$(addprefix ...)`), and the
|
||||
// body of a `define`/`endef` block are not expanded and not followed. A
|
||||
// build is recognised as `docker build`, as its three management-command
|
||||
// spellings (`docker buildx build`, `docker image build`, `docker builder
|
||||
// build`), and as any of those reached through global flags standing between
|
||||
// the command and the subcommand (`docker --context ci build`, `docker -H
|
||||
// tcp://h:2375 buildx build`), a flag's separate argument being stepped over;
|
||||
// `docker compose build` is deliberately not, since it builds services out of
|
||||
// a compose file this walk does not read, and neither is any other non-flag
|
||||
// word in that position. Matching only a literal `docker build` was worse than a
|
||||
// wrong file: `docker buildx build -f <file> .` emitted no edge at all, not
|
||||
// even the default one. A recognised build resolves to the file named by
|
||||
// `-f`, `-f=`, `-f<file>` with the value attached to the flag, `--file` or
|
||||
// `--file=`, wherever in that invocation the flag sits, and to `Dockerfile`
|
||||
// when it names none; a Dockerfile chosen some other way — a bundled short
|
||||
// flag cluster (`-qf <file>`), a value this walk cannot expand to a literal —
|
||||
// resolves to the default rather than to the real file. Each build on a line
|
||||
// is followed separately, and the flag search for one is bounded to the slice
|
||||
// running from that command to the next `&&`, `||`, `;` or `|`, so a second
|
||||
// build on the same line is not swallowed by the first and a later command's
|
||||
// `-f` is not read as the build's. That bounding is a split on those four
|
||||
// separators, not a shell parse: a separator appearing inside quotes or a
|
||||
// command substitution still ends the slice, so a flag standing after one —
|
||||
// `docker build --build-arg MSG="a;b" -f Dockerfile.lint .` — is not read.
|
||||
// Inside a Dockerfile the `RUN` keyword is matched case-insensitively and may
|
||||
// be followed by any whitespace, because Docker executes `run ...` and a
|
||||
// tab-separated `RUN\t...` exactly as it executes `RUN ...`; an instruction
|
||||
// indented from the left margin is read too. Within those edges, a prettier
|
||||
// call is caught wherever it is added.
|
||||
// through the Makefile shims, `yarn run <name>` through the `package.json`
|
||||
// scripts, and `docker build -f <file>` into that Dockerfile's `RUN` steps — and
|
||||
// counts the prettier invocations it finds. A prettier call added anywhere in
|
||||
// that graph is therefore caught, wherever it is added.
|
||||
//
|
||||
// Two entrypoints are walked, because they cover different graphs: `make check`
|
||||
// is what a developer runs, and `.gitea/workflows/check.yml` is what CI runs.
|
||||
@@ -78,9 +32,8 @@
|
||||
// Undercounting is the failure mode that would make this test worthless. Three
|
||||
// things guard against it: the walk is asserted to have reached the nodes that
|
||||
// matter, an unresolvable or empty node is a thrown error rather than a quiet
|
||||
// zero, and both the counting and the edge-following are per occurrence rather
|
||||
// than per line, so two prettier calls or two `docker build`s chained with `&&`
|
||||
// on one line cannot read as one.
|
||||
// zero, and prettier is counted per occurrence rather than per line, so two
|
||||
// invocations chained with `&&` cannot read as one.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -130,189 +83,26 @@ const countPrettier = (line: string): number =>
|
||||
// Makefile targets are thin shims (`check:` / tab / `@script/check`), so a
|
||||
// `make <target>` edge has to resolve through them to keep "per `make check`"
|
||||
// meaning what it says. Recipe lines are the tab-indented ones.
|
||||
//
|
||||
// The prerequisite list is read as well, and it is not decoration: make runs a
|
||||
// target's prerequisites before its recipe, so `check: fmt-check` invokes
|
||||
// fmt-check every bit as much as a `@script/fmt-check` line in the recipe
|
||||
// would. An earlier version of this parser captured the target name and threw
|
||||
// the rest of the line away, which made `check: fmt-check` — a one-token edit,
|
||||
// and the most natural way for someone to wire the host formatting check back
|
||||
// into `make check` — a second prettier pass that this file scored as one. The
|
||||
// shipped Makefile already relies on prerequisites (`install: build-bin`), so
|
||||
// this is the file's own house style rather than a contrived evasion.
|
||||
interface MakeTarget {
|
||||
prerequisites: string[];
|
||||
recipe: string[];
|
||||
}
|
||||
|
||||
// Names are lowercase-with-dashes here, plus digits and underscores. The
|
||||
// leading character is deliberately not uppercase: `YARN := yarn run` is a
|
||||
// variable assignment, not a rule, and `(?!=)` alone would not say so on a
|
||||
// line spelled `YARN: yarn run`.
|
||||
const MAKE_TARGET_NAME = "[a-z][a-z0-9_-]*";
|
||||
|
||||
// Conditional directives are not target lines, and they are not recipe lines
|
||||
// either — they are parse-time structure wrapped around the recipe that
|
||||
// encloses them. Treating one as an ordinary non-tab line ends the current
|
||||
// recipe, and every tab-indented line after it is discarded, so
|
||||
//
|
||||
// check:
|
||||
// \t@script/check
|
||||
// ifeq (1,1)
|
||||
// \t@script/fmt-check
|
||||
// endif
|
||||
//
|
||||
// reads as a recipe of one command while `make -n check` prints two. The
|
||||
// condition is deliberately not evaluated: that would mean evaluating make
|
||||
// variables, and the conservative reading — every branch is reachable, so
|
||||
// every branch's lines are recipe lines — is the one that cannot lose an
|
||||
// invocation. Counting a command from a branch make would skip is a false
|
||||
// alarm someone fixes; missing one is the failure this file exists to prevent.
|
||||
const MAKE_CONDITIONAL = /^\s*(?:ifeq|ifneq|ifdef|ifndef|else|endif)\b/;
|
||||
|
||||
// `@$(FMT)` is one token away from being a second prettier pass, and until the
|
||||
// variable is resolved it reads as a line that invokes nothing: `make -n check`
|
||||
// printed both `script/check` and `script/fmt-check` while this file scored
|
||||
// one. `$(MAKE)` was already special-cased below, which is this same rule
|
||||
// half-applied to a single name; this is the general form of it.
|
||||
//
|
||||
// Deliberately only the simple case: a name assigned a literal on one line,
|
||||
// optionally through an `export`/`override` prefix. `:=` and `=` differ in
|
||||
// when make expands them, which does not change the single value a literal can
|
||||
// take, so those two are read the same way and the last one wins. `?=` differs
|
||||
// in whether it assigns at all — it is skipped when the name already has a
|
||||
// value — so among assignments to one name the first `?=` wins, and reading it
|
||||
// as last-wins would resolve a reference to the string make discards. What is
|
||||
// not read is anything needing evaluation — a value containing another
|
||||
// `$(...)`, a make function, or a `define` body —
|
||||
// because expanding those means implementing make, and a half-implementation
|
||||
// that resolves a variable to the wrong string would count invocations that do
|
||||
// not happen. An unresolved reference is left standing verbatim instead, which
|
||||
// is the same dead end as any other unfollowed edge rather than a wrong answer.
|
||||
const MAKE_ASSIGNMENT = new RegExp(
|
||||
`^(?:(?:export|override)\\s+)*([A-Za-z_][A-Za-z0-9_]*)\\s*(:=|\\?=|=)\\s*(.*)$`,
|
||||
);
|
||||
const MAKE_VARIABLE_REFERENCE = /\$[({]([A-Za-z_][A-Za-z0-9_]*)[)}]/g;
|
||||
|
||||
// `define`/`endef` bodies are skipped rather than parsed: the body is a
|
||||
// multi-line value, so reading its lines as assignments would take whatever
|
||||
// `=` they happen to contain and call it a variable.
|
||||
const makeVariables = (text: string): Map<string, string> => {
|
||||
const variables = new Map<string, string>();
|
||||
let inDefine = false;
|
||||
for (const raw of text.split("\n")) {
|
||||
if (/^\s*endef\b/.test(raw)) {
|
||||
inDefine = false;
|
||||
continue;
|
||||
}
|
||||
if (/^\s*define\b/.test(raw)) {
|
||||
inDefine = true;
|
||||
continue;
|
||||
}
|
||||
if (inDefine || raw.startsWith("\t")) continue;
|
||||
const assignment = MAKE_ASSIGNMENT.exec(raw.replace(/#.*$/, "").trim());
|
||||
if (assignment === null) continue;
|
||||
const [, name, operator, value] = assignment;
|
||||
if (name === undefined || operator === undefined || value === undefined)
|
||||
continue;
|
||||
// `?=` assigns only when the name has no value yet, so the first one
|
||||
// wins where `:=` and `=` let the last one win. Overwriting here would
|
||||
// resolve the reference to a string make never uses.
|
||||
if (operator === "?=" && variables.has(name)) continue;
|
||||
// A value that is itself a reference is the recursive case, and is not
|
||||
// resolved. Recording it would hand the substitution below a string it
|
||||
// cannot finish expanding.
|
||||
if (/\$[({]/.test(value)) continue;
|
||||
variables.set(name, value.trim());
|
||||
}
|
||||
return variables;
|
||||
};
|
||||
|
||||
// One pass, and only over names that were assigned a literal: the result is
|
||||
// never re-scanned for further references, so this cannot recurse or diverge.
|
||||
const expandMakeVariables = (
|
||||
line: string,
|
||||
variables: Map<string, string>,
|
||||
): string =>
|
||||
line.replace(
|
||||
MAKE_VARIABLE_REFERENCE,
|
||||
(reference, name: string) => variables.get(name) ?? reference,
|
||||
);
|
||||
|
||||
const parseMakefile = (text: string): Map<string, MakeTarget> => {
|
||||
const recipes = new Map<string, MakeTarget>();
|
||||
// Collected in a pass of their own because make reads the whole file
|
||||
// before it runs anything: a recipe may spell a variable that the Makefile
|
||||
// assigns further down, and a single pass would leave that one unexpanded
|
||||
// purely because of where its author put it.
|
||||
const variables = makeVariables(text);
|
||||
const makeRecipes = (): Map<string, string[]> => {
|
||||
const recipes = new Map<string, string[]>();
|
||||
let current: string | null = null;
|
||||
for (const line of text.split("\n")) {
|
||||
// Expanded before parsing rather than only on recipe lines, so a
|
||||
// prerequisite written `check: $(FMT)` resolves the same way a recipe
|
||||
// line does. Assignment lines are expanded too and are unaffected: a
|
||||
// literal value has nothing to substitute.
|
||||
const raw = expandMakeVariables(line, variables);
|
||||
for (const raw of read("Makefile").split("\n")) {
|
||||
if (raw.startsWith("\t")) {
|
||||
if (current !== null) {
|
||||
recipes
|
||||
.get(current)
|
||||
?.recipe.push(raw.trim().replace(/^[@-]+/, ""));
|
||||
recipes.get(current)?.push(raw.trim().replace(/^[@-]+/, ""));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (MAKE_CONDITIONAL.test(raw)) continue;
|
||||
const target = new RegExp(`^(${MAKE_TARGET_NAME})\\s*:(?!=)(.*)$`).exec(
|
||||
raw,
|
||||
);
|
||||
current = target === null ? null : (target[1] ?? null);
|
||||
if (target === null || current === null) continue;
|
||||
// `target: prereqs ; command` puts the first recipe line on the target
|
||||
// line itself. Read as prerequisites the whole of it, `;` and the
|
||||
// command split into tokens that name no target and are filtered out,
|
||||
// and the invocation disappears — `check: ; @script/fmt-check` was a
|
||||
// second prettier pass this parser scored as none.
|
||||
const rest = target[2] ?? "";
|
||||
const semicolon = rest.indexOf(";");
|
||||
const inlineRecipe =
|
||||
semicolon === -1 ? "" : rest.slice(semicolon + 1).trim();
|
||||
// Trailing `# comment` is not a prerequisite; neither is the empty
|
||||
// string a split leaves behind.
|
||||
const prerequisites = (
|
||||
semicolon === -1 ? rest : rest.slice(0, semicolon)
|
||||
)
|
||||
.replace(/#.*$/, "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((name) => name !== "");
|
||||
const existing = recipes.get(current);
|
||||
if (existing === undefined) {
|
||||
recipes.set(current, { prerequisites, recipe: [] });
|
||||
} else {
|
||||
existing.prerequisites.push(...prerequisites);
|
||||
}
|
||||
if (inlineRecipe !== "") {
|
||||
recipes
|
||||
.get(current)
|
||||
?.recipe.push(inlineRecipe.replace(/^[@-]+/, ""));
|
||||
const target = /^([a-z][a-z-]*)\s*:(?!=)/.exec(raw);
|
||||
current = target === null ? null : target[1];
|
||||
if (current !== null && !recipes.has(current)) {
|
||||
recipes.set(current, []);
|
||||
}
|
||||
}
|
||||
return recipes;
|
||||
};
|
||||
|
||||
const recipes = parseMakefile(read("Makefile"));
|
||||
|
||||
// Only prerequisites that name a target of this Makefile are followed: the
|
||||
// rest are filenames, or expansions of variables this parser does not
|
||||
// evaluate, and neither is an invocation of anything it could read.
|
||||
const prerequisitesOf = (node: string): string[] => {
|
||||
if (!node.startsWith("make:")) return [];
|
||||
const target = recipes.get(node.slice("make:".length));
|
||||
if (target === undefined) return [];
|
||||
return target.prerequisites
|
||||
.filter((name) => recipes.has(name))
|
||||
.map((name) => `make:${name}`);
|
||||
};
|
||||
const recipes = makeRecipes();
|
||||
|
||||
const packageScripts = (): Record<string, string> => {
|
||||
const pkg = JSON.parse(read("package.json")) as {
|
||||
@@ -323,63 +113,19 @@ const packageScripts = (): Record<string, string> => {
|
||||
|
||||
const scripts = packageScripts();
|
||||
|
||||
// A `RUN <<EOF` body is a command the image executes, and BuildKit runs it on
|
||||
// the default frontend with no `# syntax=` directive, so keeping only the
|
||||
// lines that begin `RUN ` would let a whole shell script hide one line below
|
||||
// one. The body is returned alongside the `RUN` line that opened it. An
|
||||
// unterminated heredoc is a thrown error rather than a silent truncation of
|
||||
// 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/;
|
||||
|
||||
// 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 commands: string[] = [];
|
||||
let terminator: string | null = null;
|
||||
for (const line of executable(text)) {
|
||||
if (terminator !== null) {
|
||||
if (line === terminator) terminator = null;
|
||||
else commands.push(line);
|
||||
continue;
|
||||
}
|
||||
const run = DOCKER_RUN.exec(line);
|
||||
if (run === null) continue;
|
||||
const command = run[1] as string;
|
||||
terminator = HEREDOC_OPEN.exec(command)?.[2] ?? null;
|
||||
commands.push(command);
|
||||
}
|
||||
if (terminator !== null) {
|
||||
throw new Error(`unterminated heredoc: ${terminator}`);
|
||||
}
|
||||
return commands;
|
||||
};
|
||||
|
||||
// Node keys: `script/<name>`, `docker:<Dockerfile>`, `make:<target>`,
|
||||
// `yarn:<package.json script>`, `workflow:<CI workflow file>`.
|
||||
const resolve = (node: string): string[] => {
|
||||
if (node.startsWith("script/")) return executable(read(node));
|
||||
if (node.startsWith("docker:")) {
|
||||
return dockerRunCommands(read(node.slice("docker:".length)));
|
||||
return executable(read(node.slice("docker:".length)))
|
||||
.filter((line) => line.startsWith("RUN "))
|
||||
.map((line) => line.slice("RUN ".length));
|
||||
}
|
||||
// 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
|
||||
// resolves to the bare `|`, which reaches nothing — so the count is exactly
|
||||
// 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.
|
||||
// would resolve to the bare `|`, which reaches nothing and therefore fails
|
||||
// the count rather than passing quietly.
|
||||
if (node.startsWith("workflow:")) {
|
||||
return executable(read(node.slice("workflow:".length)))
|
||||
.filter((line) => /^-?\s*run:\s*\S/.test(line))
|
||||
@@ -394,7 +140,7 @@ const resolve = (node: string): string[] => {
|
||||
if (recipe === undefined) {
|
||||
throw new Error(`no such Makefile target: ${target}`);
|
||||
}
|
||||
return recipe.recipe;
|
||||
return recipe;
|
||||
}
|
||||
if (node.startsWith("yarn:")) {
|
||||
const name = node.slice("yarn:".length);
|
||||
@@ -410,128 +156,42 @@ const resolve = (node: string): string[] => {
|
||||
// Same reasoning as the missing-target error, applied to every node kind: a
|
||||
// node that resolves to no commands contributes zero prettier invocations and
|
||||
// zero edges, which is indistinguishable from a clean result. Fail instead.
|
||||
//
|
||||
// A Makefile target with prerequisites and an empty recipe is the one case
|
||||
// that is genuinely not vacuous — it runs its prerequisites — so it is not
|
||||
// caught here.
|
||||
const commandsOf = (node: string): string[] => {
|
||||
const commands = resolve(node);
|
||||
if (commands.length === 0 && prerequisitesOf(node).length === 0) {
|
||||
if (commands.length === 0) {
|
||||
throw new Error(`node resolved to no commands: ${node}`);
|
||||
}
|
||||
return commands;
|
||||
};
|
||||
|
||||
// Which invocation shapes read as a build, and which do not.
|
||||
//
|
||||
// RECOGNISED: `docker build`, the three management-command spellings of the
|
||||
// same thing — `docker buildx build`, `docker image build`, `docker builder
|
||||
// build` — and any of those reached through global flags standing 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`). All of these read `-f` the same way and
|
||||
// build the Dockerfile it names, so all of them are edges.
|
||||
//
|
||||
// `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, which the old file-flag bug at least still produced — and a tree
|
||||
// running prettier twice reported green.
|
||||
//
|
||||
// NOT RECOGNISED, deliberately: `docker compose build`, which builds services
|
||||
// named in 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. Only
|
||||
// `-`-prefixed tokens (plus their arguments) and those three literal
|
||||
// subcommand words are stepped over, so no other non-flag word between
|
||||
// `docker` and `build` matches, and `docker run -f build` is not a build.
|
||||
const DOCKER_BUILD =
|
||||
/\bdocker(?:\s+-{1,2}[A-Za-z][\w-]*(?:=\S+)?(?:\s+[^-\s]\S*)?)*(?:\s+(?:buildx|image|builder))?\s+build\b/g;
|
||||
|
||||
const edgesOf = (line: string): string[] => {
|
||||
const edges: string[] = [];
|
||||
|
||||
// `"$SCRIPT_DIR/lint"`, `"$ROOT/script/lint"` and a bare `script/lint` are
|
||||
// all the same edge.
|
||||
for (const match of line.matchAll(
|
||||
new RegExp(
|
||||
`(?:\\$SCRIPT_DIR|\\$\\{SCRIPT_DIR\\}|script)/(${MAKE_TARGET_NAME})`,
|
||||
"g",
|
||||
),
|
||||
/(?:\$SCRIPT_DIR|\$\{SCRIPT_DIR\}|script)\/([a-z][a-z-]*)/g,
|
||||
)) {
|
||||
edges.push(`script/${match[1]}`);
|
||||
}
|
||||
|
||||
// Only real targets: `pkg_install gnumake make make make` in
|
||||
// script/bootstrap is a package name, not an invocation of this Makefile.
|
||||
//
|
||||
// `$(MAKE)` and `${MAKE}` count as `make`. Recursive make is spelled that
|
||||
// way by convention rather than as a literal `make`, and this Makefile
|
||||
// already expands variables into recipes (`@$(YARN) tsc --watch`), so a
|
||||
// case-sensitive literal-only match left `@$(MAKE) fmt-check` — the other
|
||||
// one-token way to put the host prettier pass back into `make check` —
|
||||
// unfollowed. Valueless flags between the command and the target (`make
|
||||
// -n check`, `make -j4 check`) are skipped. A flag that takes a separate
|
||||
// argument is not: `make -C sub build` runs `sub/Makefile`'s target, not
|
||||
// this one's, and resolving it against these recipes would be wrong
|
||||
// rather than merely incomplete.
|
||||
for (const match of line.matchAll(
|
||||
new RegExp(
|
||||
`(?:\\$\\(MAKE\\)|\\$\\{MAKE\\}|\\bmake)(?:\\s+-\\S+)*\\s+(${MAKE_TARGET_NAME})`,
|
||||
"g",
|
||||
),
|
||||
)) {
|
||||
for (const match of line.matchAll(/\bmake\s+([a-z][a-z-]*)/g)) {
|
||||
if (recipes.has(match[1] ?? "")) edges.push(`make:${match[1]}`);
|
||||
}
|
||||
|
||||
// Same rule for yarn: `yarn run prettier` is the linter itself (counted,
|
||||
// not followed), `yarn run fmt-check` would be a package.json script that
|
||||
// runs it indirectly.
|
||||
//
|
||||
// The name charset is wider than the Makefile's because `package.json`
|
||||
// script names conventionally carry colons, digits and underscores
|
||||
// (`lint:fmt`, `test:e2e`). Under the narrower charset `yarn run lint:fmt`
|
||||
// read as `yarn run lint`, which is not a script, so it produced no edge
|
||||
// and no count.
|
||||
for (const match of line.matchAll(
|
||||
/\byarn(?:\s+run)?\s+([A-Za-z][A-Za-z0-9_:-]*)/g,
|
||||
)) {
|
||||
for (const match of line.matchAll(/\byarn(?:\s+run)?\s+([a-z][a-z-]*)/g)) {
|
||||
if ((match[1] ?? "") in scripts) edges.push(`yarn:${match[1]}`);
|
||||
}
|
||||
|
||||
// The container lint pass lives behind a `docker build`; without following
|
||||
// it the count would miss the one invocation that is supposed to survive.
|
||||
//
|
||||
// Every spelling of the file flag is read, not just `-f <file>`. Docker
|
||||
// accepts `--file <file>`, `--file=<file>`, `-f=<file>` and the value
|
||||
// attached to the short flag as one token (`-f<file>`) for the same thing,
|
||||
// and matching `-f ` alone resolved all of them to the default
|
||||
// `Dockerfile` edge — so `docker build --file=Dockerfile.lint .` in a
|
||||
// recipe was followed into the wrong file, counted nothing, and reported
|
||||
// green. The flag is searched for anywhere in the invocation, as `-f`
|
||||
// already was, because a real build line wraps it in `--build-arg` and
|
||||
// other flags. A leading `\s` is required so that a longer flag ending in
|
||||
// the same letters cannot supply the match, and the attached form is
|
||||
// allowed only for the short flag, so that `--force-rm` — a long flag that
|
||||
// merely starts with the same letters — still does not read as one.
|
||||
//
|
||||
// Every `docker build` on the line produces an edge, and each one looks for
|
||||
// its file flag only within its own invocation — the slice from the command
|
||||
// to the next `&&`, `||`, `;` or `|`. One `test` and one `exec` over the
|
||||
// whole line got both halves of that wrong: `docker build -f Dockerfile.lint
|
||||
// . && docker build -f Dockerfile.extra .` followed the first file and
|
||||
// dropped the second, so a tree running prettier twice reported green, and a
|
||||
// bare `docker build . && cp -f Dockerfile.lint /tmp/x` read `cp`'s `-f` as
|
||||
// the build's, counting an invocation that never happens and losing the
|
||||
// default `Dockerfile` edge. The bounding is a split on those four
|
||||
// separators and nothing more: a separator inside quotes or a `$(...)`
|
||||
// substitution ends the slice anyway, and a newline-separated command list
|
||||
// is already one line per invocation by the time it gets here.
|
||||
for (const match of line.matchAll(DOCKER_BUILD)) {
|
||||
const invocation = line
|
||||
.slice(match.index)
|
||||
.split(/&&|\|\||;|\|/)[0] as string;
|
||||
const file = /\s(?:--file[=\s]+|-f=?\s*)(\S+)/.exec(invocation);
|
||||
if (/\bdocker\s+build\b/.test(line)) {
|
||||
const file = /\s-f\s+(\S+)/.exec(line);
|
||||
edges.push(`docker:${file === null ? "Dockerfile" : file[1]}`);
|
||||
}
|
||||
|
||||
@@ -557,11 +217,6 @@ const walk = (node: string, path: string[] = [], into?: Walk): Walk => {
|
||||
}
|
||||
result.reached.add(node);
|
||||
|
||||
// Before the recipe, exactly as make does.
|
||||
for (const edge of prerequisitesOf(node)) {
|
||||
walk(edge, [...path, node], result);
|
||||
}
|
||||
|
||||
for (const line of commandsOf(node)) {
|
||||
result.prettier += countPrettier(line);
|
||||
for (const edge of edgesOf(line)) {
|
||||
@@ -678,13 +333,9 @@ const installBranches = (): { withoutYarn: string[]; withYarn: string[] } => {
|
||||
}
|
||||
const close = lines.indexOf("}", open);
|
||||
const body = lines.slice(open + 1, close === -1 ? undefined : close);
|
||||
// The guard has to be a plain positive `missing yarn` test at the front of
|
||||
// the condition. `if ! missing yarn ...` is the same shape to a looser
|
||||
// regex but swaps which branch is which, and since the two branches are
|
||||
// asserted separately below, that would silently relabel them — a failing
|
||||
// test would then name the wrong branch. Anything else throws the named
|
||||
// error below, which is the loud failure this file prefers.
|
||||
const guard = body.findIndex((line) => /^if\s+missing yarn\b/.test(line));
|
||||
const guard = body.findIndex((line) =>
|
||||
/^if\b.*\bmissing yarn\b/.test(line),
|
||||
);
|
||||
const otherwise = body.indexOf("else", guard);
|
||||
const end = body.indexOf("fi", otherwise);
|
||||
if (guard === -1 || otherwise === -1 || end === -1) {
|
||||
@@ -837,244 +488,6 @@ describe("the resolver reads what the shell would run", () => {
|
||||
).toEqual(["script/lint", "script/test", "script/fmt"]);
|
||||
});
|
||||
|
||||
// The two edges the previous version of this file could not see, each
|
||||
// pinned directly as well as through the graph. Both were reproduced as
|
||||
// Makefile mutations that gave `make check` two prettier passes with the
|
||||
// whole suite green.
|
||||
it("follows a Makefile prerequisite as an invocation", () => {
|
||||
// Not hypothetical: this is the shipped Makefile's own `install`.
|
||||
expect(prerequisitesOf("make:install")).toEqual(["make:build-bin"]);
|
||||
expect(prerequisitesOf("make:check")).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats $(MAKE) and ${MAKE} as make", () => {
|
||||
expect(edgesOf("$(MAKE) fmt-check")).toContain("make:fmt-check");
|
||||
expect(edgesOf("${MAKE} fmt-check")).toContain("make:fmt-check");
|
||||
expect(edgesOf("make fmt-check")).toContain("make:fmt-check");
|
||||
expect(edgesOf("make -n fmt-check")).toContain("make:fmt-check");
|
||||
});
|
||||
|
||||
// Two more constructs that gave `make check` a second prettier pass with
|
||||
// the whole suite green, each reproduced as a Makefile mutation before
|
||||
// being pinned here.
|
||||
it("keeps recipe lines inside a make conditional", () => {
|
||||
const recipes = parseMakefile(
|
||||
[
|
||||
"check:",
|
||||
"\t@script/check",
|
||||
"ifeq (1,1)",
|
||||
"\t@script/fmt-check",
|
||||
"endif",
|
||||
].join("\n"),
|
||||
);
|
||||
// What `make -n check` prints, in order.
|
||||
expect(recipes.get("check")?.recipe).toEqual([
|
||||
"script/check",
|
||||
"script/fmt-check",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a recipe written on the target line after a semicolon", () => {
|
||||
const recipes = parseMakefile(
|
||||
["check: ; @script/fmt-check", "\t@script/check"].join("\n"),
|
||||
);
|
||||
expect(recipes.get("check")?.recipe).toEqual([
|
||||
"script/fmt-check",
|
||||
"script/check",
|
||||
]);
|
||||
// And the `;` and the command are not mistaken for prerequisites.
|
||||
expect(recipes.get("check")?.prerequisites).toEqual([]);
|
||||
expect(
|
||||
parseMakefile("check: lint ; @script/fmt-check").get("check"),
|
||||
).toEqual({
|
||||
prerequisites: ["lint"],
|
||||
recipe: ["script/fmt-check"],
|
||||
});
|
||||
});
|
||||
|
||||
// The fifth construct that gave `make check` two prettier passes with the
|
||||
// whole suite green: `FMT := script/fmt-check` and a recipe of `@$(FMT)`.
|
||||
// The shipped Makefile writes recipe lines this way already
|
||||
// (`@$(YARN) tsc --watch`), so an unexpanded variable was a gap in the
|
||||
// house style rather than in an exotic corner of make.
|
||||
it("expands a variable spelled into a recipe line", () => {
|
||||
const recipes = parseMakefile(
|
||||
[
|
||||
"FMT := script/fmt-check",
|
||||
"check:",
|
||||
"\t@$(FMT)",
|
||||
"\t@${FMT}",
|
||||
"\t@script/check",
|
||||
].join("\n"),
|
||||
);
|
||||
// What `make -n check` prints, in order.
|
||||
expect(recipes.get("check")?.recipe).toEqual([
|
||||
"script/fmt-check",
|
||||
"script/fmt-check",
|
||||
"script/check",
|
||||
]);
|
||||
});
|
||||
|
||||
it("expands a variable spelled into a prerequisite list", () => {
|
||||
expect(
|
||||
parseMakefile("FMT = fmt-check\ncheck: $(FMT)").get("check")
|
||||
?.prerequisites,
|
||||
).toEqual(["fmt-check"]);
|
||||
});
|
||||
|
||||
// A recipe may name a variable the Makefile assigns further down.
|
||||
it("expands a variable assigned after the recipe that uses it", () => {
|
||||
const recipes = parseMakefile(
|
||||
["check:", "\t@$(FMT)", "FMT ?= script/fmt-check"].join("\n"),
|
||||
);
|
||||
expect(recipes.get("check")?.recipe).toEqual(["script/fmt-check"]);
|
||||
});
|
||||
|
||||
// `export FMT := script/fmt-check` is an assignment make honours and the
|
||||
// pattern anchored at the name, so the whole line read as neither an
|
||||
// assignment nor a target and `@$(FMT)` stood unresolved: `make -n check`
|
||||
// printed `script/check` and `script/fmt-check` while this file scored
|
||||
// one. `override` reaches the same place by the same route.
|
||||
it("expands a variable assigned through an export or override prefix", () => {
|
||||
expect(
|
||||
parseMakefile(
|
||||
[
|
||||
"export FMT := script/fmt-check",
|
||||
"check:",
|
||||
"\t@$(FMT)",
|
||||
"\t@script/check",
|
||||
].join("\n"),
|
||||
).get("check")?.recipe,
|
||||
).toEqual(["script/fmt-check", "script/check"]);
|
||||
expect(
|
||||
parseMakefile(
|
||||
["override FMT = script/fmt-check", "check:", "\t@$(FMT)"].join(
|
||||
"\n",
|
||||
),
|
||||
).get("check")?.recipe,
|
||||
).toEqual(["script/fmt-check"]);
|
||||
// `export` on its own line names a variable without assigning one, and
|
||||
// is not read as an assignment of the empty string.
|
||||
expect(
|
||||
parseMakefile(
|
||||
[
|
||||
"FMT := script/fmt-check",
|
||||
"export FMT",
|
||||
"check:",
|
||||
"\t@$(FMT)",
|
||||
].join("\n"),
|
||||
).get("check")?.recipe,
|
||||
).toEqual(["script/fmt-check"]);
|
||||
});
|
||||
|
||||
// `?=` assigns only when the name has no value yet, so among assignments
|
||||
// to one name the first `?=` wins where `:=` and `=` let the last one win.
|
||||
// Reading every operator as last-wins resolved `@$(FMT)` to the value make
|
||||
// discards: the file counted an invocation that never happens and missed
|
||||
// the one that does, with the suite green either way.
|
||||
it("keeps the first value when a later assignment is conditional", () => {
|
||||
expect(
|
||||
parseMakefile(
|
||||
[
|
||||
"FMT := script/fmt-check",
|
||||
"FMT ?= script/build",
|
||||
"check:",
|
||||
"\t@$(FMT)",
|
||||
].join("\n"),
|
||||
).get("check")?.recipe,
|
||||
).toEqual(["script/fmt-check"]);
|
||||
// A `?=` that is itself the first assignment does assign, and a `?=`
|
||||
// after it does not.
|
||||
expect(
|
||||
parseMakefile(
|
||||
[
|
||||
"FMT ?= script/fmt-check",
|
||||
"FMT ?= script/build",
|
||||
"check:",
|
||||
"\t@$(FMT)",
|
||||
].join("\n"),
|
||||
).get("check")?.recipe,
|
||||
).toEqual(["script/fmt-check"]);
|
||||
// `:=` and `=` stay last-wins, which is what make does.
|
||||
expect(
|
||||
parseMakefile(
|
||||
[
|
||||
"FMT := script/build",
|
||||
"FMT := script/fmt-check",
|
||||
"check:",
|
||||
"\t@$(FMT)",
|
||||
].join("\n"),
|
||||
).get("check")?.recipe,
|
||||
).toEqual(["script/fmt-check"]);
|
||||
});
|
||||
|
||||
// The edges of the expansion, asserted so the header's not-followed list
|
||||
// is the code's behaviour rather than a claim about it. Each of these
|
||||
// leaves the reference standing verbatim, which reaches nothing — the same
|
||||
// dead end as any other unfollowed edge, and not a wrong resolution.
|
||||
it("leaves a value it cannot resolve to a literal unexpanded", () => {
|
||||
// A make function: resolving it would mean running it.
|
||||
expect(
|
||||
parseMakefile(
|
||||
[
|
||||
"FMT := $(shell echo script/fmt-check)",
|
||||
"check:",
|
||||
"\t@$(FMT)",
|
||||
].join("\n"),
|
||||
).get("check")?.recipe,
|
||||
).toEqual(["$(FMT)"]);
|
||||
// A `define` body is a multi-line value, not a single-line assignment.
|
||||
expect(
|
||||
parseMakefile(
|
||||
[
|
||||
"define RUNFMT",
|
||||
"@script/fmt-check",
|
||||
"endef",
|
||||
"check:",
|
||||
"\t$(RUNFMT)",
|
||||
].join("\n"),
|
||||
).get("check")?.recipe,
|
||||
).toEqual(["$(RUNFMT)"]);
|
||||
// An undefined name is not silently emptied.
|
||||
expect(
|
||||
parseMakefile(["check:", "\t@$(NOPE)"].join("\n")).get("check")
|
||||
?.recipe,
|
||||
).toEqual(["$(NOPE)"]);
|
||||
});
|
||||
|
||||
it("reads a package.json script name that contains a colon", () => {
|
||||
// The lookup against `scripts` is what turns this into an edge, and no
|
||||
// script in this repo is colon-named, so the charset is asserted on
|
||||
// the extraction itself.
|
||||
expect(
|
||||
[
|
||||
..."yarn run lint:fmt".matchAll(
|
||||
/\byarn(?:\s+run)?\s+([A-Za-z][A-Za-z0-9_:-]*)/g,
|
||||
),
|
||||
].map((match) => match[1]),
|
||||
).toEqual(["lint:fmt"]);
|
||||
});
|
||||
|
||||
it("reads the body of a BuildKit heredoc RUN step", () => {
|
||||
expect(
|
||||
dockerRunCommands(
|
||||
[
|
||||
"FROM scratch",
|
||||
"RUN <<EOF",
|
||||
"yarn run prettier --check .",
|
||||
"EOF",
|
||||
"RUN echo done",
|
||||
].join("\n"),
|
||||
),
|
||||
).toContain("yarn run prettier --check .");
|
||||
});
|
||||
|
||||
it("refuses a heredoc that is never terminated", () => {
|
||||
expect(() =>
|
||||
dockerRunCommands("FROM scratch\nRUN <<EOF\nyarn run prettier .\n"),
|
||||
).toThrow(/unterminated heredoc/);
|
||||
});
|
||||
|
||||
it("follows a bare docker build to Dockerfile and -f to its file", () => {
|
||||
expect(edgesOf("docker build .")).toContain("docker:Dockerfile");
|
||||
expect(edgesOf("docker build -f Dockerfile.lint .")).toContain(
|
||||
@@ -1082,119 +495,6 @@ describe("the resolver reads what the shell would run", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// `-f` is not the only spelling of the flag. `--file=Dockerfile.lint` and
|
||||
// `--file Dockerfile.lint` mean exactly what `-f Dockerfile.lint` means,
|
||||
// and matching only `-f` sent both to the default `Dockerfile` edge
|
||||
// instead. That is the false-green shape this whole file exists to
|
||||
// prevent: a second prettier pass added as
|
||||
// `docker build --file=Dockerfile.lint .` was followed into the wrong
|
||||
// Dockerfile, counted nothing, and left every build green. The long form
|
||||
// is an ordinary thing for a human to write, so it is followed rather than
|
||||
// merely documented as unfollowed.
|
||||
//
|
||||
// The equality rather than containment matters: resolving to the right
|
||||
// file is only half of it, the default edge must not also be emitted.
|
||||
it("follows --file in all three spellings as it does -f", () => {
|
||||
expect(edgesOf("docker build --file=Dockerfile.lint .")).toEqual([
|
||||
"docker:Dockerfile.lint",
|
||||
]);
|
||||
expect(edgesOf("docker build --file Dockerfile.lint .")).toEqual([
|
||||
"docker:Dockerfile.lint",
|
||||
]);
|
||||
expect(edgesOf("docker build -f=Dockerfile.lint .")).toEqual([
|
||||
"docker:Dockerfile.lint",
|
||||
]);
|
||||
});
|
||||
|
||||
// The value may be attached to the short flag with no separator at all:
|
||||
// `-fDockerfile.lint` is a single token, and the flag parser reads it as
|
||||
// `-f` naming that file, exactly as `-f Dockerfile.lint` does. Requiring a
|
||||
// separator sent it to the default `Dockerfile` edge instead — the same
|
||||
// false green as the long spellings above, and one the header promised was
|
||||
// followed: a second prettier pass added as `docker build
|
||||
// -fDockerfile.lint .` was resolved into the wrong file, counted nothing,
|
||||
// and left the build green. `-fFILE` is an ordinary thing for a human to
|
||||
// write, so it is followed rather than merely documented as unfollowed.
|
||||
it("follows a short file flag with its value attached", () => {
|
||||
expect(edgesOf("docker build -fDockerfile.lint .")).toEqual([
|
||||
"docker:Dockerfile.lint",
|
||||
]);
|
||||
});
|
||||
|
||||
// The flag is found anywhere in the invocation rather than at a fixed
|
||||
// position, matching how `-f` was already read, since a real build line
|
||||
// carries `--build-arg` and friends around it.
|
||||
it("finds the file flag after other flags and build args", () => {
|
||||
expect(
|
||||
edgesOf(
|
||||
'docker build --build-arg LINT_EPOCH="1" --file=Dockerfile.lint --progress=plain .',
|
||||
),
|
||||
).toEqual(["docker:Dockerfile.lint"]);
|
||||
});
|
||||
|
||||
// A different flag that merely begins with the same letters is not the
|
||||
// file flag: `--force-rm` must not read as one, or its next token would be
|
||||
// resolved as a Dockerfile and thrown on as unreadable.
|
||||
it("does not mistake --force-rm for the file flag", () => {
|
||||
expect(edgesOf("docker build --force-rm .")).toEqual([
|
||||
"docker:Dockerfile",
|
||||
]);
|
||||
});
|
||||
|
||||
// The header says a bundled short flag cluster resolves to the default
|
||||
// rather than to the file it names, which is a limitation and so has to be
|
||||
// pinned: an unpinned limitation is how the header starts overclaiming
|
||||
// again. If someone teaches the resolver to split clusters, this test is
|
||||
// the one that tells them to correct the header too.
|
||||
it("resolves a bundled -qf cluster to the default Dockerfile", () => {
|
||||
expect(edgesOf("docker build -qf Dockerfile.lint .")).toEqual([
|
||||
"docker:Dockerfile",
|
||||
]);
|
||||
});
|
||||
|
||||
// The false green this file exists to prevent, in the one shape it still
|
||||
// had: `script/lint` chaining a second `docker build` after the lint image
|
||||
// built prettier twice and counted once, because a single `test` and a
|
||||
// single `exec` over the line produced exactly one edge and resolved it to
|
||||
// the first file named. That is the same failure as the original duplicate
|
||||
// pass — two invocations, one verdict — reached through the walker instead
|
||||
// of through the repo.
|
||||
it("follows both docker builds when one line chains two", () => {
|
||||
expect(
|
||||
edgesOf(
|
||||
'docker build --build-arg LINT_EPOCH="$(date +%s)" -f Dockerfile.lint . && docker build -f Dockerfile.extra .',
|
||||
),
|
||||
).toEqual(["docker:Dockerfile.lint", "docker:Dockerfile.extra"]);
|
||||
});
|
||||
|
||||
// The other half: a flag search over the whole line reads a *later*
|
||||
// command's `-f` as the build's own. A bare `docker build .` followed by
|
||||
// `cp -f` resolved to `Dockerfile.lint`, which both counts an invocation
|
||||
// that never happens and drops the `Dockerfile` edge — the half of the CI
|
||||
// graph the second entrypoint exists to cover. Each separator that bounds
|
||||
// an invocation is pinned, since the bounding is a split rather than a
|
||||
// shell parse and dropping one of them would be silent.
|
||||
it("does not read a later command's -f as the build's", () => {
|
||||
expect(
|
||||
edgesOf("docker build . && cp -f Dockerfile.lint /tmp/x"),
|
||||
).toEqual(["docker:Dockerfile"]);
|
||||
expect(
|
||||
edgesOf("docker build . || cp -f Dockerfile.lint /tmp/x"),
|
||||
).toEqual(["docker:Dockerfile"]);
|
||||
expect(
|
||||
edgesOf("docker build . ; cp -f Dockerfile.lint /tmp/x"),
|
||||
).toEqual(["docker:Dockerfile"]);
|
||||
expect(edgesOf("docker build . | tee -f Dockerfile.lint")).toEqual([
|
||||
"docker:Dockerfile",
|
||||
]);
|
||||
});
|
||||
|
||||
// This exact-equality assertion is also the block-scalar guard, which is
|
||||
// not obvious from its name: a `run: |` step resolves to the bare `|`,
|
||||
// which reaches nothing, so the prettier count would stay 1 no matter what
|
||||
// the block contains. Pinning the resolved list is what makes such a step
|
||||
// red. The cost is that any legitimate second `run:` step — a cache step,
|
||||
// an `echo` — fails this test for a reason unrelated to prettier.
|
||||
it("reads the run steps of the CI workflow and not its uses steps", () => {
|
||||
expect(commandsOf("workflow:.gitea/workflows/check.yml")).toEqual([
|
||||
"script/cibuild",
|
||||
|
||||
Reference in New Issue
Block a user