Run all linting in Docker via Dockerfile.lint (closes #30) #31
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user