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
|
||||||
|
|
||||||
Linting runs in a container, one way, in every path that produces a lint verdict
|
Linting runs in a container, one way, everywhere. `script/lint` builds
|
||||||
for `make check` or the pre-commit hook. `script/lint` builds `Dockerfile.lint`,
|
`Dockerfile.lint`, which copies the repo into a digest-pinned node image and
|
||||||
which copies the repo into a digest-pinned node image and runs eslint and
|
runs eslint and prettier as build steps, so a successful build is a clean lint.
|
||||||
prettier as build steps, so a successful build is a clean lint. No lint path
|
There is no host lint path: docker is required to lint, and that also works
|
||||||
reachable from `script/check` or `script/precommit` runs on the host: every lint
|
where the docker daemon is remote and bind mounts are impossible.
|
||||||
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.
|
|
||||||
|
|
||||||
The formatting check is part of that, not a step beside it. `script/check` and
|
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
|
`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.
|
invocation graph, so a second pass cannot creep back in unnoticed.
|
||||||
|
|
||||||
`script/fmt-check` remains as a standalone entrypoint for asking the formatting
|
`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
|
question on its own, without docker and without the rest of lint. Its verdict
|
||||||
last formatting path on the host — `script/fmt` writes with the host prettier,
|
cannot drift from the container's: prettier is pinned to an exact version,
|
||||||
and `package.json` exposes `fmt` and `fmt-check` as direct prettier calls — it
|
installed from `yarn.lock` under `--frozen-lockfile` in both places, and reads
|
||||||
is the one of them that the check graph could plausibly have reached, and
|
`.gitignore` as its default ignore file — which is why `.dockerignore`
|
||||||
nothing reaches it: neither `script/check` nor `script/precommit` calls it, so
|
deliberately keeps `.gitignore` in the build context.
|
||||||
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.
|
|
||||||
|
|
||||||
Lint happens in exactly one place in that graph, which constrains the rest of
|
Lint happens in exactly one place, which constrains the rest of the build.
|
||||||
the build. `script/check` calls `script/lint`, so `make check` cannot run inside
|
`script/check` calls `script/lint`, so `make check` cannot run inside a
|
||||||
a container without asking for docker inside docker. The image built from
|
container without asking for docker inside docker. The image built from
|
||||||
`Dockerfile` therefore runs `make test` and `make build` and does not lint;
|
`Dockerfile` therefore runs `make test` and `make build` and does not lint;
|
||||||
`script/cibuild` builds `Dockerfile.lint` first and that image second, so CI
|
`script/cibuild` builds `Dockerfile.lint` first and that image second, so CI
|
||||||
gets both verdicts.
|
gets both verdicts.
|
||||||
@@ -534,8 +526,8 @@ documents:
|
|||||||
runs the tests) must pass before merging to `main`. `make fmt-check` is
|
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
|
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
|
separate requirement: `make lint` already covers it, and running both would
|
||||||
check formatting twice. Never invoke eslint or prettier directly; every lint
|
check formatting twice. Never invoke eslint or prettier directly; linting runs
|
||||||
verdict `make check` and the pre-commit hook produce comes from the container.
|
in the container only.
|
||||||
|
|
||||||
- **Formatting:** prettier with 4-space indents and `proseWrap: always` for
|
- **Formatting:** prettier with 4-space indents and `proseWrap: always` for
|
||||||
markdown. Use `make fmt` to format. Use `yarn` not `npm`.
|
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
|
// 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,
|
// actually uses to reach another command — `run:` steps in the CI workflow,
|
||||||
// `"$SCRIPT_DIR/<name>"` and `script/<name>` into other scripts, `make <target>`
|
// `"$SCRIPT_DIR/<name>"` and `script/<name>` into other scripts, `make <target>`
|
||||||
// and `$(MAKE) <target>` through the Makefile shims, the prerequisites of a
|
// through the Makefile shims, `yarn run <name>` through the `package.json`
|
||||||
// Makefile target, `yarn run <name>` through the `package.json` scripts, and
|
// scripts, and `docker build -f <file>` into that Dockerfile's `RUN` steps — and
|
||||||
// `docker build` into the `RUN` steps of the Dockerfile it names, heredoc
|
// counts the prettier invocations it finds. A prettier call added anywhere in
|
||||||
// bodies included — and counts the prettier invocations it finds.
|
// that graph is therefore caught, wherever it is added.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Two entrypoints are walked, because they cover different graphs: `make check`
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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
|
// zero, and prettier is counted per occurrence rather than per line, so two
|
||||||
// than per line, so two prettier calls or two `docker build`s chained with `&&`
|
// invocations chained with `&&` cannot read as one.
|
||||||
// on one line cannot read as one.
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
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
|
// Makefile targets are thin shims (`check:` / tab / `@script/check`), so a
|
||||||
// `make <target>` edge has to resolve through them to keep "per `make check`"
|
// `make <target>` edge has to resolve through them to keep "per `make check`"
|
||||||
// meaning what it says. Recipe lines are the tab-indented ones.
|
// meaning what it says. Recipe lines are the tab-indented ones.
|
||||||
//
|
const makeRecipes = (): Map<string, string[]> => {
|
||||||
// The prerequisite list is read as well, and it is not decoration: make runs a
|
const recipes = new Map<string, string[]>();
|
||||||
// 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);
|
|
||||||
let current: string | null = null;
|
let current: string | null = null;
|
||||||
for (const line of text.split("\n")) {
|
for (const raw of read("Makefile").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);
|
|
||||||
if (raw.startsWith("\t")) {
|
if (raw.startsWith("\t")) {
|
||||||
if (current !== null) {
|
if (current !== null) {
|
||||||
recipes
|
recipes.get(current)?.push(raw.trim().replace(/^[@-]+/, ""));
|
||||||
.get(current)
|
|
||||||
?.recipe.push(raw.trim().replace(/^[@-]+/, ""));
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (MAKE_CONDITIONAL.test(raw)) continue;
|
const target = /^([a-z][a-z-]*)\s*:(?!=)/.exec(raw);
|
||||||
const target = new RegExp(`^(${MAKE_TARGET_NAME})\\s*:(?!=)(.*)$`).exec(
|
current = target === null ? null : target[1];
|
||||||
raw,
|
if (current !== null && !recipes.has(current)) {
|
||||||
);
|
recipes.set(current, []);
|
||||||
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(/^[@-]+/, ""));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return recipes;
|
return recipes;
|
||||||
};
|
};
|
||||||
|
|
||||||
const recipes = parseMakefile(read("Makefile"));
|
const recipes = makeRecipes();
|
||||||
|
|
||||||
// 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 packageScripts = (): Record<string, string> => {
|
const packageScripts = (): Record<string, string> => {
|
||||||
const pkg = JSON.parse(read("package.json")) as {
|
const pkg = JSON.parse(read("package.json")) as {
|
||||||
@@ -323,63 +113,19 @@ const packageScripts = (): Record<string, string> => {
|
|||||||
|
|
||||||
const scripts = packageScripts();
|
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>`,
|
// Node keys: `script/<name>`, `docker:<Dockerfile>`, `make:<target>`,
|
||||||
// `yarn:<package.json script>`, `workflow:<CI workflow file>`.
|
// `yarn:<package.json script>`, `workflow:<CI workflow file>`.
|
||||||
const resolve = (node: string): string[] => {
|
const resolve = (node: string): string[] => {
|
||||||
if (node.startsWith("script/")) return executable(read(node));
|
if (node.startsWith("script/")) return executable(read(node));
|
||||||
if (node.startsWith("docker:")) {
|
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,
|
// 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
|
||||||
// resolves to the bare `|`, which reaches nothing — so the count is exactly
|
// would resolve to the bare `|`, which reaches nothing and therefore fails
|
||||||
// what such a step does NOT move, and it is the pinned resolved list, not
|
// the count rather than passing quietly.
|
||||||
// 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))
|
||||||
@@ -394,7 +140,7 @@ const resolve = (node: string): string[] => {
|
|||||||
if (recipe === undefined) {
|
if (recipe === undefined) {
|
||||||
throw new Error(`no such Makefile target: ${target}`);
|
throw new Error(`no such Makefile target: ${target}`);
|
||||||
}
|
}
|
||||||
return recipe.recipe;
|
return recipe;
|
||||||
}
|
}
|
||||||
if (node.startsWith("yarn:")) {
|
if (node.startsWith("yarn:")) {
|
||||||
const name = node.slice("yarn:".length);
|
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
|
// Same reasoning as the missing-target error, applied to every node kind: a
|
||||||
// node that resolves to no commands contributes zero prettier invocations and
|
// node that resolves to no commands contributes zero prettier invocations and
|
||||||
// zero edges, which is indistinguishable from a clean result. Fail instead.
|
// 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 commandsOf = (node: string): string[] => {
|
||||||
const commands = resolve(node);
|
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}`);
|
throw new Error(`node resolved to no commands: ${node}`);
|
||||||
}
|
}
|
||||||
return commands;
|
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 edgesOf = (line: string): string[] => {
|
||||||
const edges: string[] = [];
|
const edges: string[] = [];
|
||||||
|
|
||||||
// `"$SCRIPT_DIR/lint"`, `"$ROOT/script/lint"` and a bare `script/lint` are
|
// `"$SCRIPT_DIR/lint"`, `"$ROOT/script/lint"` and a bare `script/lint` are
|
||||||
// all the same edge.
|
// all the same edge.
|
||||||
for (const match of line.matchAll(
|
for (const match of line.matchAll(
|
||||||
new RegExp(
|
/(?:\$SCRIPT_DIR|\$\{SCRIPT_DIR\}|script)\/([a-z][a-z-]*)/g,
|
||||||
`(?:\\$SCRIPT_DIR|\\$\\{SCRIPT_DIR\\}|script)/(${MAKE_TARGET_NAME})`,
|
|
||||||
"g",
|
|
||||||
),
|
|
||||||
)) {
|
)) {
|
||||||
edges.push(`script/${match[1]}`);
|
edges.push(`script/${match[1]}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only real targets: `pkg_install gnumake make make make` in
|
// Only real targets: `pkg_install gnumake make make make` in
|
||||||
// script/bootstrap is a package name, not an invocation of this Makefile.
|
// script/bootstrap is a package name, not an invocation of this Makefile.
|
||||||
//
|
for (const match of line.matchAll(/\bmake\s+([a-z][a-z-]*)/g)) {
|
||||||
// `$(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",
|
|
||||||
),
|
|
||||||
)) {
|
|
||||||
if (recipes.has(match[1] ?? "")) edges.push(`make:${match[1]}`);
|
if (recipes.has(match[1] ?? "")) edges.push(`make:${match[1]}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same rule for yarn: `yarn run prettier` is the linter itself (counted,
|
// 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
|
// not followed), `yarn run fmt-check` would be a package.json script that
|
||||||
// runs it indirectly.
|
// runs it indirectly.
|
||||||
//
|
for (const match of line.matchAll(/\byarn(?:\s+run)?\s+([a-z][a-z-]*)/g)) {
|
||||||
// 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,
|
|
||||||
)) {
|
|
||||||
if ((match[1] ?? "") in scripts) edges.push(`yarn:${match[1]}`);
|
if ((match[1] ?? "") in scripts) edges.push(`yarn:${match[1]}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The container lint pass lives behind a `docker build`; without following
|
// The container lint pass lives behind a `docker build`; without following
|
||||||
// it the count would miss the one invocation that is supposed to survive.
|
// it the count would miss the one invocation that is supposed to survive.
|
||||||
//
|
if (/\bdocker\s+build\b/.test(line)) {
|
||||||
// Every spelling of the file flag is read, not just `-f <file>`. Docker
|
const file = /\s-f\s+(\S+)/.exec(line);
|
||||||
// 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);
|
|
||||||
edges.push(`docker:${file === null ? "Dockerfile" : file[1]}`);
|
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);
|
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)) {
|
for (const line of commandsOf(node)) {
|
||||||
result.prettier += countPrettier(line);
|
result.prettier += countPrettier(line);
|
||||||
for (const edge of edgesOf(line)) {
|
for (const edge of edgesOf(line)) {
|
||||||
@@ -678,13 +333,9 @@ const installBranches = (): { withoutYarn: string[]; withYarn: string[] } => {
|
|||||||
}
|
}
|
||||||
const close = lines.indexOf("}", open);
|
const close = lines.indexOf("}", open);
|
||||||
const body = lines.slice(open + 1, close === -1 ? undefined : close);
|
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
|
const guard = body.findIndex((line) =>
|
||||||
// the condition. `if ! missing yarn ...` is the same shape to a looser
|
/^if\b.*\bmissing yarn\b/.test(line),
|
||||||
// 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 otherwise = body.indexOf("else", guard);
|
const otherwise = body.indexOf("else", guard);
|
||||||
const end = body.indexOf("fi", otherwise);
|
const end = body.indexOf("fi", otherwise);
|
||||||
if (guard === -1 || otherwise === -1 || end === -1) {
|
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"]);
|
).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", () => {
|
it("follows a bare docker build to Dockerfile and -f to its file", () => {
|
||||||
expect(edgesOf("docker build .")).toContain("docker:Dockerfile");
|
expect(edgesOf("docker build .")).toContain("docker:Dockerfile");
|
||||||
expect(edgesOf("docker build -f Dockerfile.lint .")).toContain(
|
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", () => {
|
it("reads the run steps of the CI workflow and not its uses steps", () => {
|
||||||
expect(commandsOf("workflow:.gitea/workflows/check.yml")).toEqual([
|
expect(commandsOf("workflow:.gitea/workflows/check.yml")).toEqual([
|
||||||
"script/cibuild",
|
"script/cibuild",
|
||||||
|
|||||||
Reference in New Issue
Block a user