diff --git a/.changeset/engine-partitioned-layout.md b/.changeset/engine-partitioned-layout.md index 276f3d73..4dde3696 100644 --- a/.changeset/engine-partitioned-layout.md +++ b/.changeset/engine-partitioned-layout.md @@ -8,4 +8,6 @@ The directory a rule sits in now **is** its engine: dispatch reads the path and A rule engine the CLI does not recognize is now rejected with a message instead of failing silently: an unsupported engine from the server previously exited 0 with no output, which read as success. +Runtime rules are discovered under `runtime/rules/` rather than the pre-migration `runtime-rules/`. Migration `0004` moves that tree byte-for-byte, so the signatures the server validates are unchanged. + Existing projects keep working without action. The pre-`0004` `.taskless/rules/` still runs as ast-grep, and a delivered rule that names no engine is still treated as ast-grep — a rule engine this CLI does not recognize is rejected rather than guessed at. A migration that would have to merge a file into an engine directory now refuses up front with `SCAFFOLD_CONFLICT` rather than failing part-way. diff --git a/openspec/changes/partition-rules-by-engine/tasks.md b/openspec/changes/partition-rules-by-engine/tasks.md index a1f5d72e..0e49f246 100644 --- a/openspec/changes/partition-rules-by-engine/tasks.md +++ b/openspec/changes/partition-rules-by-engine/tasks.md @@ -9,15 +9,17 @@ - [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 -> After group 1 alone, `check`/`verify`/runtime discovery still read the pre-move paths, so 20 tests in -> `check.test.ts`, `verify.test.ts`, and `runtime-check.test.ts` fail until groups 2–4 land. Group 2 brings -> that to 9, all in `runtime-check.test.ts`, which group 3 fixes. +> Group 1 relocates the trees **and** repoints every reader, so the suite is green on that group alone +> (396 passing). An earlier revision moved the files without following them, which left 20 failures across +> `check.test.ts`, `verify.test.ts`, and `runtime-check.test.ts` — those were two real defects, not an +> artifact of splitting the work: readers kept the pre-move paths, and `check` discovered rules before +> running the migration that creates them. Each group is independently correct; none is expected to be red. ## 2. Engine dispatch (directory model) - [x] 2.1 Implement directory-based engine discovery: enumerate `.taskless//` 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`) - [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 -- [ ] 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 +- [x] 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 — completed in group 3 once 3.1 moved discovery; `test/engine-dispatch.test.ts` and `test/runtime-check.test.ts` now cover both halves - [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 - [x] 2.5 Tests: a `.taskless/` with only `rules/` dispatches to ast-grep; with both `rules/` and `sg/rules/`, findings merge without duplicates @@ -36,9 +38,9 @@ ## 3. Runtime discovery path -- [ ] 3.1 Update `rules/runtime/discover.ts` to read `.taskless/runtime/rules//` and fixtures from `runtime/rule-tests//` (was `runtime-rules/`) -- [ ] 3.2 Confirm rules under `.taskless/sg/rules/` are treated as static, not runtime -- [ ] 3.3 Tests: runtime discovery at the new path; execution/reconcile/signing behavior unchanged +- [x] 3.1 Update `rules/runtime/discover.ts` to read `.taskless/runtime/rules//` and fixtures from `runtime/rule-tests//` (was `runtime-rules/`) — `RUNTIME_RULES_DIR` now derives from `ENGINE_LAYOUTS.runtime`. No code reads the rule-tests fixtures yet (only `0004` moves them), so only the rules path needed a change +- [x] 3.2 Confirm rules under `.taskless/sg/rules/` are treated as static, not runtime — discovery only ever enumerates the `runtime` engine directory; covered by a test that files a runtime-shaped capture under `sg/rules/` and asserts it is never discovered as runtime +- [x] 3.3 Tests: runtime discovery at the new path; execution/reconcile/signing behavior unchanged — `runtime-check.test.ts` and `runtime-harness.test.ts` now seed the engine layout and pass unmodified otherwise; the reported reconcile path moves to `.taskless/runtime/rules//check.ts` with the signature unchanged ## 4. ast-grep engine over the committed config diff --git a/packages/cli/src/filesystem/layout.ts b/packages/cli/src/filesystem/layout.ts deleted file mode 100644 index 950c5229..00000000 --- a/packages/cli/src/filesystem/layout.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Where migration `0004` puts the ast-grep tree, relative to `.taskless/`. - * - * `0004` runs before anything reads rules (every entry point goes through - * `ensureTasklessDirectory`), so by the time these are used the flat - * pre-migration `rules/` and `rule-tests/` no longer exist. Reading the old - * paths would silently find nothing — an empty scan reports success, so the - * failure mode is "no findings," not an error. - */ -export const SG_RULES_DIRECTORY = "sg/rules"; -export const SG_RULE_TESTS_DIRECTORY = "sg/rule-tests"; - -/** Where `0004` puts the runtime tree, relative to `.taskless/`. */ -export const RUNTIME_RULES_DIRECTORY = "runtime/rules"; -export const RUNTIME_RULE_TESTS_DIRECTORY = "runtime/rule-tests"; diff --git a/packages/cli/src/rules/runtime/discover.ts b/packages/cli/src/rules/runtime/discover.ts index a5c091a3..0ecde0af 100644 --- a/packages/cli/src/rules/runtime/discover.ts +++ b/packages/cli/src/rules/runtime/discover.ts @@ -4,10 +4,15 @@ import { join } from "node:path"; import { parse } from "yaml"; import type { CaptureRule, MatchMode } from "../../types/runtime-rule"; -import { RUNTIME_RULES_DIRECTORY } from "../../filesystem/layout"; +import { ENGINE_LAYOUTS } from "../engines"; -/** Directory (relative to `.taskless/`) that holds runtime rules. */ -export const RUNTIME_RULES_DIR = RUNTIME_RULES_DIRECTORY; +/** + * Directory (relative to `.taskless/`) that holds runtime rules — the + * `runtime` engine's own directory, so this tracks the engine layout rather + * than repeating it. Migration `0004` moved the tree here from + * `runtime-rules/` without touching a byte, so signatures are unaffected. + */ +export const RUNTIME_RULES_DIR = ENGINE_LAYOUTS.runtime.rulesDirectory; /** A parsed capture `*.yml` of a runtime rule, with the fields the harness needs. */ export interface LoadedCaptureRule { @@ -27,7 +32,7 @@ export interface LoadedCaptureRule { rule: CaptureRule; } -/** A discovered runtime rule directory under `.taskless/runtime-rules/`. */ +/** A discovered runtime rule directory under `.taskless/runtime/rules/`. */ export interface RuntimeRule { /** Rule directory basename (e.g. `no-default-export-abc12345`). */ name: string; @@ -88,10 +93,12 @@ async function loadCaptureRules( } /** - * Enumerate `.taskless/runtime-rules/` under `cwd` and return each rule + * Enumerate `.taskless/runtime/rules/` under `cwd` and return each rule * directory that holds at least one `kind: runtime` capture rule. - * `.taskless/runtime-rule-tests/` is never enumerated — it holds verification - * fixtures, not executable rules. + * `.taskless/runtime/rule-tests/` is never enumerated — it holds verification + * fixtures, not executable rules — and neither is any other engine's + * directory: a rule under `.taskless/sg/rules/` is static by virtue of where + * it lives, and is never considered here. */ export async function discoverRuntimeRules( cwd: string @@ -100,7 +107,7 @@ export async function discoverRuntimeRules( } /** - * Enumerate runtime rules under an explicit `runtime-rules` root — used to + * Enumerate runtime rules under an explicit runtime-rules root — used to * re-discover rules from the materialized `.taskless/.run/` tree so the executed * bytes are the blessed ones. */ @@ -111,7 +118,7 @@ export async function discoverRuntimeRulesIn( try { directoryEntries = await readdir(root, { withFileTypes: true }); } catch { - return []; // no runtime-rules directory + return []; // no runtime rules directory } const rules: RuntimeRule[] = []; diff --git a/packages/cli/src/rules/runtime/run-set.ts b/packages/cli/src/rules/runtime/run-set.ts index fff8c188..d2edecb1 100644 --- a/packages/cli/src/rules/runtime/run-set.ts +++ b/packages/cli/src/rules/runtime/run-set.ts @@ -90,7 +90,7 @@ export function selectBlessedRuntimeRules( * Materialize blessed runtime rules into the gitignored * `.taskless/.run/runtime-rules/` and return them re-discovered from there, so * the narrow and `check.ts` execute the blessed bytes rather than whatever is - * live in `.taskless/runtime-rules/`. + * live in `.taskless/runtime/rules/`. */ export async function materializeRuntimeRules( cwd: string, diff --git a/packages/cli/src/types/runtime-rule.ts b/packages/cli/src/types/runtime-rule.ts index fc61bf09..c4bd2900 100644 --- a/packages/cli/src/types/runtime-rule.ts +++ b/packages/cli/src/types/runtime-rule.ts @@ -1,7 +1,7 @@ /** * Runtime rule file format + harness↔check contract — mirrored structurally from * `@taskless/types` (workers/generator). A runtime rule is a DIRECTORY under - * `.taskless/runtime-rules//`: one or more ast-grep capture `*.yml` plus + * `.taskless/runtime/rules//`: one or more ast-grep capture `*.yml` plus * exactly one `check.ts`. The capture rules are the cheap syntactic narrow; the * `check.ts` refines only where the narrow matched. * diff --git a/packages/cli/test/engine-dispatch.test.ts b/packages/cli/test/engine-dispatch.test.ts index 8617a6e5..717b82e8 100644 --- a/packages/cli/test/engine-dispatch.test.ts +++ b/packages/cli/test/engine-dispatch.test.ts @@ -13,7 +13,10 @@ import { resolveIngestEngine, } from "../src/rules/engines"; import { writeRuleFile, writeRuleTestFile } from "../src/rules/files"; -import { discoverRuntimeRulesIn } from "../src/rules/runtime/discover"; +import { + discoverRuntimeRules, + discoverRuntimeRulesIn, +} from "../src/rules/runtime/discover"; import { reportRuntimeChecks, signRuntimeChecks, @@ -211,6 +214,53 @@ describe("engine dispatch by directory", () => { const sources = await discoverAstGrepRuleSources(temporaryDirectory); expect(sources).toEqual([]); }); + + it("discovers a runtime rule under runtime/rules/ and nothing else", async () => { + const runtimeRule = join( + tasklessDirectory, + "runtime", + "rules", + "logs-abc12345" + ); + await mkdir(runtimeRule, { recursive: true }); + await writeFile(join(runtimeRule, "logs.yml"), RUNTIME_CAPTURE, "utf8"); + await writeFile(join(runtimeRule, "check.ts"), RUNTIME_CHECK, "utf8"); + + const discovered = await discoverRuntimeRules(temporaryDirectory); + expect(discovered.map((rule) => rule.name)).toEqual(["logs-abc12345"]); + expect(discovered[0]?.checkFile).toBe(join(runtimeRule, "check.ts")); + }); + + it("treats a rule under sg/rules/ as static, never runtime", async () => { + // Same capture shape, filed under the ast-grep engine: the directory + // decides, so runtime discovery must not pick it up. + await writeFile( + join(tasklessDirectory, "sg", "rules", "logs.yml"), + RUNTIME_CAPTURE, + "utf8" + ); + await writeFile( + join(tasklessDirectory, "sg", "rules", "check.ts"), + RUNTIME_CHECK, + "utf8" + ); + + expect(await discoverRuntimeRules(temporaryDirectory)).toEqual([]); + const sources = await discoverAstGrepRuleSources(temporaryDirectory); + expect(sources.map((source) => source.rulesDirectory)).toEqual([ + "sg/rules", + ]); + }); + + it("does not discover runtime rules left at the pre-migration path", async () => { + // 0004 moves this tree; a leftover here is not a second runtime source. + const legacy = join(tasklessDirectory, "runtime-rules", "logs-abc12345"); + await mkdir(legacy, { recursive: true }); + await writeFile(join(legacy, "logs.yml"), RUNTIME_CAPTURE, "utf8"); + await writeFile(join(legacy, "check.ts"), RUNTIME_CHECK, "utf8"); + + expect(await discoverRuntimeRules(temporaryDirectory)).toEqual([]); + }); }); function finding(overrides: Partial = {}): CheckResult { diff --git a/packages/cli/test/runtime-check.test.ts b/packages/cli/test/runtime-check.test.ts index 0e7e3f87..63c151e2 100644 --- a/packages/cli/test/runtime-check.test.ts +++ b/packages/cli/test/runtime-check.test.ts @@ -133,7 +133,7 @@ const RUNTIME_CHECK = `export default async function (root, matches) { } `; -const CHECK_REPORT_PATH = ".taskless/runtime-rules/demo/check.ts"; +const CHECK_REPORT_PATH = ".taskless/runtime/rules/demo/check.ts"; describe("check: static vs runtime dispatch", () => { let directory: string; @@ -288,7 +288,7 @@ describe("check: static vs runtime dispatch", () => { it("a runtime rule missing check.ts is skipped, not fatal; static still runs", async () => { // A malformed rule (capture yml, no check.ts) must not abort the whole check. - const broken = join(directory, ".taskless", "runtime-rules", "broken"); + const broken = join(directory, ".taskless", "runtime", "rules", "broken"); await mkdir(broken, { recursive: true }); await writeFile(join(broken, "logs.yml"), RUNTIME_CAPTURE, "utf8");