Skip to content

Commit ee5ef75

Browse files
theCodeDriftclaude
andcommitted
feat(cli): dispatch rules by engine directory
Add `rules/engines.ts` as the single place that answers "which engine owns this rule": the top-level `.taskless/<engine>/` directory does, and no rule file is ever parsed to decide. `sg` maps to ast-grep and `runtime` to the harness; `vale` is recognized but has no executor yet, and a directory that is not a known engine is ignored rather than handed to someone else's parser. `check` now calls `ensureTasklessDirectory` itself, keeping the migration trigger that `generateSgConfig` used to provide, then scans each ast-grep source it finds. The legacy `.taskless/rules/` stays readable alongside `sg/rules/`, so a producer that keeps naming the old path still runs; findings from the two are merged and identical matches collapsed. Service-delivered rules are filed by the engine their payload identifies — `sg` when it identifies none, permanently, since the API carries no engine discriminator. An engine this CLI does not know throws before touching the filesystem instead of silently defaulting to ast-grep. The remaining hardcoded `.taskless/rules` literals (verify, files, check, rules, the detect probe) now resolve through the same module, each tolerating both layouts. Reconcile needed no change: reported paths derive from the discovery root and the server joins on content signature, both verified by test. Test failures drop from 20 to 9, all in runtime-check.test.ts (group 3). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1e17924 commit ee5ef75

10 files changed

Lines changed: 728 additions & 127 deletions

File tree

‎openspec/changes/partition-rules-by-engine/tasks.md‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,32 @@
77
- [x] 1.5 Add version-mismatch gating: `runMigrations` throws when `taskless.json.version > maxVersion` with an "upgrade the CLI" message, unless a global `--allow-version-mismatches` flag is set
88
- [x] 1.6 Tests: `0004` moves each tree correctly, `.gitkeep` present, runtime contents byte-identical; gating throws and the flag overrides
99

10+
- [x] 1.7 Anchor the `sgconfig.yml` entry in `.taskless/.gitignore` (`/sgconfig.yml`), so the unanchored pattern `0001` wrote no longer also ignores the committed `.taskless/sg/sgconfig.yml`; `0004` rewrites it in existing checkouts
11+
1012
> After group 1 alone, `check`/`verify`/runtime discovery still read the pre-move paths, so 20 tests in
11-
> `check.test.ts`, `verify.test.ts`, and `runtime-check.test.ts` fail until groups 2–4 land. `.taskless/.gitignore`
12-
> also still ignores `sgconfig.yml`, which now matches the committed `sg/sgconfig.yml` (task 5.3).
13+
> `check.test.ts`, `verify.test.ts`, and `runtime-check.test.ts` fail until groups 2–4 land. Group 2 brings
14+
> that to 9, all in `runtime-check.test.ts`, which group 3 fixes.
1315
1416
## 2. Engine dispatch (directory model)
1517

16-
- [ ] 2.1 Implement directory-based engine discovery: enumerate `.taskless/<engine>/` and route rules by directory, no per-file parsing. `sg` and `runtime` get executors here; `vale/` is recognized as an engine directory but has no executor yet
17-
- [ ] 2.2 In `commands/check.ts`, call `ensureTasklessDirectory(cwd)` directly (preserving the migration trigger now that `generateSgConfig` leaves the check path)
18-
- [ ] 2.3 Tests: a rule under `sg/rules/` dispatches to ast-grep and one under `runtime/rules/` to the harness, by directory alone; an unknown engine directory is ignored rather than misrouted
19-
- [ ] 2.4 Treat the legacy `.taskless/rules/` path as an ast-grep source alongside `sg/rules/`, so an unmigrated checkout still runs; de-duplicate when both are present
20-
- [ ] 2.5 Tests: a `.taskless/` with only `rules/` dispatches to ast-grep; with both `rules/` and `sg/rules/`, findings merge without duplicates
18+
- [x] 2.1 Implement directory-based engine discovery: enumerate `.taskless/<engine>/` and route rules by directory, no per-file parsing. `sg` and `runtime` get executors here; `vale/` is recognized as an engine directory but has no executor yet — `rules/engines.ts` (`planEngineDispatch`, `discoverAstGrepRuleSources`)
19+
- [x] 2.2 In `commands/check.ts`, call `ensureTasklessDirectory(cwd)` directly (preserving the migration trigger now that `generateSgConfig` leaves the check path) — `rules/verify.ts` does the same, since the migration moves rules between the paths it resolves
20+
- [ ] 2.3 Tests: a rule under `sg/rules/` dispatches to ast-grep and one under `runtime/rules/` to the harness, by directory alone; an unknown engine directory is ignored rather than misrouted — **partially done**: the `sg/rules/` and unknown-directory scenarios are covered (`test/engine-dispatch.test.ts`), and the dispatch plan asserts `runtime` → harness; a rule _under `runtime/rules/`_ cannot reach the harness until 3.1 moves discovery, so that half is covered by 3.3
21+
- [x] 2.4 Treat the legacy `.taskless/rules/` path as an ast-grep source alongside `sg/rules/`, so an unmigrated checkout still runs; de-duplicate when both are present
22+
- [x] 2.5 Tests: a `.taskless/` with only `rules/` dispatches to ast-grep; with both `rules/` and `sg/rules/`, findings merge without duplicates
2123

2224
## 2b. Service-delivered rule ingest
2325

24-
- [ ] 2b.1 Update `rules/files.ts` — `writeRuleFile` writes `.taskless/sg/rules/<id>.yml` and `writeRuleTestFile` writes `.taskless/sg/rule-tests/`, replacing the hardcoded `.taskless/rules` / `.taskless/rule-tests` (both call sites are `commands/rules.ts:241,245,482,486`)
25-
- [ ] 2b.2 Resolve the destination from an engine the payload identifies, defaulting to `sg` when the payload identifies none — permanently, since the API carries no engine discriminator today
26-
- [ ] 2b.3 Fail loudly on an engine the CLI does not recognize: error naming the engine, instruct upgrade, write nothing (do NOT fall back to `sg`)
27-
- [ ] 2b.4 Audit the remaining `.taskless/rules` string literals for the same defect — at minimum `rules/verify.ts:246`, `rules/files.ts:99`, `commands/check.ts:314`, `commands/rules.ts:661`, and the `detect/scan.ts:428` layout probe
28-
- [ ] 2b.5 Tests: an engine-less payload lands in `sg/rules/` and is dispatched to ast-grep by `check`; a migrated rule and a freshly delivered one come to rest at the same path; an unrecognized engine errors and writes nothing
26+
- [x] 2b.1 Update `rules/files.ts` — `writeRuleFile` writes `.taskless/sg/rules/<id>.yml` and `writeRuleTestFile` writes `.taskless/sg/rule-tests/`, replacing the hardcoded `.taskless/rules` / `.taskless/rule-tests` (both call sites are `commands/rules.ts:241,245,482,486`)
27+
- [x] 2b.2 Resolve the destination from an engine the payload identifies, defaulting to `sg` when the payload identifies none — permanently, since the API carries no engine discriminator today
28+
- [x] 2b.3 Fail loudly on an engine the CLI does not recognize: error naming the engine, instruct upgrade, write nothing (do NOT fall back to `sg`)
29+
- [x] 2b.4 Audit the remaining `.taskless/rules` string literals for the same defect — at minimum `rules/verify.ts:246`, `rules/files.ts:99`, `commands/check.ts:314`, `commands/rules.ts:661`, and the `detect/scan.ts:428` layout probe. All five now resolve through `rules/engines.ts`; `help/*.txt` still names the legacy path and belongs to 5.3
30+
- [x] 2b.5 Tests: an engine-less payload lands in `sg/rules/` and is dispatched to ast-grep by `check`; a migrated rule and a freshly delivered one come to rest at the same path; an unrecognized engine errors and writes nothing
2931

3032
## 2c. Reconcile compatibility
3133

32-
- [ ] 2c.1 Confirm reported reconcile paths follow the moved trees (`rules/runtime/run-set.ts:57` builds repo-relative POSIX paths from the discovered location)
33-
- [ ] 2c.2 Test: after `0004`, signatures are unchanged and the signature-based join resolves every moved rule — nothing reports as new or missing
34+
- [x] 2c.1 Confirm reported reconcile paths follow the moved trees (`rules/runtime/run-set.ts:57` builds repo-relative POSIX paths from the discovered location) — confirmed unchanged; `relative(cwd, rule.checkFile)` is derived from the discovery root, so no edit was needed
35+
- [x] 2c.2 Test: after `0004`, signatures are unchanged and the signature-based join resolves every moved rule — nothing reports as new or missing. Asserted at the reporting layer (same signature, moved path) rather than against a live server
3436

3537
## 3. Runtime discovery path
3638

‎packages/cli/src/commands/check.ts‎

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { resolve, join, isAbsolute, relative } from "node:path";
2-
import { readdir, stat } from "node:fs/promises";
1+
import { resolve, isAbsolute, relative } from "node:path";
2+
import { stat } from "node:fs/promises";
33
import { defineCommand } from "citty";
44

55
import { runAstGrepScan } from "../rules/scan";
66
import type { CheckResult } from "../types/check";
77
import { formatText } from "../util/format";
88
import { generateSgConfig } from "../filesystem/sgconfig";
9+
import { ensureTasklessDirectory } from "../filesystem/directory";
10+
import {
11+
dedupeFindings,
12+
discoverAstGrepRuleSources,
13+
planEngineDispatch,
14+
} from "../rules/engines";
915
import { getTelemetry } from "../telemetry";
1016
import { outputSchema as checkOutputSchema } from "../schemas/check";
1117
import { makeErrorEnvelope } from "../types/errors";
@@ -25,8 +31,6 @@ import {
2531
signRuntimeChecks,
2632
} from "../rules/runtime/run-set";
2733
import { executeRuntimeRules } from "../rules/runtime/harness";
28-
import { SG_RULES_DIRECTORY } from "../filesystem/layout";
29-
import { ensureTasklessDirectory } from "../filesystem/directory";
3034

3135
async function pathExists(absolutePath: string): Promise<boolean> {
3236
try {
@@ -311,30 +315,25 @@ export const checkCommand = defineCommand({
311315
return;
312316
}
313317

314-
// Migrate before discovering anything. Rules are read from their
315-
// engine directory, which migration `0004` is what creates — discovering
316-
// first would find an empty `sg/rules/` on any project still on the flat
317-
// layout, report "No rules configured", and return before the migration
318-
// that would have populated it ever ran. Only an existing `.taskless/` is
319-
// migrated, so `check` in a project that has none still says so instead
320-
// of scaffolding one as a side effect.
321-
if (await pathExists(join(cwd, ".taskless"))) {
322-
await ensureTasklessDirectory(cwd);
323-
}
318+
// Rules dispatch by the engine directory that contains them. This is also
319+
// the migration trigger: `generateSgConfig` is leaving the check path, so
320+
// without this call an upgraded CLI would keep reading a stale layout.
321+
await ensureTasklessDirectory(cwd);
322+
const dispatch = await planEngineDispatch(cwd);
324323

325324
// Static rules (trusted ast-grep YAML) always run; runtime rules
326-
// (untrusted check.ts) are gated separately.
327-
const rulesDirectory = join(cwd, ".taskless", SG_RULES_DIRECTORY);
328-
let staticRuleFiles: string[] = [];
329-
try {
330-
const entries = await readdir(rulesDirectory);
331-
staticRuleFiles = entries.filter((f) => f.endsWith(".yml"));
332-
} catch {
333-
// .taskless/ or rules/ directory doesn't exist
334-
}
335-
const runtimeRules = await discoverRuntimeRules(cwd);
325+
// (untrusted check.ts) are gated separately. An engine directory this CLI
326+
// has no executor for (vale) contributes nothing, and a directory that is
327+
// not a known engine is ignored rather than handed to someone's parser.
328+
const astGrepSources = await discoverAstGrepRuleSources(cwd);
329+
const runtimeEnabled =
330+
dispatch.find((entry) => entry.engine === "runtime")?.executor ===
331+
"runtime-harness";
332+
const runtimeRules = runtimeEnabled
333+
? await discoverRuntimeRules(cwd)
334+
: [];
336335

337-
if (staticRuleFiles.length === 0 && runtimeRules.length === 0) {
336+
if (astGrepSources.length === 0 && runtimeRules.length === 0) {
338337
if (args.json) {
339338
console.log(
340339
JSON.stringify(
@@ -352,12 +351,21 @@ export const checkCommand = defineCommand({
352351
try {
353352
const results: CheckResult[] = [];
354353

355-
// Static rules: always scan, no verification (inert data).
356-
if (staticRuleFiles.length > 0) {
357-
await generateSgConfig(cwd);
354+
// Static rules: always scan, no verification (inert data). Each
355+
// ast-grep source is scanned on its own — `sg/rules/` and, for an
356+
// unmigrated checkout, the legacy `.taskless/rules/` — and identical
357+
// findings from both are collapsed so a rule present in both layouts
358+
// is reported once.
359+
const staticResults: CheckResult[] = [];
360+
for (const source of astGrepSources) {
361+
await generateSgConfig(cwd, {
362+
rulesDirectory: source.rulesDirectory,
363+
testDirectory: source.ruleTestsDirectory,
364+
});
358365
const scan = await runAstGrepScan(cwd, existingPaths);
359-
results.push(...scan.results);
366+
staticResults.push(...scan.results);
360367
}
368+
results.push(...dedupeFindings(staticResults));
361369

362370
// Runtime rules: run only what the server validated (or forced).
363371
const plan = await planRuntime(cwd, runtimeRules, {

‎packages/cli/src/commands/rules.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
readRuleMetaFile,
1515
deleteRuleFiles,
1616
} from "../rules/files";
17+
import { ENGINE_LAYOUTS } from "../rules/engines";
1718
import {
1819
inputSchema as createInputSchema,
1920
outputSchema as createOutputSchema,
@@ -658,7 +659,7 @@ const deleteCommand = defineCommand({
658659
}
659660
success = true;
660661
} else {
661-
const message = `Rule "${id}" not found in .taskless/rules/${id}.yml`;
662+
const message = `Rule "${id}" not found in .taskless/${ENGINE_LAYOUTS.sg.rulesDirectory}/${id}.yml`;
662663
if (args.json) {
663664
console.log(
664665
JSON.stringify(makeErrorEnvelope("RULE_NOT_FOUND", message))

‎packages/cli/src/detect/scan.ts‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { readFile as readFileNode } from "node:fs/promises";
33
import { resolve } from "node:path";
44

55
import { parse as parseToml } from "smol-toml";
6-
import { SG_RULES_DIRECTORY } from "../filesystem/layout";
6+
7+
import { ENGINE_LAYOUTS, LEGACY_RULES_DIRECTORY } from "../rules/engines";
78

89
export interface DetectedLinter {
910
name: string;
@@ -416,22 +417,29 @@ interface PythonManifest {
416417

417418
/**
418419
* Surface the styles of the repo's own existing rules so the authoring recipe
419-
* can match house conventions. `.taskless/rules` is the repo-root, polyglot
420-
* Taskless convention, so it is read at the scan root; the custom-ESLint-rule
421-
* tells (house rule directories and the local-rules plugin dependency) describe
422-
* how this repo already authors lint rules.
420+
* can match house conventions. ast-grep rules are the repo-root, polyglot
421+
* Taskless convention, so they are read at the scan root — from the `sg` engine
422+
* directory, or the pre-migration `.taskless/rules` when that is what the repo
423+
* still has; the custom-ESLint-rule tells (house rule directories and the
424+
* local-rules plugin dependency) describe how this repo already authors lint
425+
* rules.
423426
*/
424427
function detectRuleStyles(
425428
root: string,
426429
nodeManifests: NodeManifest[]
427430
): RuleStyle[] {
428431
const ruleStyles: RuleStyle[] = [];
429-
if (existsSync(resolve(root, ".taskless", SG_RULES_DIRECTORY))) {
432+
for (const source of [
433+
`.taskless/${ENGINE_LAYOUTS.sg.rulesDirectory}`,
434+
`.taskless/${LEGACY_RULES_DIRECTORY}`,
435+
]) {
436+
if (!existsSync(resolve(root, source))) continue;
430437
ruleStyles.push({
431-
source: `.taskless/${SG_RULES_DIRECTORY}`,
438+
source,
432439
description:
433440
"Existing Taskless ast-grep rules — match their structure and conventions.",
434441
});
442+
break;
435443
}
436444
for (const directory of [
437445
"eslint-rules",

‎packages/cli/src/filesystem/sgconfig.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,30 @@ import { writeFile } from "node:fs/promises";
22
import { join } from "node:path";
33

44
import { ensureTasklessDirectory } from "./directory";
5-
import { SG_RULES_DIRECTORY, SG_RULE_TESTS_DIRECTORY } from "./layout";
5+
import { ENGINE_LAYOUTS } from "../rules/engines";
66

77
/** Build sgconfig contents pointing `ruleDirs` at the given directory. */
8-
function sgConfigContent(rulesDirectory: string): string {
9-
return `ruleDirs:\n - ${rulesDirectory}\ntestConfigs:\n - testDir: ${SG_RULE_TESTS_DIRECTORY}\n`;
8+
function sgConfigContent(
9+
rulesDirectory: string,
10+
testDirectory: string
11+
): string {
12+
return `ruleDirs:\n - ${rulesDirectory}\ntestConfigs:\n - testDir: ${testDirectory}\n`;
1013
}
1114

1215
export interface SgConfigOptions {
1316
/**
1417
* Directory (relative to `.taskless/`) that ast-grep should load rules from.
15-
* Defaults to the `sg` engine directory. Reconciliation points this at the
16-
* ephemeral run directory so only the server-blessed run set is evaluated.
18+
* Defaults to `sg/rules`, the engine-partitioned location. Callers pass the
19+
* legacy `rules` when scanning an unmigrated tree, and reconciliation points
20+
* this at the ephemeral run directory so only the server-blessed run set is
21+
* evaluated.
1722
*/
1823
rulesDirectory?: string;
24+
/**
25+
* Directory (relative to `.taskless/`) holding that rule set's tests.
26+
* Defaults to `sg/rule-tests`. Only `sg test` reads it.
27+
*/
28+
testDirectory?: string;
1929
}
2030

2131
/**
@@ -29,7 +39,10 @@ export async function generateSgConfig(
2939
await ensureTasklessDirectory(cwd);
3040
await writeFile(
3141
join(cwd, ".taskless", "sgconfig.yml"),
32-
sgConfigContent(options.rulesDirectory ?? SG_RULES_DIRECTORY),
42+
sgConfigContent(
43+
options.rulesDirectory ?? ENGINE_LAYOUTS.sg.rulesDirectory,
44+
options.testDirectory ?? ENGINE_LAYOUTS.sg.ruleTestsDirectory
45+
),
3346
"utf8"
3447
);
3548
}

‎packages/cli/src/rules/engines.ts‎

8.24 KB
Binary file not shown.

0 commit comments

Comments
 (0)