Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/engine-partitioned-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 9 additions & 7 deletions openspec/changes/partition-rules-by-engine/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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`)
- [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

Expand All @@ -36,9 +38,9 @@

## 3. Runtime discovery path

- [ ] 3.1 Update `rules/runtime/discover.ts` to read `.taskless/runtime/rules/<name>/` and fixtures from `runtime/rule-tests/<name>/` (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/<name>/` and fixtures from `runtime/rule-tests/<name>/` (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/<name>/check.ts` with the signature unchanged

## 4. ast-grep engine over the committed config

Expand Down
15 changes: 0 additions & 15 deletions packages/cli/src/filesystem/layout.ts

This file was deleted.

25 changes: 16 additions & 9 deletions packages/cli/src/rules/runtime/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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.
*/
Expand All @@ -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[] = [];
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/rules/runtime/run-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/types/runtime-rule.ts
Original file line number Diff line number Diff line change
@@ -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/<name>/`: one or more ast-grep capture `*.yml` plus
* `.taskless/runtime/rules/<name>/`: 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.
*
Expand Down
52 changes: 51 additions & 1 deletion packages/cli/test/engine-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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> = {}): CheckResult {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/test/runtime-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");

Expand Down
Loading