-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cli): dispatch the three engines concurrently and merge their findings #94
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
0ad1c1e
378a1d7
65b53be
5e74df9
1774645
5d8d080
1e03fdf
97c551f
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 |
|---|---|---|
|
|
@@ -2,13 +2,11 @@ import { resolve, join, isAbsolute, relative } from "node:path"; | |
| import { stat } from "node:fs/promises"; | ||
| import { defineCommand } from "citty"; | ||
|
|
||
| import { runAstGrepScan } from "../rules/scan"; | ||
| import type { CheckResult } from "../types/check"; | ||
| import { hasValeRules, runEngines } from "../rules/dispatch"; | ||
| import { formatText } from "../util/format"; | ||
| import { resolveSgConfigPath } from "../filesystem/sgconfig"; | ||
| import { ensureTasklessDirectory } from "../filesystem/directory"; | ||
| import { | ||
| dedupeFindings, | ||
| discoverAstGrepRuleSources, | ||
| planEngineDispatch, | ||
| } from "../rules/engines"; | ||
|
|
@@ -30,7 +28,6 @@ import { | |
| selectBlessedRuntimeRules, | ||
| signRuntimeChecks, | ||
| } from "../rules/runtime/run-set"; | ||
| import { executeRuntimeRules } from "../rules/runtime/harness"; | ||
|
|
||
| async function pathExists(absolutePath: string): Promise<boolean> { | ||
| try { | ||
|
|
@@ -285,7 +282,10 @@ export const checkCommand = defineCommand({ | |
| const telemetry = await getTelemetry(cwd); | ||
|
|
||
| // Warnings/notices are advisory human output; suppress them under --json so | ||
| // the machine output stays the { success, results, skipped? } shape. | ||
| // the machine output stays the | ||
| // { success, results, skipped?, failures?, notices? } shape. Engine | ||
| // failures and notices are carried in that envelope instead, since a | ||
| // machine consumer cannot read stderr prose. | ||
| const warn = (message: string) => { | ||
| if (!args.json) console.error(message); | ||
| }; | ||
|
|
@@ -316,7 +316,7 @@ export const checkCommand = defineCommand({ | |
| } | ||
|
|
||
| // Rules dispatch by the engine directory that contains them. This is also | ||
| // the migration trigger: no config is generated on the check path any | ||
| // the migration trigger: no config is generated on the check path any | ||
| // more, so without this call an upgraded CLI would keep reading a stale | ||
| // layout. | ||
| // | ||
|
|
@@ -330,9 +330,11 @@ export const checkCommand = defineCommand({ | |
| const dispatch = await planEngineDispatch(cwd); | ||
|
|
||
| // Static rules (trusted ast-grep YAML) always run; runtime rules | ||
| // (untrusted check.ts) are gated separately. An engine directory this CLI | ||
| // has no executor for (vale) contributes nothing, and a directory that is | ||
| // not a known engine is ignored rather than handed to someone's parser. | ||
| // (untrusted check.ts) are gated separately. Vale is discovered below, | ||
| // in the "anything to run?" gate — every known engine now has an | ||
| // executor, so none of them can be assumed to contribute nothing. A | ||
| // directory that is not a known engine is still ignored rather than | ||
| // handed to someone's parser. | ||
| const astGrepSources = await discoverAstGrepRuleSources(cwd); | ||
| // Both halves matter: `executor` alone is read from the static layout | ||
| // table and is therefore always `runtime-harness`, so gating on it only | ||
|
|
@@ -347,7 +349,18 @@ export const checkCommand = defineCommand({ | |
| ? await discoverRuntimeRules(cwd) | ||
| : []; | ||
|
|
||
| if (astGrepSources.length === 0 && runtimeRules.length === 0) { | ||
| // "No rules configured" has to mean *no engine* has any, not just these | ||
| // two: a project whose only rules live in `.taskless/vale/rules/` would | ||
| // otherwise return here and Vale would never be dispatched, which is a | ||
| // silent skip of the engine the user actually configured. Asked last and | ||
| // short-circuited, so the ordinary project with ast-grep or runtime rules | ||
| // pays nothing and `runEngines` still owns the decision to spawn Vale. | ||
| const noRuleFiles = | ||
| astGrepSources.length === 0 && | ||
| runtimeRules.length === 0 && | ||
| !(await hasValeRules(cwd)); | ||
|
|
||
| if (noRuleFiles) { | ||
| if (args.json) { | ||
| console.log( | ||
| JSON.stringify( | ||
|
|
@@ -363,22 +376,9 @@ export const checkCommand = defineCommand({ | |
| } | ||
|
|
||
| try { | ||
| const results: CheckResult[] = []; | ||
|
|
||
| // Static rules: always scan, no verification (inert data). Each | ||
| // ast-grep source is scanned on its own — `sg/rules/` and, for an | ||
| // unmigrated checkout, the legacy `.taskless/rules/` — and identical | ||
| // findings from both are collapsed so a rule present in both layouts | ||
| // is reported once. | ||
| const staticResults: CheckResult[] = []; | ||
| for (const source of astGrepSources) { | ||
| const configPath = await resolveSgConfigPath(cwd, source); | ||
| const scan = await runAstGrepScan(cwd, existingPaths, { configPath }); | ||
| staticResults.push(...scan.results); | ||
| } | ||
| results.push(...dedupeFindings(staticResults)); | ||
|
|
||
| // Runtime rules: run only what the server validated (or forced). | ||
| // Runtime rules are planned before dispatch, not during it: planning | ||
| // consults auth and reconcile state, which is a decision about *what* | ||
| // may run rather than part of running it. | ||
| const plan = await planRuntime(cwd, runtimeRules, { | ||
| anonymous: args.anonymous, | ||
| dangerouslyRunScripts: Boolean(args["dangerously-run-scripts"]), | ||
|
|
@@ -389,37 +389,56 @@ export const checkCommand = defineCommand({ | |
| `Notice: runtime rule ${skipped.rule} was not run — ${skipped.reason}.` | ||
| ); | ||
| } | ||
| if (plan.execute.length > 0) { | ||
| const runtimeResults = await executeRuntimeRules(cwd, plan.execute, { | ||
| paths: existingPaths, | ||
| timeoutMs: parseTimeoutMs(args.timeout), | ||
| }); | ||
| results.push(...runtimeResults); | ||
| } | ||
|
|
||
| // Every engine runs concurrently and merges into one result set. An | ||
| // engine that cannot run reports a notice and the others still return. | ||
| const astGrepConfigPaths = await Promise.all( | ||
| astGrepSources.map((source) => resolveSgConfigPath(cwd, source)) | ||
| ); | ||
| const dispatched = await runEngines({ | ||
| cwd, | ||
| paths: existingPaths, | ||
| astGrepConfigPaths, | ||
| runtimeRules: plan.execute, | ||
| runtimeTimeoutMs: parseTimeoutMs(args.timeout), | ||
| }); | ||
| const results = dispatched.results; | ||
|
|
||
| for (const notice of dispatched.notices) warn(`Notice: ${notice}`); | ||
| for (const failure of dispatched.failures) warn(`Error: ${failure}`); | ||
|
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.
So when an engine failure (not a finding) is why the check fails — e.g. Vale times out or its config crashes, while ast-grep/runtime produce nothing error-severity — Given |
||
|
|
||
| let errorCount = 0; | ||
| let warningCount = 0; | ||
| for (const result of results) { | ||
| if (result.severity === "error") errorCount++; | ||
| else if (result.severity === "warning") warningCount++; | ||
| } | ||
| const hasErrors = errorCount > 0; | ||
| scanCounts = { errorCount, warningCount, findings: results.length }; | ||
|
|
||
| // Computed by `runEngines`, not here: the exit code is a fact about a | ||
| // completed dispatch, and an engine failure has to fail the check even | ||
| // with no findings. | ||
| const { exitCode } = dispatched; | ||
|
|
||
| if (args.json) { | ||
| const output = checkOutputSchema.parse({ | ||
| success: !hasErrors, | ||
| success: exitCode === 0, | ||
| results, | ||
| ...(plan.skipped.length > 0 ? { skipped: plan.skipped } : {}), | ||
| ...(dispatched.failures.length > 0 | ||
| ? { failures: dispatched.failures } | ||
| : {}), | ||
| ...(dispatched.notices.length > 0 | ||
| ? { notices: dispatched.notices } | ||
| : {}), | ||
| }); | ||
| console.log(JSON.stringify(output)); | ||
| } else { | ||
| console.log(formatText(results)); | ||
| } | ||
|
|
||
| // Exit code: 1 if any errors, 0 otherwise | ||
| if (hasErrors) { | ||
| process.exitCode = 1; | ||
| if (exitCode !== 0) { | ||
| process.exitCode = exitCode; | ||
| } | ||
| } catch (error) { | ||
| const message = `Error: ${error instanceof Error ? error.message : String(error)}`; | ||
|
|
||
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.
Confirming this for the record since it lines up with the follow-up you already flagged in the PR thread: this
hasValeRules(cwd)call sits inside the outertry(opened at line 295) but before the innertry/catch(375/434) that builds theSCAN_FAILEDenvelope.hasValeRulesdeliberately rethrows anything that isn'tENOENT/ENOTDIR, so anEACCESon.taskless/vale/rules/here isn't caught by this function — it propagates past thefinallyto the top-level handler inindex.ts, which prints the raw error and ignores--jsonentirely. Not raising this as new (you already scoped it out), just confirming it's accurate in case the follow-up picks it up.