Run all linting in Docker via Dockerfile.lint (closes #30) #31

Open
clawbot wants to merge 20 commits from next into main
Showing only changes of commit f4ecef8820 - Show all commits
+83 -6
View File
@@ -29,8 +29,11 @@
// 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. Within those edges, a prettier call is caught wherever it is
// added.
// 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. Within those edges, a prettier
// call is 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.
@@ -115,10 +118,29 @@ interface MakeTarget {
// line spelled `YARN: yarn run`.
const MAKE_TARGET_NAME = "[a-z][a-z0-9_-]*";
const makeRecipes = (): Map<string, MakeTarget> => {
// 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/;
const parseMakefile = (text: string): Map<string, MakeTarget> => {
const recipes = new Map<string, MakeTarget>();
let current: string | null = null;
for (const raw of read("Makefile").split("\n")) {
for (const raw of text.split("\n")) {
if (raw.startsWith("\t")) {
if (current !== null) {
recipes
@@ -127,14 +149,26 @@ const makeRecipes = (): Map<string, MakeTarget> => {
}
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 = (target[2] ?? "")
const prerequisites = (
semicolon === -1 ? rest : rest.slice(0, semicolon)
)
.replace(/#.*$/, "")
.trim()
.split(/\s+/)
@@ -145,11 +179,16 @@ const makeRecipes = (): Map<string, MakeTarget> => {
} else {
existing.prerequisites.push(...prerequisites);
}
if (inlineRecipe !== "") {
recipes
.get(current)
?.recipe.push(inlineRecipe.replace(/^[@-]+/, ""));
}
}
return recipes;
};
const recipes = makeRecipes();
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
@@ -630,6 +669,44 @@ describe("the resolver reads what the shell would run", () => {
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"],
});
});
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