From 37c79f5b624b90dc2e86ed09855ce113ae9adafe Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 21:13:16 -0700 Subject: [PATCH 1/2] fix(cli): let ast-grep rules see inside hidden directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ast-grep's walker skips dot-directories by default, so no `sg` rule could match anything under `.github/`, `.circleci/`, `.vscode/` or `.husky/` — `check` reported nothing and exited 0 on files it flags correctly one directory over. Pass `--no-ignore hidden` from `runAstGrepScan` and the runtime narrow. Measured against the pinned ast-grep 0.41.0, `hidden` is the only value that reaches those directories, and `vcs` is deliberately not passed so `.gitignore` is still respected. `.taskless/` is hidden too, so the wider walk reached CLI-managed config that was never meant to be scanned: a rule definition is structured YAML full of `id:`/`language:`/`severity:` keys, and an ordinary user-written Yaml rule fires on the CLI's own rule files. Exclude it via `--globs`, only on a whole-project walk, matching the rule the Vale runner already follows — an explicit path stays a request. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- .changeset/scan-hidden-directories.md | 28 +++++++ packages/cli/src/rules/runtime/narrow.ts | 8 +- packages/cli/src/rules/scan.ts | 58 ++++++++++++++ packages/cli/test/check.test.ts | 97 ++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 .changeset/scan-hidden-directories.md diff --git a/.changeset/scan-hidden-directories.md b/.changeset/scan-hidden-directories.md new file mode 100644 index 00000000..0ae3f420 --- /dev/null +++ b/.changeset/scan-hidden-directories.md @@ -0,0 +1,28 @@ +--- +"@taskless/cli": patch +--- + +Let ast-grep rules see inside hidden directories such as `.github/`. + +ast-grep's file walker skips dot-directories unless told otherwise, and +`runAstGrepScan` never told it otherwise. No `sg` rule could match anything +under `.github/`, `.circleci/`, `.vscode/` or `.husky/`, so `check` reported +nothing and exited 0 on a workflow file it flags correctly the moment the same +bytes live in a non-hidden directory. Vale has no such blind spot, which left +the two static engines disagreeing about whether `.github/` existed at all. +Both `check` and the runtime engine's ast-grep narrow now pass +`--no-ignore hidden`. + +Only `hidden` is passed, and deliberately not `vcs`: `.gitignore` is still +respected, so the wider walk does not start reporting findings in `dist/` or +anywhere else a project has already said it does not want scanned. Rule +discovery is untouched — `ruleDirs` walks by its own rules, so a rule's +`.tests/` directory is still skipped rather than parsed as a rule. + +`.taskless/` is excluded from the wider walk, because it is hidden too and +reaching it is not a fix. A rule definition is structured YAML full of `id:`, +`language:`, `severity:` and `rule:` keys, so an ordinary user-written Yaml rule +fires on the CLI's own rule files — a finding in a directory the user did not +author and cannot edit without disabling their rule. The exclusion applies only +when `check` walks the whole project on its own; an explicit path stays a +request, which is the rule the Vale runner already follows. diff --git a/packages/cli/src/rules/runtime/narrow.ts b/packages/cli/src/rules/runtime/narrow.ts index 0171cc3b..eb01bb08 100644 --- a/packages/cli/src/rules/runtime/narrow.ts +++ b/packages/cli/src/rules/runtime/narrow.ts @@ -7,7 +7,7 @@ import { StringDecoder } from "node:string_decoder"; import { stringify } from "yaml"; -import { buildPath, findSgBinary } from "../scan"; +import { buildPath, findSgBinary, sgWalkArgv } from "../scan"; import type { Match } from "../../types/runtime-rule"; import type { LoadedCaptureRule, RuntimeRule } from "./discover"; @@ -38,6 +38,12 @@ function runSg( "scan", "--config", configPath, + // The narrow is a `sg scan` over the user's project, so it walks it on + // the same terms `runAstGrepScan` does — reaching `.github/`, and not + // reaching `.taskless/`. A capture rule blind to the first would report + // nothing for a workflow file; one that reached the second would anchor a + // runtime rule on the CLI's own config. + ...sgWalkArgv(paths), ...extraArguments, ...(paths.length > 0 ? ["--", ...paths] : []), ]; diff --git a/packages/cli/src/rules/scan.ts b/packages/cli/src/rules/scan.ts index c3baaddd..ada3a391 100644 --- a/packages/cli/src/rules/scan.ts +++ b/packages/cli/src/rules/scan.ts @@ -126,6 +126,63 @@ export interface ScanOptions { configPath?: string; } +/** + * The project directory this CLI owns. Declared locally rather than imported + * from the Vale runner, which keeps its own copy for the same reason. + */ +const TASKLESS_DIRECTORY = ".taskless"; + +/** + * How every `sg scan` we spawn is told to walk the project. + * + * One function rather than a constant per call site, because both scan call + * sites — {@link runAstGrepScan} and the runtime narrow — need exactly these + * flags on exactly these terms, and a third would too. ast-grep's + * `sgconfig.yml` has no equivalent knob, so this cannot live in the assembled + * config; the argv is the only place it can be expressed. + * + * **`--no-ignore hidden`**, always. It lets the walker descend into + * dot-directories, which it refuses to do by default. Without it no `sg` rule + * could match anything under `.github/`, `.circleci/`, `.vscode/` or + * `.husky/` — a silent false negative, since `check` reported nothing and + * exited 0. Vale has no such blind spot, so the two static engines disagreed + * about whether `.github/` existed at all. Measured against the pinned ast-grep + * 0.41.0, `hidden` is the only value that reaches those directories: `dot`, + * `exclude`, `global` and `parent` all left `.github/` unscanned. Deliberately + * **not** passed is `vcs`, which stops `.gitignore` being respected and was + * measured to pull `dist/` into the scan — a rule has no business reporting + * findings in build output or vendored dependencies. + * + * **A `--globs` exclusion of `.taskless/`**, when we are the ones who chose to + * walk the whole project. That directory is hidden, so it was never scanned + * before and reaching it is not a fix: it is CLI-managed config the user did + * not author. Every rule definition in it is structured YAML carrying `id:`, + * `language:`, `severity:`, `message:` and `rule:` keys, so any reasonable + * user-written Yaml rule fires on the CLI's own rule files — an unfixable false + * positive in a directory the user cannot edit without disabling their rule. + * The `**` prefix on the glob is load-bearing: a root-anchored `.taskless/**` + * was measured to miss a `.taskless/` nested inside a monorepo package, which + * is the same CLI-managed config one level down. + * + * The exclusion is applied **only** for a whole-project scan, matching what the + * Vale runner already does and for its reason: an explicit path is a request, + * and silently declining to check a file someone named would be worse than + * checking one they did not. + * + * Measured interactions worth keeping in mind if this is ever changed: + * `--globs` survives `--no-ignore hidden` rather than being overridden by it, + * and neither flag touches rule discovery — `ruleDirs` reads the rules out of + * `.taskless/` by its own walk, so excluding that path from the *scan* does not + * stop the rules from loading, and a rule's `.tests/` directory is still + * skipped rather than parsed as a rule (see `RULE_TESTS_DIRECTORY` in + * `engines.ts`). + */ +export function sgWalkArgv(paths: string[]): string[] { + const exclude = + paths.length === 0 ? ["--globs", `!**/${TASKLESS_DIRECTORY}/**`] : []; + return ["--no-ignore", "hidden", ...exclude]; +} + /** Run ast-grep scan and return parsed results */ export async function runAstGrepScan( cwd: string, @@ -141,6 +198,7 @@ export async function runAstGrepScan( "--config", options.configPath ?? ASSEMBLED_SG_CONFIG, "--json=stream", + ...sgWalkArgv(paths), ...(paths.length > 0 ? ["--", ...paths] : []), ]; const child = spawn(sgBinary, argv, { diff --git a/packages/cli/test/check.test.ts b/packages/cli/test/check.test.ts index 827f004c..909dc7aa 100644 --- a/packages/cli/test/check.test.ts +++ b/packages/cli/test/check.test.ts @@ -195,6 +195,103 @@ describe("check", () => { expect(stdout).toContain("no-eval"); }); + it("scans hidden directories without abandoning .gitignore", async () => { + // ast-grep's walker skips dot-directories by default, so every `sg` rule + // was blind to `.github/`, `.circleci/`, `.vscode/` and friends — a silent + // false negative that let `check` exit 0 on a file it flags correctly one + // directory over. The fix passes `--no-ignore hidden`, and this pins both + // halves of it: the hidden file is reported, and the gitignored one is + // still skipped (which `--no-ignore vcs` would have broken). + await cp(fixturesDirectory, temporaryDirectory, { recursive: true }); + await mkdir(join(temporaryDirectory, ".github", "workflows"), { + recursive: true, + }); + await writeFile( + join(temporaryDirectory, ".github", "workflows", "hook.js"), + 'eval("in-dot-github");\n' + ); + await mkdir(join(temporaryDirectory, "build"), { recursive: true }); + await writeFile( + join(temporaryDirectory, "build", "bundle.js"), + 'eval("in-ignored-output");\n' + ); + await writeFile(join(temporaryDirectory, ".gitignore"), "build/\n"); + // ast-grep reads `.gitignore` only inside a repository, so the negative + // half of this test needs one to be meaningful. + await execFileAsync("git", ["init", "-q"], { cwd: temporaryDirectory }); + + const { stdout, exitCode } = await runCli([ + "check", + "-d", + temporaryDirectory, + "--json", + ]); + + expect(exitCode).toBe(1); + const parsed = JSON.parse(stdout.trim()) as { + results: Array<{ file: string }>; + }; + const files = parsed.results.map((result) => result.file); + expect(files).toContain(join(".github", "workflows", "hook.js")); + expect(files).not.toContain(join("build", "bundle.js")); + }); + + it("does not flag the CLI's own rule files under .taskless/", async () => { + // `.taskless/` is hidden, so scanning it was never intended — it only + // became reachable when `--no-ignore hidden` was added. Every rule + // definition is structured YAML carrying `id:`, `language:`, `severity:`, + // `message:` and `rule:`, so an ordinary user-written Yaml rule fires on + // the CLI's own config: a false positive in a directory the user cannot + // edit without disabling their rule. Both halves are asserted, because the + // exclusion must not undo the hidden-directory fix it rides along with. + const ruleDirectory = join( + temporaryDirectory, + ".taskless", + "rules", + "sg", + "no-severity-key" + ); + await mkdir(ruleDirectory, { recursive: true }); + await writeFile( + join(ruleDirectory, "no-severity-key.yml"), + [ + "id: no-severity-key", + "language: Yaml", + "severity: error", + "rule:", + " pattern:", + ' context: "severity: error"', + " selector: block_mapping_pair", + "message: found a severity key", + ].join("\n") + ); + await mkdir(join(temporaryDirectory, ".github", "workflows"), { + recursive: true, + }); + await writeFile( + join(temporaryDirectory, ".github", "workflows", "ci.yml"), + "name: ci\nseverity: error\n" + ); + + const { stdout, exitCode } = await runCli([ + "check", + "-d", + temporaryDirectory, + "--json", + ]); + + expect(exitCode).toBe(1); + const parsed = JSON.parse(stdout.trim()) as { + results: Array<{ file: string }>; + }; + const files = parsed.results.map((result) => result.file); + // The rule still reaches the hidden directory it is meant to. + expect(files).toContain(join(".github", "workflows", "ci.yml")); + // ...but never the rule file that defines it, which says `severity: error` + // for reasons that have nothing to do with the project being checked. + expect(files.some((file) => file.includes(".taskless"))).toBe(false); + }); + describe("positional path arguments", () => { it("scans only the specified file when a path is passed", async () => { await cp(fixturesDirectory, temporaryDirectory, { recursive: true }); From 85c0a16bdbcfee9444577c595f557e69c6e0d628 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 23:00:17 -0700 Subject: [PATCH 2/2] fix(cli): decide "whole project" the same way in both static engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sgWalkArgv` gated its `.taskless/` exclusion on `paths.length === 0`, but `filterExistingPaths` normalizes a positional path resolving to cwd into the literal string ".", so `taskless check .` arrived with `paths = ["."]`. A length test reads that as a user-named path and skips the exclusion, which put the self-flagging bug back for the most ordinary way of asking for a whole-project check: `check` was clean while `check .` reported findings inside `.taskless/`. Vale was already wrong in the same way at `run.ts`, independently of the hidden-directory change and reachable by the same invocation — and since Vale reads hidden directories by default, it had been reporting prose findings in `.taskless/` on any `check .`. The defect is two engines inferring the same thing from the same wrong signal, so the predicate is now shared in `walk-scope.ts` rather than fixed in one copy. Also excludes `.git/` from the wider walk. Measured against 0.41.0: ast-grep has no exclusion of its own for it and `.gitignore` does not list it, so the default hidden-directory skip was the only thing holding it back. A whole-project scan descended into `.git/objects` and `.git/logs` on every run, and `.git/hooks/*` are real source files that match language rules never meant to lint VCS internals. Repeated `--globs` flags were measured to both apply, unlike Vale's `--glob` where the last silently wins, so the exclusions are separate flags rather than one brace pattern. An explicit path is still a request: `check .taskless` reports it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- .changeset/scan-hidden-directories.md | 15 ++++ packages/cli/src/rules/scan.ts | 41 +++++++-- packages/cli/src/rules/vale/run.ts | 10 ++- packages/cli/src/rules/walk-scope.ts | 35 ++++++++ packages/cli/test/check.test.ts | 124 ++++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/rules/walk-scope.ts diff --git a/.changeset/scan-hidden-directories.md b/.changeset/scan-hidden-directories.md index 0ae3f420..661d9b60 100644 --- a/.changeset/scan-hidden-directories.md +++ b/.changeset/scan-hidden-directories.md @@ -26,3 +26,18 @@ fires on the CLI's own rule files — a finding in a directory the user did not author and cannot edit without disabling their rule. The exclusion applies only when `check` walks the whole project on its own; an explicit path stays a request, which is the rule the Vale runner already follows. + +`.git/` is excluded on the same terms. ast-grep has no exclusion of its own for +it and `.gitignore` does not list it, so the default hidden-directory skip was +the only thing holding it back: without this, a whole-project `check` descended +into `.git/objects` and `.git/logs` on every run, and `.git/hooks/*` scripts +matched language rules never meant to lint VCS internals. + +Both engines now decide "whole project" the same way, and it is no longer +`paths.length === 0`. An explicit `.` is normalized to the literal path `"."` +before it reaches either runner, so a length test read the most ordinary way of +asking for a whole-project check as a user-named path and skipped the exclusions +— `check` was clean while `check .` reported findings inside `.taskless/`. Vale +was already wrong in the same way and for the same reason, independently of the +hidden-directory change, so the predicate is now shared rather than written +twice. diff --git a/packages/cli/src/rules/scan.ts b/packages/cli/src/rules/scan.ts index ada3a391..70f4cd8d 100644 --- a/packages/cli/src/rules/scan.ts +++ b/packages/cli/src/rules/scan.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import type { AstGrepMatch } from "../types/check"; import { toCheckResult, type CheckResult } from "../types/check"; import { ASSEMBLED_SG_CONFIG } from "./engines"; +import { isWholeProjectWalk } from "./walk-scope"; import { isPlatformBinary, pathCommandName, @@ -131,6 +132,16 @@ export interface ScanOptions { * from the Vale runner, which keeps its own copy for the same reason. */ const TASKLESS_DIRECTORY = ".taskless"; +const GIT_DIRECTORY = ".git"; + +/** + * Directories kept out of a whole-project walk, each with its own reason. + * + * ast-grep honors repeated `--globs` flags — measured against 0.41.0, two + * exclusions both apply, unlike Vale's `--glob`, where the last one silently + * wins. So this is a list rather than one combined brace pattern. + */ +const EXCLUDED_DIRECTORIES = [TASKLESS_DIRECTORY, GIT_DIRECTORY] as const; /** * How every `sg scan` we spawn is told to walk the project. @@ -164,10 +175,24 @@ const TASKLESS_DIRECTORY = ".taskless"; * was measured to miss a `.taskless/` nested inside a monorepo package, which * is the same CLI-managed config one level down. * - * The exclusion is applied **only** for a whole-project scan, matching what the - * Vale runner already does and for its reason: an explicit path is a request, - * and silently declining to check a file someone named would be worse than - * checking one they did not. + * **A `--globs` exclusion of `.git/`**, on the same terms. Measured against + * 0.41.0: ast-grep has no exclusion of its own for it, so `--no-ignore hidden` + * makes `.git/` reachable along with every other dot-directory, and a + * whole-project scan descended into `.git/objects`, `.git/logs` and + * `.git/hooks`. That is wasted work proportional to repository history on every + * run, and `.git/hooks/*` are real source files that match language rules never + * meant to lint VCS internals. `.gitignore` does not cover this — `.git/` is not + * in it — so the default hidden-directory skip was the only thing holding it + * back. Repeated `--globs` flags were measured to both apply, unlike Vale's + * `--glob` where the last one silently wins, so the two exclusions are separate + * flags rather than one brace pattern. + * + * Both exclusions are applied **only** for a whole-project walk, matching what + * the Vale runner does and for its reason: an explicit path is a request, and + * silently declining to check a file someone named would be worse than checking + * one they did not. "Whole project" is {@link isWholeProjectWalk} rather than + * `paths.length === 0` — see that function for why the difference is + * load-bearing rather than cosmetic. * * Measured interactions worth keeping in mind if this is ever changed: * `--globs` survives `--no-ignore hidden` rather than being overridden by it, @@ -178,8 +203,12 @@ const TASKLESS_DIRECTORY = ".taskless"; * `engines.ts`). */ export function sgWalkArgv(paths: string[]): string[] { - const exclude = - paths.length === 0 ? ["--globs", `!**/${TASKLESS_DIRECTORY}/**`] : []; + const exclude = isWholeProjectWalk(paths) + ? EXCLUDED_DIRECTORIES.flatMap((directory) => [ + "--globs", + `!**/${directory}/**`, + ]) + : []; return ["--no-ignore", "hidden", ...exclude]; } diff --git a/packages/cli/src/rules/vale/run.ts b/packages/cli/src/rules/vale/run.ts index 0b7fd9a2..92090428 100644 --- a/packages/cli/src/rules/vale/run.ts +++ b/packages/cli/src/rules/vale/run.ts @@ -6,6 +6,7 @@ import type { CheckResult } from "../../types/check"; import { ASSEMBLED_VALE_CONFIG } from "../engines"; import { buildPath } from "../scan"; +import { isWholeProjectWalk } from "../walk-scope"; import { findValeBinary, valeUnavailableMessage } from "./binary"; import { asValeConfigError, toValeCheckResults, type ValeOutput } from "./map"; @@ -128,7 +129,14 @@ export async function runVale( // reason this is easy to miss: it takes its targets from the config and is // content with none, so the two engines disagree about what "no paths" means. // `cwd` is the project root, so `.` is the whole project. - const wholeProject = paths.length === 0; + // + // `isWholeProjectWalk` rather than `paths.length === 0`: `check .` arrives + // here with `paths = ["."]`, which a length test reads as a user-named path + // and so skips the `.taskless/` exclusion below. Vale reads hidden + // directories by default, so that route reported prose findings inside + // `.taskless/` on any `check .`, independently of the ast-grep fix in this + // change. Same defect, same signal, one line apart. + const wholeProject = isWholeProjectWalk(paths); const targets = wholeProject ? ["."] : paths; // Walking the whole project reaches `.taskless/` too, and Vale has no reason diff --git a/packages/cli/src/rules/walk-scope.ts b/packages/cli/src/rules/walk-scope.ts new file mode 100644 index 00000000..a73915ad --- /dev/null +++ b/packages/cli/src/rules/walk-scope.ts @@ -0,0 +1,35 @@ +/** + * Whether a set of positional paths means "walk the whole project". + * + * Both static engines apply their CLI-managed-directory exclusions only on a + * whole-project walk, because an explicit path is a request: silently declining + * to check a file someone named would be worse than checking one they did not. + * That rule needs a correct answer to "did we choose this, or did the user?", + * and both engines were getting it from `paths.length === 0`. + * + * **That test is wrong for `.`, and the mistake is silent.** `filterExistingPaths` + * (`commands/check.ts`) normalizes a positional path resolving to cwd into the + * literal string `"."` rather than dropping back to an empty array, so + * `taskless check .` — a near-default invocation — arrives with `paths = ["."]`. + * Under a length test that reads as a user request and skips the exclusions, + * which is how `check .` came to report findings inside `.taskless/` while a + * bare `check` did not. + * + * A single explicit `.` is a request for the project, not for the CLI's own + * config inside it, so it is a whole-project walk. A path *under* `.taskless/` + * is still honored: that names the config directly. + * + * Shared rather than duplicated because the two engines diverging here is + * exactly the class of bug this fixes — one of them was already wrong in the + * same way. + */ +export function isWholeProjectWalk(paths: string[]): boolean { + if (paths.length === 0) return true; + return paths.length === 1 && CWD_ALIASES.has(paths[0] ?? ""); +} + +/** + * Spellings of "here" that `filterExistingPaths` can emit or a shell can pass. + * `"."` is what the normalizer produces; the others reach us straight from argv. + */ +const CWD_ALIASES = new Set([".", "./", ".\\"]); diff --git a/packages/cli/test/check.test.ts b/packages/cli/test/check.test.ts index 909dc7aa..8b7d8e6f 100644 --- a/packages/cli/test/check.test.ts +++ b/packages/cli/test/check.test.ts @@ -292,6 +292,130 @@ describe("check", () => { expect(files.some((file) => file.includes(".taskless"))).toBe(false); }); + it.each([["."], ["./"]])( + "excludes .taskless/ when the whole project is named as %j", + async (positional) => { + // `filterExistingPaths` normalizes a positional resolving to cwd into the + // literal string ".", so `check .` arrives with `paths = ["."]`. Gating + // the exclusion on `paths.length === 0` read that as a user-named path + // and skipped it, which put the self-flagging bug back for a near-default + // invocation. Naming the project is not naming the config inside it. + const ruleDirectory = join( + temporaryDirectory, + ".taskless", + "rules", + "sg", + "no-severity-key" + ); + await mkdir(ruleDirectory, { recursive: true }); + await writeFile( + join(ruleDirectory, "no-severity-key.yml"), + [ + "id: no-severity-key", + "language: Yaml", + "severity: error", + "rule:", + " pattern:", + ' context: "severity: error"', + " selector: block_mapping_pair", + "message: found a severity key", + ].join("\n") + ); + await mkdir(join(temporaryDirectory, ".github", "workflows"), { + recursive: true, + }); + await writeFile( + join(temporaryDirectory, ".github", "workflows", "ci.yml"), + "name: ci\nseverity: error\n" + ); + + const { stdout, exitCode } = await runCli([ + "check", + "-d", + temporaryDirectory, + "--json", + positional, + ]); + + expect(exitCode).toBe(1); + const parsed = JSON.parse(stdout.trim()) as { + results: Array<{ file: string }>; + }; + const files = parsed.results.map((result) => result.file); + expect(files).toContain(join(".github", "workflows", "ci.yml")); + expect(files.some((file) => file.includes(".taskless"))).toBe(false); + } + ); + + it("does not descend into .git/ on a whole-project scan", async () => { + // ast-grep has no exclusion of its own for `.git/`, and `.gitignore` does + // not list it, so the default hidden-directory skip was the only thing + // keeping it out of the walk — exactly what `--no-ignore hidden` removes. + // `.git/hooks/*` are real source files, so they match language rules that + // were never meant to lint VCS internals. + await cp(fixturesDirectory, temporaryDirectory, { recursive: true }); + await mkdir(join(temporaryDirectory, ".git", "hooks"), { recursive: true }); + await writeFile( + join(temporaryDirectory, ".git", "hooks", "pre-commit.js"), + 'eval("in-git-hooks");\n' + ); + + const { stdout } = await runCli([ + "check", + "-d", + temporaryDirectory, + "--json", + ]); + + const parsed = JSON.parse(stdout.trim()) as { + results: Array<{ file: string }>; + }; + const files = parsed.results.map((result) => result.file); + expect(files.some((file) => file.includes(".git"))).toBe(false); + }); + + it("still checks .taskless/ when it is named explicitly", async () => { + // The other half of the rule the exclusion follows: naming a path is a + // request, and declining to check a file someone asked about would be worse + // than checking one they did not. This is what keeps the exclusion from + // becoming an unconditional blind spot. + const ruleDirectory = join( + temporaryDirectory, + ".taskless", + "rules", + "sg", + "no-severity-key" + ); + await mkdir(ruleDirectory, { recursive: true }); + await writeFile( + join(ruleDirectory, "no-severity-key.yml"), + [ + "id: no-severity-key", + "language: Yaml", + "severity: error", + "rule:", + " pattern:", + ' context: "severity: error"', + " selector: block_mapping_pair", + "message: found a severity key", + ].join("\n") + ); + + const { stdout } = await runCli([ + "check", + "-d", + temporaryDirectory, + "--json", + ".taskless", + ]); + + const parsed = JSON.parse(stdout.trim()) as { + results: Array<{ file: string }>; + }; + const files = parsed.results.map((result) => result.file); + expect(files.some((file) => file.includes(".taskless"))).toBe(true); + }); + describe("positional path arguments", () => { it("scans only the specified file when a path is passed", async () => { await cp(fixturesDirectory, temporaryDirectory, { recursive: true });