Repository navigation
fix(test): notice when an sg rule's globs reach no file #487
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@taskless/cli": patch | ||
| --- | ||
|
|
||
| `taskless test` now says when an ast-grep rule's `files:`/`ignores:` globs match no file in the project. A fixture snippet has no path, so the globs never apply to it, and a rule scoped to `lib/**` in a project that keeps its code in `src/` used to pass `test` while `check` never ran it. The rule still passes; the result carries a notice naming the globs, on the human output and in `notices` under `--json`. Rules scoped by `language:` alone are not checked, since a project with no code in that language is honestly clean. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| import { spawn } from "node:child_process"; | ||
| import { readFile } from "node:fs/promises"; | ||
| import process from "node:process"; | ||
| import { createInterface } from "node:readline"; | ||
|
|
||
| import { parse } from "yaml"; | ||
|
|
||
| import { ruleFilePath } from "./engines"; | ||
| import { buildPath, findSgBinary, sgWalkArgv } from "./scan"; | ||
|
|
||
| /** | ||
| * Whether an ast-grep rule's `files:`/`ignores:` globs reach any file in the | ||
| * project. | ||
| * | ||
| * `test` cannot answer this from the fixtures. An sg fixture is a `valid:` or | ||
| * `invalid:` snippet with no path, so the globs never apply to it, and a rule | ||
| * whose `files:` names `lib/**` in a project that keeps its code in `src/` | ||
| * passes every test and is then never run by `check` (#486). A typo in a glob | ||
| * and a clean codebase look exactly the same from either command. | ||
| * | ||
| * ## Why ast-grep answers, not a glob library | ||
| * | ||
| * The question is what ast-grep's walker would hand this rule, which depends on | ||
| * its glob dialect, the `ignore` crate's `.gitignore` handling, and the walk | ||
| * flags `check` passes. Re-deriving that with a second matcher is a second | ||
| * opinion that can disagree with the first. `--inspect entity` reports it | ||
| * directly, per file, on stderr. Measured against the vendored 0.45.3: | ||
| * | ||
| * ``` | ||
| * sg: entity|file|src/app.ts: language=TypeScript,appliedRuleCount=0 | ||
| * ``` | ||
| * | ||
| * `appliedRuleCount` is the number of loaded rules whose language, `files:` and | ||
| * `ignores:` all admit the file. With `-r` exactly one rule is loaded, so any | ||
| * line above zero means the rule is in scope somewhere. | ||
| * | ||
| * `-r` rather than the assembled config for the reason | ||
| * `sg-fixture-findings.ts` gives: one malformed sibling rule aborts parsing of | ||
| * the assembled config, and the scope of THIS rule would then read as zero. | ||
| * With no project config, ast-grep resolves the globs against the cwd, which is | ||
| * the project root `check` scans from. `sgWalkArgv([])` is the whole-project | ||
| * walk `check` performs, so the files considered here are the files `check` | ||
| * considers. | ||
| */ | ||
|
|
||
| /** One `--inspect entity` file line, as ast-grep 0.45.3 writes it. */ | ||
| const FILE_ENTITY = /^sg: entity\|file\|.*,appliedRuleCount=(\d+)$/; | ||
|
|
||
| /** | ||
| * Which scoping keys the rule declares, or none when it declares neither. | ||
| * | ||
| * A rule with neither key is scoped only by `language:`, and reaching no file | ||
| * then means the project has no code in that language, which `check` reporting | ||
| * nothing is an honest account of. The defect is a glob that selects nothing, | ||
| * so only a rule that has a glob is measured, and only that rule pays for the | ||
| * walk. | ||
| */ | ||
| async function scopingKeys(ruleFile: string): Promise<string[]> { | ||
| let rule: unknown; | ||
| try { | ||
| rule = parse(await readFile(ruleFile, "utf8")) as unknown; | ||
| } catch { | ||
| // An unreadable or malformed rule is `verify`'s to report. | ||
| return []; | ||
| } | ||
| if (typeof rule !== "object" || rule === null) return []; | ||
| return ["files", "ignores"].filter((key) => key in rule); | ||
| } | ||
|
|
||
| /** | ||
| * Whether ast-grep applies the rule to at least one file in the project. | ||
| * | ||
| * Three answers, because a walk that did not finish is neither: `unknown` is | ||
| * what lets the caller stay silent rather than report an empty scope it never | ||
| * measured. | ||
| * | ||
| * Stops at the first file in scope: the answer is a yes/no, and the rest of the | ||
| * walk, including running the rule over every file, would only confirm it. | ||
| */ | ||
| function reachesAnyFile( | ||
|
Comment on lines
+70
to
+80
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [New] Performance: one full-project walk per scoped rule, run sequentially for every rule in the test target.
Two things compound this:
Not necessarily a blocker — the PR already made a deliberate "costs little in the common case" tradeoff — but worth confirming this was measured against a repo of realistic size/rule-count, since the expensive path is exactly the one the feature is meant to exercise.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Measured rather than assumed. Declining. On this repo (298 TypeScript files in the walk), one empty-scope walk takes ~65 ms, about the same as running a real rule over every file. The full walk only happens when a glob matches nothing, and that run also prints a notice for every such rule. Thirty rules scoped ahead of their code means thirty notices to act on. The extra latency on top of that is not where the cost lands. The walk can't be batched into one scan either. |
||
| cwd: string, | ||
| ruleFile: string | ||
| ): Promise<"reached" | "empty" | "unknown"> { | ||
| const sgBinary = findSgBinary(); | ||
| return new Promise((resolve) => { | ||
| const child = spawn( | ||
| sgBinary, | ||
| [ | ||
| "scan", | ||
| "-r", | ||
| ruleFile, | ||
| "--json=stream", | ||
| "--inspect", | ||
| "entity", | ||
| ...sgWalkArgv([]), | ||
| ], | ||
| { | ||
| cwd, | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| env: { ...process.env, PATH: buildPath() }, | ||
| } | ||
| ); | ||
|
|
||
| let reached = false; | ||
| // Findings arrive on stdout and are not this question. Drained so a full | ||
| // pipe cannot block the child before it reaches the line we are waiting | ||
| // for. | ||
| child.stdout.resume(); | ||
| const rl = createInterface({ input: child.stderr }); | ||
| rl.on("line", (line) => { | ||
| const match = FILE_ENTITY.exec(line); | ||
| if (match !== null && Number(match[1]) > 0 && !reached) { | ||
| reached = true; | ||
| child.kill(); | ||
| } | ||
| }); | ||
|
|
||
| child.on("error", () => { | ||
| resolve("unknown"); | ||
| }); | ||
| child.on("close", (code) => { | ||
| if (reached) { | ||
| resolve("reached"); | ||
| return; | ||
| } | ||
| // 0 is a clean scan and 1 is error-severity findings; anything else is a | ||
| // walk that did not finish, and "no file seen" from it is not evidence | ||
| // of an empty scope. | ||
| resolve(code === 0 || code === 1 ? "empty" : "unknown"); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * A notice when the rule's globs select no file in the project, or `undefined`. | ||
| * | ||
| * A notice rather than a failure. A glob can be narrow on purpose, written | ||
| * ahead of the code it targets, and nothing about the rule itself is invalid. | ||
| * What must not happen is the rule reading as tested and working while `check` | ||
| * never runs it, and a notice is printed beside the tick, which is where that | ||
| * reading forms. | ||
| * | ||
| * Silent when ast-grep could not finish the walk: saying the scope is empty on | ||
| * the strength of a failed scan would be the same false clean this exists to | ||
| * remove, in the other direction. | ||
| */ | ||
| export async function describeEmptySgScope( | ||
| cwd: string, | ||
| ruleId: string | ||
| ): Promise<string | undefined> { | ||
| const ruleFile = ruleFilePath(cwd, "sg", ruleId); | ||
| const keys = await scopingKeys(ruleFile); | ||
| if (keys.length === 0) return undefined; | ||
| if ((await reachesAnyFile(cwd, ruleFile)) !== "empty") return undefined; | ||
| const globs = keys.map((key) => `${key}:`).join("/"); | ||
| return ( | ||
| `${ruleId}'s ${globs} globs match no file in this project, so \`check\` ` + | ||
| `will never run it. The globs are matched against paths relative to the ` + | ||
| `project root; the fixtures passing does not exercise them.` | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[New] Minor: these two awaits run sequentially where they could overlap.
await describeEmptySgScope(cwd, ruleId)(a full-project walk) completes beforecollectSgFixtureFindings(cwd, ruleId)(onescan --stdinper fixture snippet) even starts, since both are plainawaits evaluated in object-literal order. Running them viaPromise.allwould let the per-rule cost of this PR's new walk overlap with worktestwas already doing, rather than adding on top of it. Minor given the scan already dominates; flagging alongside the broader performance note onsg-scope.ts.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declining. On a scoped rule whose glob reaches a file, which is every scoped rule in this repo, the walk exits at the first hit and the whole feature adds ~50 ms across 7 rules. Overlapping it with the fixture scans would save part of that. That's not enough to justify reordering how the result object is built.