diff --git a/.changeset/qawolf-lint-command.md b/.changeset/qawolf-lint-command.md new file mode 100644 index 000000000..730915306 --- /dev/null +++ b/.changeset/qawolf-lint-command.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": minor +--- + +Add `qawolf flows lint [pattern]` to lint source files with QA Wolf's rules, honoring the repo's `.eslintrc.json`. It lints every `.ts` and `.js` file in the project when the pattern is omitted — flows, helpers, and page objects alike, skipping generated output such as `dist/` and `coverage/` — exits 1 when a file has a lint error or could not be read, and 0 when every file is clean or only has warnings. diff --git a/.oxlintrc.json b/.oxlintrc.json index 86b5dbcb9..6d0ab5647 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -13,7 +13,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." } ] } @@ -28,7 +28,7 @@ "error", { "name": "Bun", - "message": "Bun global is not available in the nodejs build — use a Bun-compatible API or guard with typeof Bun !== 'undefined'." + "message": "Bun global is not available in the nodejs build \u2014 use a Bun-compatible API or guard with typeof Bun !== 'undefined'." } ], "eslint/eqeqeq": "error", @@ -124,11 +124,11 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/shell/*", "~/shell/**"], - "message": "core/ must not import from shell/ — keep core/ pure." + "message": "core/ must not import from shell/ \u2014 keep core/ pure." }, { "group": ["~/domains/*", "~/domains/**"], @@ -140,15 +140,15 @@ }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "core/ must not import node:fs — keep core/ pure." + "message": "core/ must not import node:fs \u2014 keep core/ pure." }, { "group": ["node:child_process", "child_process"], - "message": "core/ must not import node:child_process — keep core/ pure." + "message": "core/ must not import node:child_process \u2014 keep core/ pure." }, { "group": ["node:readline", "readline"], - "message": "core/ must not import node:readline — keep core/ pure." + "message": "core/ must not import node:readline \u2014 keep core/ pure." } ] } @@ -168,7 +168,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/domains/*", "~/domains/**"], @@ -196,7 +196,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/commands/*", "~/commands/**"], @@ -237,17 +237,21 @@ "group": ["~/domains/publicApi/*", "~/domains/publicApi/**"], "message": "Cross-domain import: shared types belong in core/ or shell/." }, + { + "group": ["~/domains/lint/*", "~/domains/lint/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "domains/ must not import node:fs — file I/O belongs in shell/." + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." }, { "group": ["node:child_process", "child_process"], - "message": "domains/ must not import node:child_process — use SpawnFn from shell/spawn.js." + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." }, { "group": ["node:readline", "readline"], - "message": "domains/ must not import node:readline — use UI from shell/ui/." + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." } ] } @@ -267,7 +271,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/commands/*", "~/commands/**"], @@ -308,17 +312,21 @@ "group": ["~/domains/publicApi/*", "~/domains/publicApi/**"], "message": "Cross-domain import: shared types belong in core/ or shell/." }, + { + "group": ["~/domains/lint/*", "~/domains/lint/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "domains/ must not import node:fs — file I/O belongs in shell/." + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." }, { "group": ["node:child_process", "child_process"], - "message": "domains/ must not import node:child_process — use SpawnFn from shell/spawn.js." + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." }, { "group": ["node:readline", "readline"], - "message": "domains/ must not import node:readline — use UI from shell/ui/." + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." } ] } @@ -338,7 +346,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/commands/*", "~/commands/**"], @@ -379,17 +387,21 @@ "group": ["~/domains/publicApi/*", "~/domains/publicApi/**"], "message": "Cross-domain import: shared types belong in core/ or shell/." }, + { + "group": ["~/domains/lint/*", "~/domains/lint/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "domains/ must not import node:fs — file I/O belongs in shell/." + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." }, { "group": ["node:child_process", "child_process"], - "message": "domains/ must not import node:child_process — use SpawnFn from shell/spawn.js." + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." }, { "group": ["node:readline", "readline"], - "message": "domains/ must not import node:readline — use UI from shell/ui/." + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." } ] } @@ -409,7 +421,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/commands/*", "~/commands/**"], @@ -450,17 +462,21 @@ "group": ["~/domains/publicApi/*", "~/domains/publicApi/**"], "message": "Cross-domain import: shared types belong in core/ or shell/." }, + { + "group": ["~/domains/lint/*", "~/domains/lint/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "domains/ must not import node:fs — file I/O belongs in shell/." + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." }, { "group": ["node:child_process", "child_process"], - "message": "domains/ must not import node:child_process — use SpawnFn from shell/spawn.js." + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." }, { "group": ["node:readline", "readline"], - "message": "domains/ must not import node:readline — use UI from shell/ui/." + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." } ] } @@ -480,7 +496,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/commands/*", "~/commands/**"], @@ -521,17 +537,96 @@ "group": ["~/domains/publicApi/*", "~/domains/publicApi/**"], "message": "Cross-domain import: shared types belong in core/ or shell/." }, + { + "group": ["~/domains/lint/*", "~/domains/lint/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "domains/ must not import node:fs — file I/O belongs in shell/." + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." }, { "group": ["node:child_process", "child_process"], - "message": "domains/ must not import node:child_process — use SpawnFn from shell/spawn.js." + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." }, { "group": ["node:readline", "readline"], - "message": "domains/ must not import node:readline — use UI from shell/ui/." + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." + } + ] + } + ] + } + }, + { + "files": ["src/domains/lint/**/*.ts"], + "rules": { + "eslint/no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["../*"], + "message": "Use \"~/\" path alias instead of relative parent imports." + }, + { + "group": ["bun", "bun:*"], + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." + }, + { + "group": ["~/commands/*", "~/commands/**"], + "message": "domains/ must not import from commands/." + }, + { + "group": ["~/domains/auth/*", "~/domains/auth/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/config/*", "~/domains/config/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/doctor/*", "~/domains/doctor/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/emails/*", "~/domains/emails/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/install/*", "~/domains/install/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": [ + "~/domains/interactiveRunner/*", + "~/domains/interactiveRunner/**" + ], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/runner/*", "~/domains/runner/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/publicApi/*", "~/domains/publicApi/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." + }, + { + "group": ["node:child_process", "child_process"], + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." + }, + { + "group": ["node:readline", "readline"], + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." + }, + { + "group": ["~/domains/flows/*", "~/domains/flows/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." } ] } @@ -551,7 +646,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/commands/*", "~/commands/**"], @@ -592,17 +687,21 @@ "group": ["~/domains/publicApi/*", "~/domains/publicApi/**"], "message": "Cross-domain import: shared types belong in core/ or shell/." }, + { + "group": ["~/domains/lint/*", "~/domains/lint/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "domains/ must not import node:fs — file I/O belongs in shell/." + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." }, { "group": ["node:child_process", "child_process"], - "message": "domains/ must not import node:child_process — use SpawnFn from shell/spawn.js." + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." }, { "group": ["node:readline", "readline"], - "message": "domains/ must not import node:readline — use UI from shell/ui/." + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." } ] } @@ -622,7 +721,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/commands/*", "~/commands/**"], @@ -663,17 +762,21 @@ "group": ["~/domains/runner/*", "~/domains/runner/**"], "message": "Cross-domain import: shared types belong in core/ or shell/." }, + { + "group": ["~/domains/lint/*", "~/domains/lint/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "domains/ must not import node:fs — file I/O belongs in shell/." + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." }, { "group": ["node:child_process", "child_process"], - "message": "domains/ must not import node:child_process — use SpawnFn from shell/spawn.js." + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." }, { "group": ["node:readline", "readline"], - "message": "domains/ must not import node:readline — use UI from shell/ui/." + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." } ] } @@ -693,7 +796,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/commands/*", "~/commands/**"], @@ -731,17 +834,21 @@ "group": ["~/domains/runner/*", "~/domains/runner/**"], "message": "Cross-domain import: shared types belong in core/ or shell/." }, + { + "group": ["~/domains/lint/*", "~/domains/lint/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "domains/ must not import node:fs — file I/O belongs in shell/." + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." }, { "group": ["node:child_process", "child_process"], - "message": "domains/ must not import node:child_process — use SpawnFn from shell/spawn.js." + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." }, { "group": ["node:readline", "readline"], - "message": "domains/ must not import node:readline — use UI from shell/ui/." + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." } ] } @@ -761,7 +868,7 @@ }, { "group": ["bun", "bun:*"], - "message": "Bun module imports are not available in the nodejs build — use a portable alternative." + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." }, { "group": ["~/commands/*", "~/commands/**"], @@ -802,17 +909,21 @@ "group": ["~/domains/publicApi/*", "~/domains/publicApi/**"], "message": "Cross-domain import: shared types belong in core/ or shell/." }, + { + "group": ["~/domains/lint/*", "~/domains/lint/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, { "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], - "message": "domains/ must not import node:fs — file I/O belongs in shell/." + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." }, { "group": ["node:child_process", "child_process"], - "message": "domains/ must not import node:child_process — use SpawnFn from shell/spawn.js." + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." }, { "group": ["node:readline", "readline"], - "message": "domains/ must not import node:readline — use UI from shell/ui/." + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." } ] } @@ -827,17 +938,17 @@ { "object": "process", "property": "stdout", - "message": "core/ and domains/ must not write to process.stdout — return data and let shell/ render it." + "message": "core/ and domains/ must not write to process.stdout \u2014 return data and let shell/ render it." }, { "object": "process", "property": "stderr", - "message": "core/ and domains/ must not write to process.stderr — return data and let shell/ render it." + "message": "core/ and domains/ must not write to process.stderr \u2014 return data and let shell/ render it." }, { "object": "process", "property": "stdin", - "message": "core/ and domains/ must not read from process.stdin — use UI from shell/ui/." + "message": "core/ and domains/ must not read from process.stdin \u2014 use UI from shell/ui/." } ] } diff --git a/package.json b/package.json index 7b1a48a12..aa152f903 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@qawolf/flow-targets": "1.0.0", "@qawolf/flows": "0.1.4", "@qawolf/testkit": "1.1.1", + "@qawolf/workflow-linter": "1.0.0", "commander": "14.0.3", "env-paths": "4.0.0", "picomatch": "4.0.4", diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index 03d3faa92..22b384602 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -140,6 +140,7 @@ that `url`; never guess a route and never send a repository link in its place. | `qawolf environment update` | write | Update an environment owned by the caller's team and return it in the environment.get shape. Omitted fields remain unchanged. | | `qawolf flow addTag` | write | Assign an existing tag to the selected flows. Create tags with tag.create. | | `qawolf flow update` | write | Move a flow between draft and active readiness. The other statuses shown in the app are derived and cannot be set. | +| `qawolf flows lint` | local | Lint source files matching [pattern], or every .ts/.js file when omitted, with QA Wolf's rules, honoring the project's .eslintrc.json | | `qawolf flows list` | local (read with --remote) | List flows matching [pattern] from the local project, or from a QA Wolf environment with --remote | | `qawolf flows pull` | read | Download an environment's flows into the local .qawolf// cache | | `qawolf flows run` | local (read with --env) | Run flows matching [pattern], or every flow when omitted; with --env, pull missing flows from that QA Wolf environment | diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 89dd9aa7c..037f0a102 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -167,6 +167,9 @@ Commands: Wolf environment list [options] [pattern] List flows matching [pattern] from the local project, or from a QA Wolf environment with --remote + lint [options] [pattern] Lint source files matching [pattern], or every + .ts/.js file when omitted, with QA Wolf's rules, + honoring the project's .eslintrc.json pull [options] Download an environment's flows into the local .qawolf// cache help [command] display help for command diff --git a/src/commands/flows/index.ts b/src/commands/flows/index.ts index be715de2e..5f7423005 100644 --- a/src/commands/flows/index.ts +++ b/src/commands/flows/index.ts @@ -8,6 +8,7 @@ import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; import { handleFlowsList } from "~/domains/flows/listDefaults.js"; import { flowsListRemote } from "~/domains/flows/listRemote.js"; +import { registerFlowsLintCommand } from "./lint.register.js"; import { registerFlowsPullCommand } from "./pull.register.js"; import { registerFlowsRunCommand } from "./run.register.js"; import { registerRunWorkerCommand } from "./runWorker.register.js"; @@ -105,5 +106,6 @@ export function registerFlowsCommand( }, ); + registerFlowsLintCommand(flows, signals); registerFlowsPullCommand(flows, signals); } diff --git a/src/commands/flows/lint.register.ts b/src/commands/flows/lint.register.ts new file mode 100644 index 000000000..9d3740c37 --- /dev/null +++ b/src/commands/flows/lint.register.ts @@ -0,0 +1,40 @@ +import type { Command } from "commander"; + +import { declareCommandKind } from "~/commands/commandKind.js"; +import { withContext } from "~/commands/context.js"; +import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; + +import { type FlowsLintFlags, handleFlowsLint } from "./lintDefaults.js"; + +const lintExamples = ` +Examples: + $ qawolf flows lint + $ qawolf flows lint "flows/checkout/**" + $ qawolf flows lint "src/pages/**/*.ts" + $ qawolf flows lint flows/login.flow.ts + +Exits 1 when a file has a lint error, and 0 when every file is clean or only +has warnings.`; + +export function registerFlowsLintCommand( + flows: Command, + signals: SignalRegistry, +): void { + declareCommandKind(flows.command("lint [pattern]"), "local") + .description( + "Lint source files matching [pattern], or every .ts/.js file when omitted, with QA Wolf's rules, honoring the project's .eslintrc.json", + ) + .option( + "--allow-no-match", + "Exit 0 instead of 2 when the pattern selects no lintable file", + false, + ) + .addHelpText("after", lintExamples) + .action( + (pattern: string | undefined, opts: FlowsLintFlags, command: Command) => { + return withContext(signals, (ctx) => + handleFlowsLint(ctx, pattern, opts), + )(opts, command); + }, + ); +} diff --git a/src/commands/flows/lintDefaults.test.ts b/src/commands/flows/lintDefaults.test.ts new file mode 100644 index 000000000..af2f7e1a7 --- /dev/null +++ b/src/commands/flows/lintDefaults.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { makeCtx } from "~/shell/commandContext.testUtils.js"; +import { exitCodes } from "~/shell/exit.js"; +import { makeDefaultFs } from "~/shell/fs.js"; +import { makeTmpDirTracker } from "~/shell/tmpDir.testUtils.js"; + +import { handleFlowsLint } from "./lintDefaults.js"; + +const tracker = makeTmpDirTracker("qawolf-flows-lint-test-"); + +afterEach(() => tracker.cleanup()); + +const brokenFlow = "const value: any = 1;\nexport const doubled = value * 2;\n"; +const cleanFlow = "export const greeting = `hello`;\n"; + +async function inProject( + filesByPath: Record, + run: () => Promise, +): Promise { + const project = await tracker.makeTmpDir(); + await writeFile(join(project, "package.json"), "{}"); + await Promise.all( + Object.entries(filesByPath).map(async ([filePath, content]) => { + const absolutePath = join(project, filePath); + await mkdir(join(absolutePath, ".."), { recursive: true }); + await writeFile(absolutePath, content); + }), + ); + const previousCwd = process.cwd(); + process.chdir(project); + try { + await run(); + } finally { + process.chdir(previousCwd); + } +} + +function writtenText(ctx: ReturnType): string { + return (ctx.ui.write as unknown as { mock: { calls: string[][] } }).mock.calls + .map((call) => call[0]) + .join(""); +} + +describe("handleFlowsLint", () => { + it("lints every source file in the project when no pattern is given", async () => { + await inProject( + { + "flows/broken.flow.ts": brokenFlow, + "flows/nested/also-broken.flow.ts": brokenFlow, + "helpers/not-a-flow.ts": brokenFlow, + "src/pages/LoginPage.ts": brokenFlow, + "data/fixture.json": '{ "value": 1 }\n', + "node_modules/dep/dep.flow.ts": brokenFlow, + }, + async () => { + const ctx = makeCtx("human", { fs: makeDefaultFs() }); + + const result = await handleFlowsLint(ctx, undefined, { + allowNoMatch: false, + }); + + expect(result).toEqual({ + error: "4 lint errors found", + exitCode: exitCodes.testFailure, + }); + const output = writtenText(ctx); + expect(output).toContain("flows/broken.flow.ts"); + expect(output).toContain( + join("flows", "nested", "also-broken.flow.ts"), + ); + expect(output).toContain(join("helpers", "not-a-flow.ts")); + expect(output).toContain(join("src", "pages", "LoginPage.ts")); + expect(output).not.toContain("fixture.json"); + expect(output).not.toContain("dep.flow.ts"); + }, + ); + }); + + it("ignores the files a pattern matches that are not lintable", async () => { + await inProject( + { + "flows/broken.flow.ts": brokenFlow, + "flows/fixture.json": '{ "value": 1 }\n', + }, + async () => { + const ctx = makeCtx("human", { fs: makeDefaultFs() }); + + const result = await handleFlowsLint(ctx, "flows/*", { + allowNoMatch: false, + }); + + expect(result).toEqual({ + error: "1 lint error found", + exitCode: exitCodes.testFailure, + }); + expect(writtenText(ctx)).not.toContain("fixture.json"); + }, + ); + }); + + it("treats a pattern that matches only unlintable files as no match", async () => { + await inProject( + { + "flows/broken.flow.ts": brokenFlow, + "data/fixture.json": '{ "value": 1 }\n', + }, + async () => { + const ctx = makeCtx("human", { fs: makeDefaultFs() }); + + const result = await handleFlowsLint(ctx, "data/**", { + allowNoMatch: false, + }); + + expect(result).toEqual({ + error: + "No lintable source files matched 'data/**'. Pass --allow-no-match to exit 0 instead.", + exitCode: exitCodes.invalidArgs, + }); + expect(ctx.ui.write).not.toHaveBeenCalled(); + }, + ); + }); + + it("lints only the files a pattern selects", async () => { + await inProject( + { + "flows/checkout/pay.flow.ts": brokenFlow, + "flows/login.flow.ts": brokenFlow, + }, + async () => { + const ctx = makeCtx("human", { fs: makeDefaultFs() }); + + const result = await handleFlowsLint(ctx, "flows/checkout/**", { + allowNoMatch: false, + }); + + expect(result).toEqual({ + error: "1 lint error found", + exitCode: exitCodes.testFailure, + }); + expect(writtenText(ctx)).not.toContain("login.flow.ts"); + }, + ); + }); + + it("succeeds without output when every file is clean", async () => { + await inProject({ "flows/clean.flow.ts": cleanFlow }, async () => { + const ctx = makeCtx("human", { fs: makeDefaultFs() }); + + const result = await handleFlowsLint(ctx, undefined, { + allowNoMatch: false, + }); + + expect(result).toBeUndefined(); + expect(ctx.ui.write).not.toHaveBeenCalled(); + }); + }); + + it("succeeds when a file only has warnings", async () => { + await inProject( + { + ".eslintrc.json": JSON.stringify({ + rules: { "@typescript-eslint/no-explicit-any": "warn" }, + }), + "flows/broken.flow.ts": brokenFlow, + }, + async () => { + const ctx = makeCtx("human", { fs: makeDefaultFs() }); + + const result = await handleFlowsLint(ctx, undefined, { + allowNoMatch: false, + }); + + expect(result).toBeUndefined(); + expect(writtenText(ctx)).toContain("1 problem (0 errors, 1 warning)"); + }, + ); + }); + + it("emits the report as data in json mode", async () => { + await inProject({ "flows/broken.flow.ts": brokenFlow }, async () => { + const ctx = makeCtx("json", { fs: makeDefaultFs() }); + + await handleFlowsLint(ctx, undefined, { allowNoMatch: false }); + + expect(ctx.ui.json).toHaveBeenCalledTimes(1); + expect(ctx.ui.write).not.toHaveBeenCalled(); + }); + }); + + it("fails when the pattern selects no file", async () => { + await inProject({ "flows/clean.flow.ts": cleanFlow }, async () => { + const ctx = makeCtx("human", { fs: makeDefaultFs() }); + + const result = await handleFlowsLint(ctx, "flows/checkout/**", { + allowNoMatch: false, + }); + + expect(result).toEqual({ + error: + "No lintable source files matched 'flows/checkout/**'. Pass --allow-no-match to exit 0 instead.", + exitCode: exitCodes.invalidArgs, + }); + }); + }); + + it("succeeds on no match with --allow-no-match", async () => { + await inProject({ "flows/clean.flow.ts": cleanFlow }, async () => { + const ctx = makeCtx("human", { fs: makeDefaultFs() }); + + const result = await handleFlowsLint(ctx, "flows/checkout/**", { + allowNoMatch: true, + }); + + expect(result).toBeUndefined(); + expect(ctx.ui.info).toHaveBeenCalledWith( + "No lintable source files matched.", + ); + }); + }); +}); diff --git a/src/commands/flows/lintDefaults.ts b/src/commands/flows/lintDefaults.ts new file mode 100644 index 000000000..badf2cbba --- /dev/null +++ b/src/commands/flows/lintDefaults.ts @@ -0,0 +1,80 @@ +import { lintMessages } from "~/core/messages/index.js"; +import { buildPatternArgs } from "~/core/patternArgs.js"; +import { pluralize } from "~/core/pluralize.js"; +import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; +import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js"; +import { lintFiles as defaultLintFiles } from "~/domains/lint/lintFiles.js"; +import { + lintablePattern, + selectLintableFiles, +} from "~/domains/lint/selectLintableFiles.js"; +import { renderLintReport } from "~/domains/lint/renderLintReport.js"; +import { noMatchResult } from "~/domains/runner/noMatch.js"; +import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; +import { exitCodes } from "~/shell/exit.js"; +import type { Fs } from "~/shell/fs.js"; +import type { Logger } from "~/shell/logger.js"; + +export type FlowsLintFlags = { readonly allowNoMatch: boolean }; + +export type HandleFlowsLintDeps = { + expandPatterns: ( + patterns: string[], + cwd: string, + logger?: Logger, + ) => Promise; + lintFiles: typeof defaultLintFiles; +}; + +function makeDefaultDeps(fs: Fs): HandleFlowsLintDeps { + return { + expandPatterns: (patterns, cwd, logger) => + defaultExpandPatterns(patterns, cwd, logger, fs), + lintFiles: defaultLintFiles, + }; +} + +export async function handleFlowsLint( + ctx: CommandContext, + pattern: string | undefined, + flags: FlowsLintFlags, + deps?: HandleFlowsLintDeps, +): Promise { + const resolvedDeps = deps ?? makeDefaultDeps(ctx.fs); + const cwd = process.cwd(); + + const matched = await resolvedDeps.expandPatterns( + buildPatternArgs(pattern ?? lintablePattern), + cwd, + ctx.log("flows"), + ); + const files = selectLintableFiles(matched, cwd); + if (files.length === 0) { + return noMatchResult(ctx, { + allowNoMatch: flags.allowNoMatch, + error: lintMessages.noFilesMatchedPattern(pattern), + notice: lintMessages.noFilesMatched, + }); + } + + const report = await resolvedDeps.lintFiles({ + cwd, + filePaths: files, + fs: ctx.fs, + projectDir: resolveProjectDirSafe([...files], ctx.fs), + }); + renderLintReport(ctx.ui, report); + + if (report.errorCount > 0) { + return { + error: `${pluralize(report.errorCount, "lint error")} found`, + exitCode: exitCodes.testFailure, + }; + } + if (report.unreadablePaths.length > 0) { + return { + error: lintMessages.unreadableFiles(report.unreadablePaths.length), + exitCode: exitCodes.testFailure, + }; + } +} diff --git a/src/core/messages/index.ts b/src/core/messages/index.ts index 1e345f184..8ab29f772 100644 --- a/src/core/messages/index.ts +++ b/src/core/messages/index.ts @@ -5,6 +5,7 @@ export { flowsMessages } from "./flows.js"; export { initMessages } from "./init.js"; export { installMessages } from "./install.js"; export { interactiveRunnerMessages } from "./interactiveRunner/index.js"; +export { lintMessages } from "./lint.js"; export { runnerMessages } from "./runner.js"; export { packageLoadFailed } from "./toolNotFound.js"; export { updateCheckMessages } from "./updateCheck.js"; diff --git a/src/core/messages/lint.ts b/src/core/messages/lint.ts new file mode 100644 index 000000000..0d5b0c714 --- /dev/null +++ b/src/core/messages/lint.ts @@ -0,0 +1,13 @@ +import { pluralize } from "~/core/pluralize.js"; + +const allowNoMatchHint = "Pass --allow-no-match to exit 0 instead."; + +export const lintMessages = { + noFilesMatched: "No lintable source files matched.", + noFilesMatchedPattern: (pattern: string | undefined) => + pattern === undefined + ? `No lintable source files found. ${allowNoMatchHint}` + : `No lintable source files matched '${pattern}'. ${allowNoMatchHint}`, + unreadableFiles: (count: number) => + `${pluralize(count, "file")} could not be read, so ${count === 1 ? "it was" : "they were"} not checked`, +}; diff --git a/src/domains/lint/lintFiles.test.ts b/src/domains/lint/lintFiles.test.ts new file mode 100644 index 000000000..6cbacd409 --- /dev/null +++ b/src/domains/lint/lintFiles.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; +import { makeDefaultFs } from "~/shell/fs.js"; +import { makeTmpDirTracker } from "~/shell/tmpDir.testUtils.js"; + +import { lintFiles, type LintReport } from "./lintFiles.js"; +import { formatLintReport } from "./renderLintReport.js"; + +const fs = makeDefaultFs(); +const tracker = makeTmpDirTracker("qawolf-lint-test-"); + +afterEach(() => tracker.cleanup()); + +async function createProject( + filesByPath: Record, +): Promise { + const project = await tracker.makeTmpDir(); + await writeFile(join(project, "package.json"), "{}"); + await Promise.all( + Object.entries(filesByPath).map(async ([filePath, content]) => { + const absolutePath = join(project, filePath); + await mkdir(join(absolutePath, ".."), { recursive: true }); + await writeFile(absolutePath, content); + }), + ); + return project; +} + +function lint(cwd: string, filePaths: string[]): Promise { + const absolute = filePaths.map((filePath) => join(cwd, filePath)); + return lintFiles({ + cwd, + filePaths: absolute, + fs, + projectDir: resolveProjectDirSafe(absolute, fs), + }); +} + +describe("lintFiles", () => { + it("reports nothing for a clean file", async () => { + const project = await createProject({ + "clean.flow.ts": "export const greeting = `hello`;\n", + }); + + const report = await lint(project, ["clean.flow.ts"]); + + expect(report.errorCount).toBe(0); + expect(report.warningCount).toBe(0); + expect(formatLintReport(report)).toBe(""); + }); + + it("reports an error with its position and rule, against the path as typed", async () => { + const project = await createProject({ + "flows/broken.flow.ts": + "const value: any = 1;\nexport const doubled = value * 2;\n", + }); + + const report = await lint(project, ["flows/broken.flow.ts"]); + + expect(report.errorCount).toBe(1); + const output = formatLintReport(report); + expect(output).toContain("flows/broken.flow.ts\n 1:14 error"); + expect(output).toContain("@typescript-eslint/no-explicit-any"); + expect(output).toContain("1 problem (1 error, 0 warnings)"); + }); + + it("uses types from an imported file on disk", async () => { + const project = await createProject({ + "helper.ts": + "export type Options = { count: number };\nexport const options: Options = { count: 1 };\n", + "uses-helper.flow.ts": + 'import { type Options, options } from "./helper.js";\n\nexport const count = (options as Options).count;\n', + }); + + const report = await lint(project, ["uses-helper.flow.ts"]); + + expect(report.errorCount).toBeGreaterThan(0); + expect(formatLintReport(report)).toContain( + "@typescript-eslint/no-unnecessary-type-assertion", + ); + }); + + it("applies the severity the team's .eslintrc.json asks for", async () => { + const project = await createProject({ + ".eslintrc.json": JSON.stringify({ + rules: { "@typescript-eslint/no-explicit-any": "warn" }, + }), + "broken.flow.ts": + "const value: any = 1;\nexport const doubled = value * 2;\n", + }); + + const report = await lint(project, ["broken.flow.ts"]); + + expect(report.errorCount).toBe(0); + expect(report.warningCount).toBe(1); + expect(formatLintReport(report)).toContain( + "warning Unexpected any. Specify a different type.", + ); + }); + + it("finds the team's .eslintrc.json from a subdirectory", async () => { + const project = await createProject({ + ".eslintrc.json": JSON.stringify({ + rules: { "@typescript-eslint/no-explicit-any": "off" }, + }), + "src/flows/broken.flow.ts": + "const value: any = 1;\nexport const doubled = value * 2;\n", + }); + + const report = await lintFiles({ + cwd: join(project, "src/flows"), + filePaths: [join(project, "src/flows/broken.flow.ts")], + fs, + projectDir: project, + }); + + expect(report.errorCount).toBe(0); + expect(formatLintReport(report)).toBe(""); + }); + + it("stops looking for .eslintrc.json above the project package", async () => { + const outside = await tracker.makeTmpDir(); + await writeFile( + join(outside, ".eslintrc.json"), + JSON.stringify({ + rules: { "@typescript-eslint/no-explicit-any": "off" }, + }), + ); + const project = join(outside, "project"); + await mkdir(project); + await writeFile(join(project, "package.json"), "{}"); + await writeFile( + join(project, "broken.flow.ts"), + "const value: any = 1;\nexport const doubled = value * 2;\n", + ); + + const report = await lint(project, ["broken.flow.ts"]); + + expect(report.errorCount).toBe(1); + }); + + it("keeps a warning out of the error count", async () => { + const project = await createProject({ + "unreachable.flow.ts": + '/* eslint no-unreachable: "warn" */\nexport function run(): number {\n return 1;\n return 2;\n}\n', + }); + + const report = await lint(project, ["unreachable.flow.ts"]); + + expect(report.errorCount).toBe(0); + expect(report.warningCount).toBe(1); + expect(formatLintReport(report)).toContain("no-unreachable"); + }); + + it("reports a file it cannot read instead of failing the whole run", async () => { + const project = await createProject({ + "clean.flow.ts": "export const greeting = `hello`;\n", + }); + + const report = await lintFiles({ + cwd: project, + filePaths: [ + join(project, "clean.flow.ts"), + join(project, "vanished.flow.ts"), + ], + fs, + projectDir: project, + }); + + expect(report.unreadablePaths).toEqual(["vanished.flow.ts"]); + expect(report.errorCount).toBe(0); + expect(report.files).toHaveLength(1); + expect(formatLintReport(report)).toContain( + "Not checked, could not be read (1 file):", + ); + }); + + it("does not read an .eslintrc.json outside the project when files span packages", async () => { + const outside = await tracker.makeTmpDir(); + await writeFile( + join(outside, ".eslintrc.json"), + JSON.stringify({ + rules: { "@typescript-eslint/no-explicit-any": "off" }, + }), + ); + const project = join(outside, "project"); + await mkdir(join(project, "b"), { recursive: true }); + await writeFile(join(project, "package.json"), "{}"); + await mkdir(join(project, "a"), { recursive: true }); + await writeFile(join(project, "a/package.json"), "{}"); + await writeFile(join(project, "b/package.json"), "{}"); + const broken = "const value: any = 1;\nexport const doubled = value * 2;\n"; + await writeFile(join(project, "a/one.flow.ts"), broken); + await writeFile(join(project, "b/two.flow.ts"), broken); + + const absolute = [ + join(project, "a/one.flow.ts"), + join(project, "b/two.flow.ts"), + ]; + expect(resolveProjectDirSafe(absolute, fs)).toBeUndefined(); + + const report = await lintFiles({ + cwd: project, + filePaths: absolute, + fs, + projectDir: undefined, + }); + + expect(report.errorCount).toBe(2); + }); +}); diff --git a/src/domains/lint/lintFiles.ts b/src/domains/lint/lintFiles.ts new file mode 100644 index 000000000..c988f2c4d --- /dev/null +++ b/src/domains/lint/lintFiles.ts @@ -0,0 +1,112 @@ +import { relative } from "node:path"; + +import type { WorkflowLintMessage } from "@qawolf/workflow-linter"; +import { makeLinter } from "@qawolf/workflow-linter/node-bundle"; +import { + createTypescriptProgram, + resolveImportGraph, +} from "@qawolf/workflow-linter/program"; + +import { batchMap, flowBatchSize } from "~/core/batchMap.js"; +import type { Fs } from "~/shell/fs.js"; +import { readTeamEslintrcJsonText } from "./readTeamEslintrcJsonText.js"; + +type LintFileReport = { messages: WorkflowLintMessage[]; path: string }; + +export type LintReport = { + errorCount: number; + files: LintFileReport[]; + unreadablePaths: string[]; + warningCount: number; +}; + +export async function lintFiles({ + cwd, + filePaths, + fs, + projectDir, +}: { + cwd: string; + filePaths: readonly string[]; + fs: Fs; + projectDir: string | undefined; +}): Promise { + const eslintrcJsonText = await readTeamEslintrcJsonText({ + cwd, + fs, + projectDir, + }); + const linter = makeLinter({ eslintrcJsonText }); + + const files: LintFileReport[] = []; + const unreadablePaths: string[] = []; + const outcomes = batchMap( + [...filePaths], + (filePath) => lintOneFile({ cwd, filePath, fs, linter }), + flowBatchSize, + ); + for await (const outcome of outcomes) { + if (outcome.type === "could-not-read") unreadablePaths.push(outcome.path); + else files.push(outcome.report); + } + + const messages = files.flatMap((file) => file.messages); + const errorCount = messages.filter( + (message) => message.severity === 2, + ).length; + return { + errorCount, + files, + unreadablePaths, + warningCount: messages.length - errorCount, + }; +} + +type LintOneFileOutcome = + | { path: string; type: "could-not-read" } + | { report: LintFileReport; type: "linted" }; + +async function lintOneFile({ + cwd, + filePath, + fs, + linter, +}: { + cwd: string; + filePath: string; + fs: Fs; + linter: ReturnType; +}): Promise { + const path = relative(cwd, filePath); + + const fileContent = await readFileOrUndefined(fs, filePath); + if (fileContent === undefined) return { path, type: "could-not-read" }; + + const resolvedFiles = await resolveImportGraph({ + fileContent, + fileExists: (importPath) => fs.existsSync(importPath), + filePath, + resolveFileContent: (importPath) => readFileOrUndefined(fs, importPath), + }); + + const messages = linter + .verify(fileContent, { + filename: filePath, + program: createTypescriptProgram(resolvedFiles, filePath), + }) + .filter((message) => message.severity >= 1); + + return { report: { messages, path }, type: "linted" }; +} + +async function readFileOrUndefined( + fs: Fs, + filePath: string, +): Promise { + if (!(await fs.pathExists(filePath))) return undefined; + try { + return await fs.readFile(filePath); + } catch { + return undefined; + } +} diff --git a/src/domains/lint/readTeamEslintrcJsonText.ts b/src/domains/lint/readTeamEslintrcJsonText.ts new file mode 100644 index 000000000..fe607dbe2 --- /dev/null +++ b/src/domains/lint/readTeamEslintrcJsonText.ts @@ -0,0 +1,50 @@ +import { dirname, join } from "node:path"; + +import { eslintrcJsonPath } from "@qawolf/workflow-linter/team-config"; + +import type { Fs } from "~/shell/fs.js"; + +export async function readTeamEslintrcJsonText({ + cwd, + fs, + projectDir, +}: { + cwd: string; + fs: Fs; + projectDir: string | undefined; +}): Promise { + const outermost = outermostSearchedDirectory({ cwd, fs, projectDir }); + + let directory = cwd; + while (true) { + const candidate = join(directory, eslintrcJsonPath); + if (await fs.pathExists(candidate)) return fs.readFile(candidate); + + const parent = dirname(directory); + if (directory === outermost || parent === directory) return undefined; + directory = parent; + } +} + +function outermostSearchedDirectory({ + cwd, + fs, + projectDir, +}: { + cwd: string; + fs: Fs; + projectDir: string | undefined; +}): string { + if (projectDir !== undefined) return projectDir; + return findRepositoryRoot(cwd, fs) ?? cwd; +} + +function findRepositoryRoot(cwd: string, fs: Fs): string | undefined { + let directory = cwd; + while (true) { + if (fs.existsSync(join(directory, ".git"))) return directory; + const parent = dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} diff --git a/src/domains/lint/renderLintReport.ts b/src/domains/lint/renderLintReport.ts new file mode 100644 index 000000000..109de0581 --- /dev/null +++ b/src/domains/lint/renderLintReport.ts @@ -0,0 +1,54 @@ +import type { WorkflowLintMessage } from "@qawolf/workflow-linter"; + +import { pluralize } from "~/core/pluralize.js"; +import type { UI } from "~/shell/ui/types.js"; +import type { LintReport } from "./lintFiles.js"; + +export function renderLintReport(ui: UI, report: LintReport): void { + if (ui.mode === "json") { + ui.json(report); + return; + } + const output = formatLintReport(report); + if (output.length > 0) ui.write(`${output}\n`); +} + +export function formatLintReport(report: LintReport): string { + const blocks = report.files + .map((file) => formatFileMessages(file.path, file.messages)) + .filter((block) => block !== ""); + + const problemCount = report.errorCount + report.warningCount; + const summary = + problemCount === 0 + ? [] + : [ + `${pluralize(problemCount, "problem")} (${pluralize(report.errorCount, "error")}, ${pluralize(report.warningCount, "warning")})`, + ]; + + return [...blocks, ...summary, ...formatUnreadable(report)].join("\n\n"); +} + +function formatUnreadable(report: LintReport): string[] { + if (report.unreadablePaths.length === 0) return []; + return [ + [ + `Not checked, could not be read (${pluralize(report.unreadablePaths.length, "file")}):`, + ...report.unreadablePaths.map((path) => ` ${path}`), + ].join("\n"), + ]; +} + +function formatFileMessages( + path: string, + messages: WorkflowLintMessage[], +): string { + if (messages.length === 0) return ""; + return [ + path, + ...messages.map( + (message) => + ` ${String(message.line)}:${String(message.column)} ${message.severity === 2 ? "error" : "warning"} ${message.message} ${message.ruleId ?? "syntax-error"}`, + ), + ].join("\n"); +} diff --git a/src/domains/lint/selectLintableFiles.test.ts b/src/domains/lint/selectLintableFiles.test.ts new file mode 100644 index 000000000..d7c48dccc --- /dev/null +++ b/src/domains/lint/selectLintableFiles.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test"; + +import { selectLintableFiles } from "./selectLintableFiles.js"; + +describe("selectLintableFiles", () => { + it("keeps .ts and .js files, including declaration files", () => { + expect( + selectLintableFiles( + [ + "/project/flows/login.flow.ts", + "/project/src/pages/LoginPage.ts", + "/project/scripts/seed.js", + "/project/types/globals.d.ts", + ], + "/project", + ), + ).toEqual([ + "/project/flows/login.flow.ts", + "/project/src/pages/LoginPage.ts", + "/project/scripts/seed.js", + "/project/types/globals.d.ts", + ]); + }); + + it("drops files the linter cannot parse as source", () => { + expect( + selectLintableFiles( + [ + "/project/data/fixture.json", + "/project/README.md", + "/project/flows/login.flow.tsx", + "/project/Makefile", + ], + "/project", + ), + ).toEqual([]); + }); + + it("drops files under a generated output directory", () => { + expect( + selectLintableFiles( + [ + "/project/flows/login.flow.ts", + "/project/dist/flows/login.flow.js", + "/project/build/bundle.js", + "/project/coverage/lcov-report/block-navigation.js", + "/project/.next/server/page.js", + ], + "/project", + ), + ).toEqual(["/project/flows/login.flow.ts"]); + }); + + it("keeps a file whose own name matches a generated directory", () => { + expect(selectLintableFiles(["/project/flows/dist.ts"], "/project")).toEqual( + ["/project/flows/dist.ts"], + ); + }); + + it("ignores generated directory names above the project root", () => { + expect( + selectLintableFiles( + ["/home/me/build/project/flows/a.flow.ts"], + "/home/me/build/project", + ), + ).toEqual(["/home/me/build/project/flows/a.flow.ts"]); + }); +}); diff --git a/src/domains/lint/selectLintableFiles.ts b/src/domains/lint/selectLintableFiles.ts new file mode 100644 index 000000000..c86fd8d22 --- /dev/null +++ b/src/domains/lint/selectLintableFiles.ts @@ -0,0 +1,37 @@ +import { extname, relative, sep } from "node:path"; + +export const lintablePattern = "**/*.{ts,js}"; + +const lintableExtensions = new Set([".js", ".ts"]); + +const generatedDirectoryNames = new Set([ + ".next", + ".nuxt", + ".output", + ".svelte-kit", + ".turbo", + ".vercel", + "build", + "coverage", + "dist", + "out", +]); + +export function selectLintableFiles( + filePaths: readonly string[], + cwd: string, +): readonly string[] { + return filePaths.filter( + (filePath) => + lintableExtensions.has(extname(filePath)) && + !isUnderGeneratedDirectory(filePath, cwd), + ); +} + +function isUnderGeneratedDirectory(filePath: string, cwd: string): boolean { + const withinProject = relative(cwd, filePath); + return withinProject + .split(sep) + .slice(0, -1) + .some((segment) => generatedDirectoryNames.has(segment)); +}