Skip to content
Open
6 changes: 3 additions & 3 deletions openspec/changes/add-vale-rule-engine/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@

## 2. Check orchestration

- [ ] 2.1 Dispatch to distinct executors by engine directory — ast-grep (`sg/`) → scanner, Vale (`vale/`) → runner, runtime (`runtime/rules/`) → harness
- [ ] 2.2 Run engines concurrently, merge `CheckResult`s into one set, derive the exit code from merged severities, and keep an unavailable engine from aborting the others
- [ ] 2.3 Tests: a mixed `sg`+`vale`+`runtime` corpus runs all executors and merges; with the `vale` binary absent, ast-grep results still return
- [x] 2.1 Dispatch to distinct executors by engine directory — ast-grep (`sg/`) → scanner, Vale (`vale/`) → runner, runtime (`runtime/rules/`) → harness
- [x] 2.2 Run engines concurrently, merge `CheckResult`s into one set, derive the exit code from merged severities, and keep an unavailable engine from aborting the others
- [x] 2.3 Tests: a mixed `sg`+`vale`+`runtime` corpus runs all executors and merges; with the `vale` binary absent, ast-grep results still return

## 3. Engine-selection knowledge topic

Expand Down
95 changes: 57 additions & 38 deletions packages/cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -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.
//
Expand All @@ -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
Expand All @@ -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));

Copy link
Copy Markdown

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 outer try (opened at line 295) but before the inner try/catch (375/434) that builds the SCAN_FAILED envelope. hasValeRules deliberately rethrows anything that isn't ENOENT/ENOTDIR, so an EACCES on .taskless/vale/rules/ here isn't caught by this function — it propagates past the finally to the top-level handler in index.ts, which prints the raw error and ignores --json entirely. Not raising this as new (you already scoped it out), just confirming it's accurate in case the follow-up picks it up.


if (noRuleFiles) {
if (args.json) {
console.log(
JSON.stringify(
Expand All @@ -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"]),
Expand All @@ -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}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dispatched.failures has no way to reach --json output. warn() is a no-op whenever args.json is true (line 286-288), and checkOutputSchema (packages/cli/src/schemas/check.ts:34-41) only defines success/results/skipped — no field for failures or notices.

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 — --json output is just {"success":false,"results":[]}. A script consuming that JSON has no way to distinguish "a rule fired" from "an engine crashed," even though dispatched.failures has the message right there.

Given skipped already exists on outputSchema as an advisory array for one engine's (runtime's) edge case, a failures/notices field following the same pattern would give --json consumers parity with what the text path already prints.


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)}`;
Expand Down
Loading
Loading