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` // 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 // 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 // `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 // not followed. Recipe lines reached through a make conditional are followed,
// added. // 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` // 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.
@@ -115,10 +118,29 @@ interface MakeTarget {
// line spelled `YARN: yarn run`. // line spelled `YARN: yarn run`.
const MAKE_TARGET_NAME = "[a-z][a-z0-9_-]*"; 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>(); const recipes = new Map<string, MakeTarget>();
let current: string | null = null; 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 (raw.startsWith("\t")) {
if (current !== null) { if (current !== null) {
recipes recipes
@@ -127,14 +149,26 @@ const makeRecipes = (): Map<string, MakeTarget> => {
} }
continue; continue;
} }
if (MAKE_CONDITIONAL.test(raw)) continue;
const target = new RegExp(`^(${MAKE_TARGET_NAME})\\s*:(?!=)(.*)$`).exec( const target = new RegExp(`^(${MAKE_TARGET_NAME})\\s*:(?!=)(.*)$`).exec(
raw, raw,
); );
current = target === null ? null : (target[1] ?? null); current = target === null ? null : (target[1] ?? null);
if (target === null || current === null) continue; 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 // Trailing `# comment` is not a prerequisite; neither is the empty
// string a split leaves behind. // string a split leaves behind.
const prerequisites = (target[2] ?? "") const prerequisites = (
semicolon === -1 ? rest : rest.slice(0, semicolon)
)
.replace(/#.*$/, "") .replace(/#.*$/, "")
.trim() .trim()
.split(/\s+/) .split(/\s+/)
@@ -145,11 +179,16 @@ const makeRecipes = (): Map<string, MakeTarget> => {
} else { } else {
existing.prerequisites.push(...prerequisites); existing.prerequisites.push(...prerequisites);
} }
if (inlineRecipe !== "") {
recipes
.get(current)
?.recipe.push(inlineRecipe.replace(/^[@-]+/, ""));
}
} }
return recipes; return recipes;
}; };
const recipes = makeRecipes(); const recipes = parseMakefile(read("Makefile"));
// Only prerequisites that name a target of this Makefile are followed: the // Only prerequisites that name a target of this Makefile are followed: the
// rest are filenames, or expansions of variables this parser does not // 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"); 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", () => { it("reads a package.json script name that contains a colon", () => {
// The lookup against `scripts` is what turns this into an edge, and no // 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 // script in this repo is colon-named, so the charset is asserted on