chore: finish frontend ESLint migration #18
Enable every plugin recommended preset and replace Biome with ESLint formatting. Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
@@ -1,31 +1,31 @@
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
chmodSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} = require("node:fs");
|
||||
const { tmpdir } = require("node:os");
|
||||
const { join, resolve } = require("node:path");
|
||||
const nodePath = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const EXECUTABLE_MODE = 0o755;
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
test("immutable publication resumes exact digest checks after post-push failure", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "wl-image-retry-"));
|
||||
const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-"));
|
||||
const workflow = readFileSync(
|
||||
resolve(__dirname, "../.gitea/workflows/image.yml"),
|
||||
nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const block = workflow
|
||||
.split(" - name: Publish immutable release tag\n run: |\n")[1]
|
||||
.split(" - name:")[0]
|
||||
.replace(/^ {10}/gm, "");
|
||||
const double = `#!${process.execPath}
|
||||
.split(" - name: Publish immutable release tag\n run: |\n", 2)[1]
|
||||
.split(" - name:", 1)[0]
|
||||
.replaceAll(/^ {10}/gm, "");
|
||||
const double = String.raw`#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2).join(' ');
|
||||
fs.appendFileSync('calls', args + '\\n');
|
||||
fs.appendFileSync('calls', args + '\n');
|
||||
if (args.startsWith('manifest inspect')) {
|
||||
if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); }
|
||||
} else if (args.startsWith('buildx imagetools inspect')) {
|
||||
@@ -41,8 +41,10 @@ if (args.startsWith('manifest inspect')) {
|
||||
`;
|
||||
try {
|
||||
for (const name of ["docker", "npm"]) {
|
||||
writeFileSync(join(directory, name), double);
|
||||
chmodSync(join(directory, name), EXECUTABLE_MODE);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
writeFileSync(nodePath.join(directory, name), double);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
|
||||
}
|
||||
const run = (extra = {}) =>
|
||||
spawnSync("/bin/sh", ["-ec", block], {
|
||||
@@ -50,8 +52,8 @@ if (args.startsWith('manifest inspect')) {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${directory}:${process.env.PATH}`,
|
||||
IMAGE: "synthetic/image",
|
||||
PATH: `${directory}:${process.env.PATH}`,
|
||||
RELEASE_TAG: "v1.2.3",
|
||||
REVISION: "commit",
|
||||
...extra,
|
||||
@@ -62,14 +64,16 @@ if (args.startsWith('manifest inspect')) {
|
||||
assert.equal(retry.status, 0, retry.stdout + retry.stderr);
|
||||
assert.notEqual(run({ BAD_REVISION: "1" }).status, 0);
|
||||
assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0);
|
||||
const calls = readFileSync(join(directory, "calls"), "utf8");
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
const calls = readFileSync(nodePath.join(directory, "calls"), "utf8");
|
||||
assert.equal(
|
||||
calls.split("\n").filter((line) => line.startsWith("buildx build"))
|
||||
calls.split("\n").filter(line => line.startsWith("buildx build"))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.ok(calls.includes("synthetic/image@sha256:exact"));
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
+14
-11
@@ -1,14 +1,14 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import nodePath from "node:path";
|
||||
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
join(tmpdir(), "wealth-ledger-openapi-"),
|
||||
nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
|
||||
);
|
||||
const generatedPath = join(temporaryDirectory, "schema.d.ts");
|
||||
const committedPath = resolve("src/shared/api/generated/schema.d.ts");
|
||||
const cliPath = resolve("node_modules/openapi-typescript/bin/cli.js");
|
||||
const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
|
||||
const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
|
||||
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
@@ -18,12 +18,10 @@ try {
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
if (generated.status !== 0) {
|
||||
process.stderr.write(generated.stderr || generated.stdout);
|
||||
exitCode = generated.status ?? 1;
|
||||
} else {
|
||||
if (generated.status === 0) {
|
||||
const [committed, current] = await Promise.all([
|
||||
readFile(committedPath, "utf8"),
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
readFile(generatedPath, "utf8"),
|
||||
]);
|
||||
if (committed !== current) {
|
||||
@@ -33,8 +31,13 @@ try {
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
else {
|
||||
process.stderr.write(generated.stderr || generated.stdout);
|
||||
exitCode = generated.status ?? 1;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await rm(temporaryDirectory, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
process.exitCode = exitCode;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { version } = require("../package.json");
|
||||
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
||||
const git = (...arguments_) => execFileSync("git", arguments_, { encoding: "utf8" }).trim();
|
||||
|
||||
assert.equal(
|
||||
git("status", "--porcelain"),
|
||||
"",
|
||||
"Commit all changes before preparing a release",
|
||||
);
|
||||
const tags = git(
|
||||
const tag_ = git(
|
||||
"tag",
|
||||
"--merged",
|
||||
"HEAD",
|
||||
@@ -17,9 +19,9 @@ const tags = git(
|
||||
"--sort=-version:refname",
|
||||
)
|
||||
.split("\n")
|
||||
.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||
assert.equal(
|
||||
version,
|
||||
tags[0]?.slice(1) ?? "0.0.0",
|
||||
tag_?.slice(1) ?? "0.0.0",
|
||||
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.",
|
||||
);
|
||||
|
||||
+117
-6
@@ -1,6 +1,22 @@
|
||||
import react from "@eslint-react/eslint-plugin";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import query from "@tanstack/eslint-plugin-query";
|
||||
import vitest from "@vitest/eslint-plugin";
|
||||
import { ESLint } from "eslint";
|
||||
import { importX } from "eslint-plugin-import-x";
|
||||
import jestDom from "eslint-plugin-jest-dom";
|
||||
import perfectionist from "eslint-plugin-perfectionist";
|
||||
import hooks from "eslint-plugin-react-hooks";
|
||||
import refresh from "eslint-plugin-react-refresh";
|
||||
import regexp from "eslint-plugin-regexp";
|
||||
import security from "eslint-plugin-security";
|
||||
import sonarjs from "eslint-plugin-sonarjs";
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
import testingLibrary from "eslint-plugin-testing-library";
|
||||
import unicorn from "eslint-plugin-unicorn";
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { ESLint } from "eslint";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
|
||||
const eslint = new ESLint();
|
||||
@@ -12,7 +28,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
||||
],
|
||||
[
|
||||
"packages/ui/src/EntryCard/EntryCard.tsx",
|
||||
'import { useState } from "react"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }',
|
||||
"import { useState } from \"react\"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }",
|
||||
"react-hooks/rules-of-hooks",
|
||||
],
|
||||
[
|
||||
@@ -25,26 +41,61 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
||||
"export default 1;",
|
||||
"import-x/no-default-export",
|
||||
],
|
||||
[
|
||||
"src/shared/api/problem-details.ts",
|
||||
"new Error(\"forgot to throw\");",
|
||||
"sonarjs/no-unthrown-error",
|
||||
],
|
||||
[
|
||||
"src/shared/api/problem-details.ts",
|
||||
"export const pattern = /[aab]/;",
|
||||
"regexp/no-dupe-characters-character-class",
|
||||
],
|
||||
[
|
||||
"src/shared/api/problem-details.ts",
|
||||
"export type Status = \"z\" | \"a\";",
|
||||
"perfectionist/sort-union-types",
|
||||
],
|
||||
[
|
||||
"src/shared/api/problem-details.ts",
|
||||
"//missing space\nexport const ready = true;",
|
||||
"@stylistic/spaced-comment",
|
||||
],
|
||||
[
|
||||
"src/shared/api/problem-details.ts",
|
||||
"export function pattern(input: string) { return new RegExp(input); }",
|
||||
"security/detect-non-literal-regexp",
|
||||
],
|
||||
[
|
||||
"scripts/check-openapi.mjs",
|
||||
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
|
||||
"security/detect-child-process",
|
||||
],
|
||||
[
|
||||
"packages/ui/src/EntryCard/EntryCard.test.tsx",
|
||||
"import { expect } from \"vitest\"; expect(document.activeElement).toBe(document.body);",
|
||||
"jest-dom/prefer-focus",
|
||||
],
|
||||
[
|
||||
"src/shared/api/client.test.ts",
|
||||
'import { it } from "vitest"; it.only("focused", () => {});',
|
||||
"import { it } from \"vitest\"; it.only(\"focused\", () => {});",
|
||||
"vitest/no-focused-tests",
|
||||
],
|
||||
[
|
||||
"packages/ui/src/EntryCard/EntryCard.test.tsx",
|
||||
'import { screen } from "@testing-library/react"; screen.findByText("ready");',
|
||||
"import { screen } from \"@testing-library/react\"; screen.findByText(\"ready\");",
|
||||
"testing-library/await-async-queries",
|
||||
],
|
||||
[
|
||||
"packages/ui/src/EntryCard/EntryCard.tsx",
|
||||
'import { QueryClient } from "@tanstack/react-query"; export function Example() { const client = new QueryClient(); return <div>{String(client)}</div>; }',
|
||||
"import { QueryClient } from \"@tanstack/react-query\"; export function Example() { const client = new QueryClient(); return <div>{String(client)}</div>; }",
|
||||
"@tanstack/query/stable-query-client",
|
||||
],
|
||||
]) {
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
assert.equal(result.fatalErrorCount, 0, filePath);
|
||||
assert.ok(
|
||||
result.messages.some((message) => message.ruleId === rule),
|
||||
result.messages.some(message => message.ruleId === rule),
|
||||
rule,
|
||||
);
|
||||
}
|
||||
@@ -62,3 +113,63 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
||||
assert.ok(story.rules["storybook/default-exports"][0] > 0);
|
||||
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
|
||||
});
|
||||
|
||||
test("all recommended plugin rules remain enabled in their intended scopes", async () => {
|
||||
const eslint = new ESLint();
|
||||
const source = "src/shared/api/problem-details.ts";
|
||||
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
|
||||
for (const [preset, filePath] of [
|
||||
[tseslint.configs.recommendedTypeChecked, source],
|
||||
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
|
||||
[unicorn.configs.recommended, source],
|
||||
[sonarjs.configs.recommended, source],
|
||||
[regexp.configs.recommended, source],
|
||||
[security.configs.recommended, source],
|
||||
[perfectionist.configs["recommended-natural"], source],
|
||||
[stylistic.configs.recommended, component],
|
||||
[react.configs["recommended-type-checked"], component],
|
||||
[hooks.configs.flat.recommended, component],
|
||||
[refresh.configs.vite, component],
|
||||
[query.configs["flat/recommended"], component],
|
||||
[vitest.configs.recommended, unit],
|
||||
[testingLibrary.configs["flat/react"], unit],
|
||||
[jestDom.configs["flat/recommended"], unit],
|
||||
...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]),
|
||||
]) {
|
||||
const configs = [preset].flat();
|
||||
const expected = Object.assign({}, ...configs.map(config => config.rules));
|
||||
const config = await eslint.calculateConfigForFile(filePath);
|
||||
const actual = new Map(Object.entries(config.rules));
|
||||
for (const [rule, setting] of Object.entries(expected)) {
|
||||
const severity = Array.isArray(setting) ? setting[0] : setting;
|
||||
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
|
||||
assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
|
||||
const eslint = new ESLint({ fix: true });
|
||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||
const [fixed] = await eslint.lintText(
|
||||
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
|
||||
{ filePath },
|
||||
);
|
||||
assert.equal(fixed.fatalErrorCount, 0);
|
||||
assert.equal(fixed.errorCount, 0);
|
||||
assert.ok(fixed.output);
|
||||
const [again] = await eslint.lintText(fixed.output, { filePath });
|
||||
assert.equal(again.errorCount, 0);
|
||||
assert.equal(again.output, undefined);
|
||||
});
|
||||
|
||||
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
|
||||
const eslint = new ESLint({ fix: true });
|
||||
const [fixed] = await eslint.lintText(
|
||||
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
|
||||
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
|
||||
);
|
||||
assert.equal(fixed.errorCount, 0);
|
||||
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
|
||||
});
|
||||
|
||||
+22
-13
@@ -9,53 +9,62 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join, resolve } from "node:path";
|
||||
import nodePath from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
const GITHUB_TOKEN_RANDOM_BYTES = 18;
|
||||
|
||||
test("pre-commit scans the index, redacts findings and fails closed", () => {
|
||||
const hook = resolve(".husky/pre-commit");
|
||||
const cwd = mkdtempSync(join(tmpdir(), "frontend-hook-"));
|
||||
const bin = join(cwd, "bin");
|
||||
const marker = join(cwd, "downstream");
|
||||
const hook = nodePath.resolve(".husky/pre-commit");
|
||||
const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-"));
|
||||
const bin = nodePath.join(cwd, "bin");
|
||||
const marker = nodePath.join(cwd, "downstream");
|
||||
const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`;
|
||||
const git = (...args) => execFileSync("git", args, { cwd, stdio: "pipe" });
|
||||
const run = (path = `${bin}${delimiter}${process.env.PATH}`) =>
|
||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
||||
const git = (...arguments_) => execFileSync("git", arguments_, { cwd, stdio: "pipe" });
|
||||
const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) =>
|
||||
spawnSync("/bin/sh", ["-e", hook], {
|
||||
cwd,
|
||||
env: { ...process.env, PATH: path },
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: path },
|
||||
});
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
mkdirSync(bin);
|
||||
for (const command of ["npx", "npm"]) {
|
||||
writeFileSync(join(bin, command), "#!/bin/sh\necho ran >> downstream\n", {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", {
|
||||
mode: 0o755,
|
||||
});
|
||||
}
|
||||
git("init", "--quiet");
|
||||
writeFileSync(join(cwd, "sample.txt"), "safe staged content\n");
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
|
||||
git("add", "sample.txt");
|
||||
// An unstaged secret must not be mistaken for the index being committed.
|
||||
writeFileSync(join(cwd, "sample.txt"), `token=${token}\n`);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
writeFileSync(nodePath.join(cwd, "sample.txt"), `token=${token}\n`);
|
||||
let result = run();
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
"clean index must pass; install Gitleaks first",
|
||||
);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
assert.ok(existsSync(marker));
|
||||
rmSync(marker);
|
||||
git("add", "sample.txt");
|
||||
result = run();
|
||||
assert.equal(result.status, 1, "staged secret must block commit");
|
||||
assert.ok(!`${result.stdout}${result.stderr}`.includes(token));
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
assert.ok(!existsSync(marker), "formatting/dependency checks must not run");
|
||||
result = run(bin);
|
||||
assert.notEqual(result.status, 0, "missing Gitleaks must block commit");
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||
assert.ok(!existsSync(marker));
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
finally {
|
||||
rmSync(cwd, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user