diff --git a/.changeset/engine-partitioned-layout.md b/.changeset/engine-partitioned-layout.md new file mode 100644 index 00000000..fc081f1f --- /dev/null +++ b/.changeset/engine-partitioned-layout.md @@ -0,0 +1,9 @@ +--- +"@taskless/cli": minor +--- + +Partition `.taskless/` by rule engine. Migration `0004` moves ast-grep rules to `sg/rules/` and `sg/rule-tests/`, the runtime tree to `runtime/rules/` and `runtime/rule-tests/`, and scaffolds an inert `vale/`. Files move byte-for-byte, so runtime rule signatures survive. + +The directory a rule sits in now **is** its engine: dispatch reads the path and never parses a rule file to decide who owns it. `check` runs ast-grep against the committed `.taskless/sg/sgconfig.yml` instead of generating an ephemeral config each run. + +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/.openspec.yaml b/openspec/changes/partition-rules-by-engine/.openspec.yaml new file mode 100644 index 00000000..e8209ffa --- /dev/null +++ b/openspec/changes/partition-rules-by-engine/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-28 diff --git a/openspec/changes/partition-rules-by-engine/design.md b/openspec/changes/partition-rules-by-engine/design.md new file mode 100644 index 00000000..ba1fd25b --- /dev/null +++ b/openspec/changes/partition-rules-by-engine/design.md @@ -0,0 +1,91 @@ +## Context + +Taskless rules run today through one engine, ast-grep: `commands/check.ts` writes an ephemeral `sgconfig.yml` (`ruleDirs: [rules]`) and runs `sg scan --config … --json=stream`; rules live as bare ast-grep YAML at `.taskless/rules/*.yml`. A separate **runtime** tier (`runtime-rules//`: capture YAML + signed `check.ts`) runs only after an authenticated server reconcile. A `.taskless/` migration system already exists (`filesystem/migrate.ts` → `runMigrations`, keyed on `taskless.json`'s `version`, invoked via `ensureTasklessDirectory`). + +That shape has room for exactly one engine. Every co-located alternative explored earlier forced either an in-file marker saying which engine owns a rule, or a generated config materialized at check time. Partitioning by directory removes the question: the path _is_ the answer. + +Two facts, verified against `sg 0.41.0` and `vale 3.15.1`, make committed per-engine configs viable: + +- **Each tool's native config already expresses everything needed.** ast-grep scopes a rule with its own `files`/`ignores` and carries extra data in `metadata`. Vale scopes via `.vale.ini` matchers and silently ignores unknown keys. So Taskless needs **no sidecar or metadata layer** of its own. +- **Both read rules from disk via `--config` with no size ceiling** — where `--inline-rules` (`ARG_MAX`) and `sg --rule` (one file) cannot scale to hundreds of rules. + +## Goals / Non-Goals + +**Goals:** partition rules by engine directory; make each engine's committed native config the source of truth; remove config generation from the check path; ride the existing migration ladder; keep every observable behavior identical. + +**Non-Goals:** adding the Vale engine (it needs this layout first) and the engine-selection knowledge topic. Also excluded: changing runtime _execution_ — the harness, reconcile, and signing are untouched, only the directory moves; and authoring the committed configs, which belongs to rule generation. + +## Directory layout (target) + +``` +.taskless/ + sg/ sgconfig.yml rules/.yml rule-tests/.yml + vale/ .vale.ini rules/.yml rule-tests/.yml + runtime/ rules//{.yml, check.ts} rule-tests/// + taskless.json +``` + +All three land here. `vale/` is scaffolded but inert — no engine reads it until the Vale change. The `runtime/` realignment is safe now precisely because nothing uses runtime rules yet, and the move is content-preserving so hashes are unaffected. Every scaffolded directory carries a `.gitkeep` so the structure tracks reliably when empty. + +## Decisions + +### D1 — Engines partition by top-level directory + +`.taskless//` holds `{config, rules/, rule-tests/}` in that engine's native format. **Directory = engine**: dispatch reads the path, never the file. The config is committed and persisted, not generated. + +This is what makes `check` construction-free, and it sidesteps cross-parsing entirely — one engine's config directory never sees another's rule files. + +- **Alternative — co-located rules plus a sidecar or envelope:** rejected. Every variant forced either an in-file marker (which Vale rejects outright, `E201`) or a generated config materialized at check time. Per-engine native configs delete that layer rather than manage it. + +### D2 — The native config is the source of truth; nothing is generated at check time + +`sg/sgconfig.yml` declares `ruleDirs: [rules]` and `testConfigs: [{testDir: rule-tests}]`; rule files carry their own `files`/`ignores` scoping and any Taskless data in native `metadata`. Construction of these configs happens at **rule-authoring time** — by the generator, or by a human editing a committed file — never during a check. + +Because `generateSgConfig` no longer runs on the check path, `check` calls `ensureTasklessDirectory` directly so the migration still triggers. That is easy to drop and would silently strand users on an old layout. + +### D3 — Migration `0004` is a mechanical, content-preserving move + +The existing `.taskless/rules/` and `rule-tests/` are all known-ast-grep, so `0004` moves them under `sg/` without editing contents. `sgconfig.yml`'s `ruleDirs: [rules]` is **relative to the config**, so it survives the move unchanged — no path rewriting. It then scaffolds `vale/` and moves the runtime tier into the same shape. + +Content preservation is not incidental: runtime capture bytes determine their reconciliation hashes, so editing during the move would invalidate every signature. Only `discover.ts`'s search path changes. + +**Version gating:** `runMigrations` currently returns silently when `version > maxVersion`. It changes to **throw** ("upgrade the CLI") unless `--allow-version-mismatches` is passed, so a newer scaffold fails loudly instead of being half-read by an older CLI. + +### D4 — Service-delivered rules default to `sg`, permanently + +The ingest path is a separate hazard from the migration: `writeRuleFile`/`writeRuleTestFile` (`rules/files.ts`) hardcode `.taskless/rules`. Left alone, `0004` would relayout existing rules under `sg/` while the next `rule create` wrote straight back into a directory no engine dispatches from — silent, and it reads as a vanished rule. + +The API offers nothing to switch on: there is no `engine`, `analysisType`, or `ruleType` field anywhere in `src/generated/api.d.ts`, and `/cli/api/rule/{ruleId}` documents `rules[].content` as "The ast-grep rule definition". The `filename` fields that exist are **client→server** (`references[].filename` on `rule improve`; `files[].file` on reconcile) — the CLI tells the service where things live, not the reverse. + +So **no engine identified ⇒ `sg`**, permanently rather than for a migration window, since published CLIs keep receiving engine-less payloads. This is the same judgment `0004` makes about on-disk state, so ingest and migration land a rule in the same place. + +**Absence and unrecognized are not the same.** An unrecognized engine means the payload is newer than the CLI; defaulting would file it where the wrong parser reads it, surfacing as a broken rule rather than version skew. Ingest errors and writes nothing. + +- **Alternative — sniff the rule body to guess its engine:** rejected; contradicts D1 and guesses where an explicit default is correct and knowable. + +### D5 — Both layouts stay readable, so no producer has to cut over + +The CLI dispatches the legacy `.taskless/rules/` path as ast-grep alongside `sg/rules/`, de-duplicating when both exist. An unmigrated checkout still runs, and a service that keeps naming the old location keeps working. + +This is what decouples this change's release from anyone else's: there is no coordinated cutover, and no window in which a rule silently stops being checked. + +### D6 — Reconciliation is path-independent, so the relayout is invisible to the server + +Reported reconcile paths are repo-relative POSIX and derived from wherever a rule was discovered (`rules/runtime/run-set.ts`), so they follow the moved trees automatically. The server joins reported files **by content signature, not path** — "content-based, so a moved-but-unchanged rule resolves" — and `0004` is byte-preserving. A migrated rule therefore reconciles to the same server-side rule with no coordinated release. + +The API schema's description "Delivered rule filename under `.taskless/rules/` on the client" goes stale when this lands. That is cosmetic and belongs to the platform side; nothing depends on it. + +## Risks / Trade-offs + +- **Losing the migration trigger when `generateSgConfig` leaves the check path** → `check` calls `ensureTasklessDirectory` directly (D2), and that is worth a test rather than a comment. +- **Hardcoded `.taskless/rules` literals beyond the ingest writer** — `rules/verify.ts`, `rules/files.ts`, `commands/check.ts`, `commands/rules.ts`, and the `detect/scan.ts` layout probe all name the pre-migration path. Each is the same defect class; a missed one silently reads or writes outside the engine directories. +- **A partial migration leaves a split-brain `.taskless/`** → `0004` moves whole trees and the legacy path stays readable (D5), so a half-applied state still checks rules rather than dropping them. +- **Committed configs are maintained artifacts** — scoping now lives in files a human or generator edits, rather than being reconstructed each run. That is the trade: check-time simplicity for author-time maintenance. + +## Migration Plan + +`0004` runs on first `ensureTasklessDirectory` after upgrade, which `check` triggers. No user action. Rollback is a CLI downgrade plus moving `sg/rules/` back to `rules/` — but the legacy path stays readable, so an old CLI against a new layout degrades to finding no rules rather than erroring, and a new CLI against an old layout still works. + +## Open Questions + +None outstanding. The engine-selection knowledge topic and the Vale engine are deliberately deferred to the change that adds Vale, since both name engines this change only scaffolds. diff --git a/openspec/changes/partition-rules-by-engine/proposal.md b/openspec/changes/partition-rules-by-engine/proposal.md new file mode 100644 index 00000000..b72344e1 --- /dev/null +++ b/openspec/changes/partition-rules-by-engine/proposal.md @@ -0,0 +1,49 @@ +## Why + +Taskless rules run through one engine. `check` writes an ephemeral `sgconfig.yml` and runs `sg scan` over bare ast-grep YAML at `.taskless/rules/*.yml`; the runtime tier sits beside it at `.taskless/runtime-rules/`. Adding any second engine to that shape means either an in-file marker to say which engine owns a rule, or a generated config materialized at check time — both of which were tried and rejected in earlier designs. + +This change makes the layout multi-engine **before** any second engine exists: rules partition by top-level directory, each holding that tool's own native, committed config. It ships no new engine and changes no behavior a user can observe — `check` finds and reports exactly what it did before, from a different path. That is the point: the relayout and the migration carry all the risk, so they land on their own where a regression has one obvious cause. + +## What Changes + +- Partition rules into `.taskless//{config, rules/, rule-tests/}` — `sg/`, `vale/` (scaffolded empty), and `runtime/`. The containing directory determines the engine, so dispatch needs no per-file parsing. +- Make each engine's **committed native config** the source of truth, removing ephemeral `sgconfig.yml` generation from the check path. `check` runs `sg scan --config .taskless/sg/sgconfig.yml`. +- Add migration `0004`: move `rules/` → `sg/`, `rule-tests/` → `sg/rule-tests/`, `sgconfig.yml` → `sg/`, and `runtime-rules/` → `runtime/rules/` — content-preserving, so runtime capture hashes are unchanged. Scaffold `vale/`, `.gitkeep` every otherwise-empty directory, and bump the scaffold version. +- Gate on scaffold version: `runMigrations` throws when `taskless.json`'s `version` exceeds what the CLI knows, unless `--allow-version-mismatches` is passed. +- Write service-delivered rules into `sg/`. The delivery API carries no engine discriminator, so an engine-less payload is ast-grep by definition; an unrecognized engine fails loudly rather than defaulting. +- Keep dispatching the legacy `.taskless/rules/` path alongside `sg/rules/`, so an unmigrated checkout still runs and no producer has to cut over. + +## Capabilities + +### New Capabilities + +- `cli-rule-format`: The engine-partitioned on-disk layout — `.taskless//{config, rules/, rule-tests/}` with each engine's native committed config as the source of truth, directory-based dispatch, the migration that gets there, ingest defaulting, and legacy-layout tolerance. The extension point every future engine plugs into. + +### Modified Capabilities + +- `cli-check`: Check runs ast-grep against the committed `sg/sgconfig.yml` rather than generating one, and dispatches by engine directory. Because `generateSgConfig` leaves the check path, `check` calls `ensureTasklessDirectory` directly to preserve the migration trigger. +- `cli-runtime-rule-execution`: Runtime rules are discovered under `.taskless/runtime/rules//` instead of `runtime-rules/` — a directory move only; execution, reconcile, and signing semantics are untouched. + +## Impact + +- **CLI (`packages/cli`)**: new `filesystem/migrations/0004-*.ts`; `commands/check.ts` (engine-directory dispatch, migration trigger); `rules/scan.ts` and `rules/verify.ts` (`--config` over `sg/`); `rules/runtime/discover.ts` (new runtime path); `rules/files.ts` (ingest writes into `sg/`); removal of ephemeral `filesystem/sgconfig.ts` generation from the check path. +- **On-disk**: every existing `.taskless/` is relaid out by `0004` on first run. Content-preserving, so nothing needs re-signing or re-reconciling. +- **No new engine, no new binary, no user-visible behavior change.** `vale/` is scaffolded empty and nothing executes it yet. +- **Deliberately excluded**: the Vale engine itself and the engine-selection knowledge topic, which need this layout to exist first. + +## Delivery shape + +**Stacked, merging down.** Not a preference — a constraint, verified. Task group 1 alone leaves **20 tests failing**: migration `0004` moves rules out from under readers that groups 2–4 update (`rules/scan.ts`, `rules/verify.ts`, `commands/check.ts`, `rules/runtime/discover.ts`). Any intermediate state ships a CLI that has relocated its rules and cannot find them, so no unit can reach production alone. + +The stack merges down into the bottom branch and reaches `main` as one commit. + +| Unit | Scope | +| ---- | -------------------------------------------------------------------------------- | +| 1 | Migration `0004`, directory scaffolding, version gating | +| 2 | Directory-based dispatch, service-delivered rule ingest, reconcile compatibility | +| 3 | Runtime discovery path | +| 4 | ast-grep over the committed config, quality gates | + +Group 1's diff is ~580 lines under `packages/`, 347 of them fixture tests. That exceeds the repo's ~300-line guideline and is the honest cost of a migration that must prove byte-identical moves; splitting the tests from the migration they cover would make review worse, not better. + +**Tracking:** OSS-24 diff --git a/openspec/changes/partition-rules-by-engine/specs/cli-check/spec.md b/openspec/changes/partition-rules-by-engine/specs/cli-check/spec.md new file mode 100644 index 00000000..cc8bf7a4 --- /dev/null +++ b/openspec/changes/partition-rules-by-engine/specs/cli-check/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Check subcommand executes ast-grep scan + +The CLI SHALL execute `sg scan --config .taskless/sg/sgconfig.yml --json=stream` using `child_process.spawn` with `shell: true` for cross-platform binary resolution, reading the **committed** ast-grep config at `.taskless/sg/sgconfig.yml`. No `sgconfig.yml` is generated at check time. The `sg` binary SHALL be resolved from the `@ast-grep/cli` dependency via PATH. Reconciliation/run-set semantics for runtime rules are unchanged. + +#### Scenario: ast-grep scan runs with the committed config + +- **WHEN** the CLI executes the ast-grep scanner +- **THEN** it SHALL invoke `sg scan` with `--config .taskless/sg/sgconfig.yml` and `--json=stream` +- **AND** it SHALL NOT write or generate a config file +- **AND** the working directory for the spawned process SHALL be the resolved project directory + +#### Scenario: ast-grep binary is not found + +- **WHEN** the `sg` binary cannot be resolved from PATH +- **THEN** the CLI SHALL print an error message indicating ast-grep is not available +- **AND** the CLI SHALL exit with code 1 + +### Requirement: Check dispatches static and runtime rules to distinct executors + +`taskless check` SHALL dispatch rules to distinct executors by their engine directory: **ast-grep** rules under `.taskless/sg/` via the ast-grep scanner, **Vale** rules under `.taskless/vale/` via the Vale runner (per the `cli-vale-rule-engine` capability), and **runtime** rules under `.taskless/runtime/rules/` via the runtime harness (per the `cli-runtime-rule-execution` capability). Findings from all executors SHALL be aggregated into the same result set and SHALL count toward the exit code identically. + +#### Scenario: Mixed corpus runs all executors + +- **WHEN** `.taskless/sg/` contains ast-grep rules, `.taskless/vale/` contains Vale rules, and `.taskless/runtime/rules/` contains runtime rules +- **THEN** the CLI SHALL run ast-grep rules through `sg scan`, Vale rules through the Vale runner, and runtime rules through the runtime harness +- **AND** SHALL merge their findings into one result set + +## REMOVED Requirements + +### Requirement: Check subcommand generates ephemeral sgconfig.yml + +**Reason**: Engine configs are now committed per-engine directory (`.taskless/sg/sgconfig.yml`), not generated at check time — check reads the committed config directly. + +**Migration**: Migration `0004` moves the existing `.taskless/sgconfig.yml` to `.taskless/sg/sgconfig.yml` (its relative `ruleDirs: [rules]` remains valid). No config is written at check time thereafter. diff --git a/openspec/changes/partition-rules-by-engine/specs/cli-rule-format/spec.md b/openspec/changes/partition-rules-by-engine/specs/cli-rule-format/spec.md new file mode 100644 index 00000000..f2c87038 --- /dev/null +++ b/openspec/changes/partition-rules-by-engine/specs/cli-rule-format/spec.md @@ -0,0 +1,114 @@ +## ADDED Requirements + +### Requirement: Rules are partitioned into per-engine directories + +The system SHALL store rules under a top-level engine directory `.taskless//`, each with a `rules/` directory and a `rule-tests/` directory. The `sg` engine SHALL use `sgconfig.yml`; the `vale` engine SHALL use `.vale.ini`; the `runtime` engine SHALL store each rule as a directory `rules//` (capture `*.yml` + `check.ts`) with fixtures under `rule-tests//`. + +#### Scenario: ast-grep engine directory + +- **WHEN** the CLI resolves `.taskless/` +- **THEN** ast-grep rules are found under `.taskless/sg/rules/`, the config is `.taskless/sg/sgconfig.yml`, and tests are under `.taskless/sg/rule-tests/` + +#### Scenario: Vale engine directory + +- **WHEN** the CLI resolves `.taskless/` +- **THEN** Vale styles are found under `.taskless/vale/rules/`, the config is `.taskless/vale/.vale.ini`, and tests are under `.taskless/vale/rule-tests/` + +### Requirement: A rule's engine is determined by its containing directory + +The system SHALL dispatch each rule to the engine named by its top-level `.taskless//` directory, and SHALL NOT parse a rule file to determine its engine. + +#### Scenario: Directory-based dispatch + +- **WHEN** a rule file exists at `.taskless/sg/rules/no-eval.yml` and another at `.taskless/vale/rules/no-simply.yml` +- **THEN** the first is executed by ast-grep and the second by Vale, based solely on directory + +### Requirement: Each engine's committed native config is the source of truth + +The system SHALL treat each engine's committed native config as the authoritative definition of its rules, their scoping, and their metadata. The system SHALL NOT require a separate Taskless sidecar or metadata file for a rule, and SHALL NOT generate an engine config at check time. + +#### Scenario: No sidecar or generated config + +- **WHEN** the CLI runs a check +- **THEN** it reads the committed `sg/sgconfig.yml` and `vale/.vale.ini` as-is, and neither writes nor generates an engine config + +#### Scenario: Native scoping is applied by the engine + +- **WHEN** an ast-grep rule declares native `files`/`ignores`, or a Vale `.vale.ini` declares per-rule include/exclude sections +- **THEN** the engine applies that scoping directly, with no Taskless-side rule transformation + +### Requirement: Migration preserves existing ast-grep rules by moving them under sg + +The migration to the engine-partitioned layout SHALL move the existing `.taskless/rules/`, `.taskless/rule-tests/`, and `.taskless/sgconfig.yml` under `.taskless/sg/` without editing file contents, relying on `sgconfig.yml`'s relative `ruleDirs: [rules]` remaining valid after the move. It SHALL scaffold `.taskless/vale/` and SHALL move `.taskless/runtime-rules/` to `.taskless/runtime/rules/` and `.taskless/runtime-rule-tests/` to `.taskless/runtime/rule-tests/` without editing file contents (preserving runtime capture-rule hashes). Every scaffolded directory that would otherwise be empty SHALL contain a `.gitkeep` file so the structure is tracked reliably. + +#### Scenario: Mechanical move of legacy rules + +- **WHEN** the migration runs against a `.taskless/` containing `rules/`, `rule-tests/`, and `sgconfig.yml` +- **THEN** those become `sg/rules/`, `sg/rule-tests/`, and `sg/sgconfig.yml`, and `sg scan --config .taskless/sg/sgconfig.yml` runs the same rules as before the move + +#### Scenario: Vale scaffolded, runtime moved + +- **WHEN** the migration runs +- **THEN** `.taskless/vale/` is created with empty `rules/` and `rule-tests/`, and `.taskless/runtime-rules/` becomes `.taskless/runtime/rules/` with byte-identical contents + +### Requirement: Service-delivered rules without an engine are written as ast-grep + +The rule ingest path SHALL write a service-delivered rule into the engine directory its payload identifies. The current API carries **no** engine discriminator — `/cli/api/rule/{ruleId}` returns `rules[].content` documented as an ast-grep rule definition — so a payload that does not identify an engine SHALL be written as ast-grep, under `.taskless/sg/rules/.yml`, with its tests under `.taskless/sg/rule-tests/`. + +This default is permanent, not a migration window: published CLIs and stored payloads without an engine field continue to exist indefinitely, and the default matches what the migration does to the same rules already on disk. + +Absence of an engine and an **unrecognized** engine are distinct. If a payload identifies an engine the installed CLI does not know, ingest SHALL fail with an error naming the engine and instructing the user to upgrade, and SHALL NOT fall back to ast-grep. + +#### Scenario: Engine-less payload is filed under sg + +- **WHEN** a rule is delivered by the service with no engine identified in its payload +- **THEN** it is written to `.taskless/sg/rules/.yml` and its tests to `.taskless/sg/rule-tests/`, and a subsequent `check` dispatches it to ast-grep + +#### Scenario: Ingest and migration agree on destination + +- **WHEN** a rule that predates the engine-partitioned layout is migrated, and an equivalent rule is delivered fresh by the service +- **THEN** both come to rest at the same path under `.taskless/sg/rules/` + +#### Scenario: Unrecognized engine fails loudly + +- **WHEN** a payload identifies an engine the installed CLI does not support +- **THEN** ingest exits with an error naming the engine and directing the user to upgrade, and no rule file is written under any engine directory + +### Requirement: Both the legacy and engine-partitioned layouts are readable + +The CLI SHALL dispatch rules found at the legacy `.taskless/rules/` path as ast-grep, in addition to `.taskless/sg/rules/`, so a checkout that has not yet been migrated — or a rule delivered by a service that still names the legacy location — is executed rather than ignored. + +This tolerance is what decouples the CLI's release from any consumer's: a producer may continue to use the pre-migration layout indefinitely and its rules keep running. + +#### Scenario: Unmigrated checkout still runs its rules + +- **WHEN** `check` runs against a `.taskless/` containing `rules/` but no `sg/` +- **THEN** those rules are dispatched to ast-grep and reported, not silently skipped + +#### Scenario: Both layouts present + +- **WHEN** rules exist under both `.taskless/rules/` and `.taskless/sg/rules/` +- **THEN** both are dispatched to ast-grep and their findings merged, with no duplicate reporting of the same rule + +### Requirement: Reconciliation survives the relayout + +The CLI SHALL report rule files to the reconcile endpoint at their post-migration repo-relative paths. Because the server joins reported files by content signature rather than by path, moving a rule without editing it SHALL NOT change its reconciled state. + +#### Scenario: Moved rules reconcile unchanged + +- **WHEN** `check` reconciles after the migration has moved rules from `.taskless/rules/` to `.taskless/sg/rules/` and runtime rules to `.taskless/runtime/rules/` +- **THEN** each file's signature is unchanged, the server resolves it to the same rule, and no rule is reported as new or missing + +### Requirement: The CLI refuses a scaffold newer than it understands unless overridden + +When `taskless.json`'s `version` exceeds the highest migration the installed CLI knows, the system SHALL exit with an error instructing the user to upgrade the CLI, unless `--allow-version-mismatches` is passed, in which case it SHALL proceed without applying migrations. + +#### Scenario: Newer scaffold blocks + +- **WHEN** `taskless.json` has a `version` greater than the CLI's maximum known migration +- **THEN** the CLI exits with an error telling the user to upgrade the CLI + +#### Scenario: Override proceeds + +- **WHEN** the same condition holds and `--allow-version-mismatches` is set +- **THEN** the CLI proceeds without applying migrations diff --git a/openspec/changes/partition-rules-by-engine/specs/cli-runtime-rule-execution/spec.md b/openspec/changes/partition-rules-by-engine/specs/cli-runtime-rule-execution/spec.md new file mode 100644 index 00000000..7eb1447f --- /dev/null +++ b/openspec/changes/partition-rules-by-engine/specs/cli-runtime-rule-execution/spec.md @@ -0,0 +1,23 @@ +## MODIFIED Requirements + +### Requirement: Runtime rules are directories recognized by metadata + +The CLI SHALL recognize a **runtime rule** as a directory under `.taskless/runtime/rules//` +containing one or more ast-grep capture `*.yml` (one per capture rule) and a single `check.ts`, +its capture rules declaring `metadata.taskless.kind: runtime`. The rule's check file SHALL be +the `check.ts` in the rule directory. The CLI SHALL read **each capture rule's** +`metadata.taskless.match` (`anchor` or `broad`) to select that capture rule's ast-grep +invocation mode; capture rules within one runtime rule MAY mix modes (each is independent). +Rule files under `.taskless/sg/rules/` SHALL continue to be treated as static ast-grep rules, +not runtime rules. `.taskless/runtime/rule-tests//` holds `valid/` and `invalid/` +verification fixtures and SHALL NOT be executed by `check`. + +#### Scenario: A runtime directory entry is a runtime rule + +- **WHEN** `.taskless/runtime/rules//` contains capture `*.yml` with `metadata.taskless.kind: runtime` and a `check.ts` +- **THEN** the CLI SHALL treat it as a runtime rule with `check.ts` as its check file and route it to the runtime harness + +#### Scenario: Rules under sg remain static + +- **WHEN** a rule file lives under `.taskless/sg/rules/` +- **THEN** the CLI SHALL treat it as a static rule and SHALL NOT route it to the runtime harness diff --git a/openspec/changes/partition-rules-by-engine/tasks.md b/openspec/changes/partition-rules-by-engine/tasks.md new file mode 100644 index 00000000..48683608 --- /dev/null +++ b/openspec/changes/partition-rules-by-engine/tasks.md @@ -0,0 +1,51 @@ +## 1. Migration and directory layout + +- [x] 1.1 Add `filesystem/migrations/0004-vale-engine.ts`: move `.taskless/rules/`, `rule-tests/`, and `sgconfig.yml` under `.taskless/sg/` (content-preserving; `ruleDirs: [rules]` is relative and stays valid) — also writes a default `sg/sgconfig.yml` when the project has none to move (it was git-ignored and generated ephemerally before this change) +- [x] 1.2 Extend `0004` to move `.taskless/runtime-rules/ → runtime/rules/` and `runtime-rule-tests/ → runtime/rule-tests/` byte-for-byte (runtime hashes unchanged) +- [x] 1.3 Extend `0004` to scaffold `.taskless/vale/` (`.vale.ini` + `rules/` + `rule-tests/`), writing a `.gitkeep` into every otherwise-empty scaffolded directory, and bump `taskless.json` `version` +- [x] 1.4 Register `0004` in `filesystem/migrate.ts` and confirm `runMigrations` applies it via `ensureTasklessDirectory` +- [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 +- [x] 1.6 Tests: `0004` moves each tree correctly, `.gitkeep` present, runtime contents byte-identical; gating throws and the flag overrides + +> 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. `.taskless/.gitignore` +> also still ignores `sgconfig.yml`, which now matches the committed `sg/sgconfig.yml` (task 5.3). + +## 2. Engine dispatch (directory model) + +- [ ] 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 +- [ ] 2.2 In `commands/check.ts`, call `ensureTasklessDirectory(cwd)` directly (preserving the migration trigger now that `generateSgConfig` leaves the check path) +- [ ] 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 +- [ ] 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 +- [ ] 2.5 Tests: a `.taskless/` with only `rules/` dispatches to ast-grep; with both `rules/` and `sg/rules/`, findings merge without duplicates + +## 2b. Service-delivered rule ingest + +- [ ] 2b.1 Update `rules/files.ts` — `writeRuleFile` writes `.taskless/sg/rules/.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`) +- [ ] 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 +- [ ] 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`) +- [ ] 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 +- [ ] 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 + +## 2c. Reconcile compatibility + +- [ ] 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) +- [ ] 2c.2 Test: after `0004`, signatures are unchanged and the signature-based join resolves every moved rule — nothing reports as new or missing + +## 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 + +## 4. ast-grep engine over the committed config + +- [ ] 4.1 Update `rules/scan.ts` to run `sg scan --config .taskless/sg/sgconfig.yml --json=stream` and remove ephemeral `sgconfig.yml` generation from the check path +- [ ] 4.2 Update `rules/verify.ts` to run `sg test -c .taskless/sg/sgconfig.yml` over `sg/rule-tests/` +- [ ] 4.3 Tests: scan/verify run against the committed `sg/` config; `sg` binary-not-found prints an error and exits 1 + +## 5. Quality gates + +- [ ] 5.1 `pnpm --filter @taskless/cli typecheck && lint && test` clean +- [ ] 5.2 Verify `check` output is identical before and after the relayout on a real `.taskless/` — same findings, same exit code. This change is a no-op to the user, so a difference is a regression +- [ ] 5.3 Update CLI help/onboarding text that names `.taskless/rules/` for the engine-partitioned layout, and `.taskless/.gitignore` handling diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index ac856e94..6bbdce72 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -25,6 +25,8 @@ import { signRuntimeChecks, } from "../rules/runtime/run-set"; import { executeRuntimeRules } from "../rules/runtime/harness"; +import { SG_RULES_DIRECTORY } from "../filesystem/layout"; +import { ensureTasklessDirectory } from "../filesystem/directory"; async function pathExists(absolutePath: string): Promise { try { @@ -309,9 +311,20 @@ export const checkCommand = defineCommand({ return; } + // Migrate before discovering anything. Rules are read from their + // engine directory, which migration `0004` is what creates — discovering + // first would find an empty `sg/rules/` on any project still on the flat + // layout, report "No rules configured", and return before the migration + // that would have populated it ever ran. Only an existing `.taskless/` is + // migrated, so `check` in a project that has none still says so instead + // of scaffolding one as a side effect. + if (await pathExists(join(cwd, ".taskless"))) { + await ensureTasklessDirectory(cwd); + } + // Static rules (trusted ast-grep YAML) always run; runtime rules // (untrusted check.ts) are gated separately. - const rulesDirectory = join(cwd, ".taskless", "rules"); + const rulesDirectory = join(cwd, ".taskless", SG_RULES_DIRECTORY); let staticRuleFiles: string[] = []; try { const entries = await readdir(rulesDirectory); diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 8577d32b..898c3faa 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -3,6 +3,7 @@ import { readFile as readFileNode } from "node:fs/promises"; import { resolve } from "node:path"; import { parse as parseToml } from "smol-toml"; +import { SG_RULES_DIRECTORY } from "../filesystem/layout"; export interface DetectedLinter { name: string; @@ -425,9 +426,9 @@ function detectRuleStyles( nodeManifests: NodeManifest[] ): RuleStyle[] { const ruleStyles: RuleStyle[] = []; - if (existsSync(resolve(root, ".taskless", "rules"))) { + if (existsSync(resolve(root, ".taskless", SG_RULES_DIRECTORY))) { ruleStyles.push({ - source: ".taskless/rules", + source: `.taskless/${SG_RULES_DIRECTORY}`, description: "Existing Taskless ast-grep rules — match their structure and conventions.", }); diff --git a/packages/cli/src/filesystem/directory.ts b/packages/cli/src/filesystem/directory.ts index a53b1311..5b1fa210 100644 --- a/packages/cli/src/filesystem/directory.ts +++ b/packages/cli/src/filesystem/directory.ts @@ -11,6 +11,11 @@ export interface EnsureOptions { * runner falls back to its default `console.error` message. */ onNotice?: (message: string) => void; + /** + * Proceed instead of throwing when `.taskless/` is newer than this CLI + * understands. Defaults to whether `--allow-version-mismatches` is in argv. + */ + allowVersionMismatches?: boolean; } /** @@ -24,5 +29,8 @@ export async function ensureTasklessDirectory( ): Promise { const tasklessDirectory = join(cwd, ".taskless"); await mkdir(tasklessDirectory, { recursive: true }); - await runMigrations(tasklessDirectory, { onNotice: options.onNotice }); + await runMigrations(tasklessDirectory, { + onNotice: options.onNotice, + allowVersionMismatches: options.allowVersionMismatches, + }); } diff --git a/packages/cli/src/filesystem/layout.ts b/packages/cli/src/filesystem/layout.ts new file mode 100644 index 00000000..950c5229 --- /dev/null +++ b/packages/cli/src/filesystem/layout.ts @@ -0,0 +1,15 @@ +/** + * 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/filesystem/migrate.ts b/packages/cli/src/filesystem/migrate.ts index 01448931..f954fe27 100644 --- a/packages/cli/src/filesystem/migrate.ts +++ b/packages/cli/src/filesystem/migrate.ts @@ -1,10 +1,12 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { CLIError } from "../util/cli-error"; import type { Migrations } from "./types"; import init from "./migrations/0001-init"; import installMigration from "./migrations/0002-install"; import dropInstalledAt from "./migrations/0003-drop-installed-at"; +import valeEngine from "./migrations/0004-vale-engine"; export interface TasklessInstallTarget { skills?: string[]; @@ -34,8 +36,24 @@ const migrations: Migrations = { "1": init, "2": installMigration, "3": dropInstalledAt, + "4": valeEngine, }; +/** Global flag that downgrades a too-new scaffold from an error to a skip. */ +export const ALLOW_VERSION_MISMATCHES_FLAG = "--allow-version-mismatches"; + +/** + * Whether the invocation opted out of scaffold-version enforcement. Read from + * raw argv rather than a parsed command, because every command reaches the + * migration runner through {@link ensureTasklessDirectory} and none of them + * thread their own options down to it. + */ +export function hasVersionMismatchOverride( + rawArguments: string[] = process.argv.slice(2) +): boolean { + return rawArguments.includes(ALLOW_VERSION_MISMATCHES_FLAG); +} + /** Sort migration keys numerically and return [version, migration] pairs */ function sortedMigrations( record: Migrations @@ -145,12 +163,22 @@ export interface RunMigrationsOptions { * custom handler to route the notice through their logger. */ onNotice?: (message: string) => void; + /** + * Proceed without applying migrations when the on-disk scaffold is newer + * than this CLI understands, instead of throwing. Defaults to whether + * {@link ALLOW_VERSION_MISMATCHES_FLAG} is present in argv. + */ + allowVersionMismatches?: boolean; } /** * Run any pending migrations against the .taskless/ directory. * Reads the current version from taskless.json and runs migrations * whose numeric key is greater than the current version. + * + * Throws when the manifest's version is *newer* than the highest migration + * this CLI knows: an older CLI cannot safely read a layout written by a newer + * one, so it fails loudly rather than half-reading it. */ export async function runMigrations( tasklessDirectory: string, @@ -162,7 +190,18 @@ export async function runMigrations( const maxVersion = sorted.at(-1)![0]; const { version } = await readRawManifest(tasklessDirectory); - if (version >= maxVersion) { + if (version > maxVersion) { + if (options.allowVersionMismatches ?? hasVersionMismatchOverride()) { + return; + } + throw new CLIError( + `This project's .taskless/ scaffold is version ${String(version)}, but this CLI only understands version ${String(maxVersion)}. ` + + `Upgrade the CLI to continue, or re-run with ${ALLOW_VERSION_MISMATCHES_FLAG} to proceed without migrating.`, + "SCAFFOLD_VERSION_MISMATCH" + ); + } + + if (version === maxVersion) { return; } diff --git a/packages/cli/src/filesystem/migrations/0001-init.ts b/packages/cli/src/filesystem/migrations/0001-init.ts index 4d301605..8f7bfa8d 100644 --- a/packages/cli/src/filesystem/migrations/0001-init.ts +++ b/packages/cli/src/filesystem/migrations/0001-init.ts @@ -41,7 +41,11 @@ const migration: Migration = async (directory) => { // Ensure .gitignore has required entries const cwd = join(directory, ".."); - await addToGitignore(cwd, [".env.local.json", "sgconfig.yml"]); + // `/sgconfig.yml` is anchored to `.taskless/` on purpose: an unanchored + // pattern matches at any depth and would also ignore the committed + // `.taskless/sg/sgconfig.yml`. Migration 0004 rewrites the unanchored form + // that earlier versions of this migration wrote. + await addToGitignore(cwd, [".env.local.json", "/sgconfig.yml"]); // Create subdirectories await mkdir(join(directory, "rules"), { recursive: true }); diff --git a/packages/cli/src/filesystem/migrations/0004-vale-engine.ts b/packages/cli/src/filesystem/migrations/0004-vale-engine.ts new file mode 100644 index 00000000..425a5594 --- /dev/null +++ b/packages/cli/src/filesystem/migrations/0004-vale-engine.ts @@ -0,0 +1,282 @@ +import { + cp, + mkdir, + readdir, + readFile, + rename, + rm, + rmdir, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import type { Migration } from "../types"; +import { CLIError } from "../../util/cli-error"; + +/** + * Default `sgconfig.yml` written when a project has none to move. `ruleDirs` + * and `testDir` are relative to the config file, so this is the same content + * the pre-migration ephemeral generator produced — it simply now lives beside + * the rules it points at, inside `sg/`. + */ +const SG_CONFIG_CONTENT = `ruleDirs:\n - rules\ntestConfigs:\n - testDir: rule-tests\n`; + +/** + * Minimal, inert `.vale.ini`. Nothing executes Vale yet; this exists so the + * engine directory has its native config in the canonical place from day one. + */ +const VALE_CONFIG_CONTENT = `StylesPath = rules\nMinAlertLevel = suggestion\n\n[*]\n`; + +/** Directories that must exist after the migration, tracked when empty. */ +const SCAFFOLD_DIRECTORIES = [ + ["sg", "rules"], + ["sg", "rule-tests"], + ["vale", "rules"], + ["vale", "rule-tests"], + ["runtime", "rules"], + ["runtime", "rule-tests"], +] as const; + +/** + * Every path that must be a directory for the migration to complete: each + * scaffold directory *and* each of its ancestors, shallow first. + * + * The engine roots (`sg/`, `vale/`, `runtime/`) are only ever created + * implicitly, as a side effect of creating the directories under them, so they + * appear in no other list — and a file sitting at one of those roots blocks the + * migration just as surely as a file at a leaf. + */ +const REQUIRED_DIRECTORIES: string[][] = (() => { + const seen = new Set(); + const paths: string[][] = []; + for (const segments of SCAFFOLD_DIRECTORIES) { + for (let depth = 1; depth <= segments.length; depth++) { + const prefix = segments.slice(0, depth); + const key = prefix.join("/"); + if (seen.has(key)) continue; + seen.add(key); + paths.push([...prefix]); + } + } + return paths; +})(); + +/** + * Trees moved by this migration, as [legacy path, engine-partitioned path] + * relative to `.taskless/`. Every move is content-preserving: runtime capture + * bytes determine their server-side reconciliation hashes, so a rewrite here + * would invalidate every signature. + */ +const MOVES: Array<[string[], string[]]> = [ + [["rules"], ["sg", "rules"]], + [["rule-tests"], ["sg", "rule-tests"]], + [["sgconfig.yml"], ["sg", "sgconfig.yml"]], + [["runtime-rules"], ["runtime", "rules"]], + [["runtime-rule-tests"], ["runtime", "rule-tests"]], +]; + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +/** + * Move `source` to `destination`, preserving contents exactly. + * + * When `destination` does not exist this is a plain rename (a copy+delete + * fallback covers a cross-device `.taskless/`). When it does exist — a + * half-applied earlier run, or a project that already started using the new + * layout — directory contents are merged entry by entry with the destination + * winning, and any source entry that could not be merged is left in place + * rather than deleted. The legacy path stays readable, so leftovers keep + * running instead of disappearing. + */ +async function movePreservingContent( + source: string, + destination: string +): Promise { + if (!(await pathExists(source))) return; + + await mkdir(dirname(destination), { recursive: true }); + + if (!(await pathExists(destination))) { + try { + await rename(source, destination); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error; + } + await cp(source, destination, { + recursive: true, + preserveTimestamps: true, + }); + await rm(source, { recursive: true, force: true }); + return; + } + + const [sourceStats, destinationStats] = await Promise.all([ + stat(source), + stat(destination), + ]); + if (!sourceStats.isDirectory() || !destinationStats.isDirectory()) { + // Something already occupies the destination and at least one side is a + // file, so there is no merge to perform. Checking only the source would + // let a directory recurse into a file destination and fail with ENOTDIR + // part-way through the migration, having already moved some entries. + return; + } + + for (const entry of await readdir(source)) { + await movePreservingContent(join(source, entry), join(destination, entry)); + } + + // Remove the legacy directory only when nothing was left behind. + try { + await rmdir(source); + } catch { + // Not empty (collisions kept at the source) — leave it for the user. + } +} + +/** Write `content` to `path` only when nothing is there yet. */ +async function writeIfAbsent(path: string, content: string): Promise { + if (await pathExists(path)) return; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, "utf8"); +} + +/** + * Anchor the legacy `sgconfig.yml` entry in `.taskless/.gitignore` to the + * directory root. + * + * Migration `0001` wrote the pattern unanchored, when the only `sgconfig.yml` + * was the ephemeral one generated directly in `.taskless/`. A gitignore pattern + * without a slash matches at **any** depth, so that same line would now also + * ignore the committed `.taskless/sg/sgconfig.yml` this layout makes the source + * of truth — the config would silently never be tracked. `/sgconfig.yml` still + * ignores the ephemeral file and nothing below it. + */ +async function anchorSgConfigIgnore(directory: string): Promise { + const gitignorePath = join(directory, ".gitignore"); + let existing: string; + try { + existing = await readFile(gitignorePath, "utf8"); + } catch { + return; // No .gitignore — 0001 writes one, nothing to fix. + } + + let changed = false; + let seenAnchored = false; + const rewritten: string[] = []; + for (const line of existing.split("\n")) { + const anchored = + line.trim() === "sgconfig.yml" ? "/sgconfig.yml" : line.trim(); + if (anchored === "/sgconfig.yml") { + // 0001 may already have appended the anchored form beside the legacy + // unanchored one; collapse them into a single entry. + if (seenAnchored) { + changed = true; + continue; + } + seenAnchored = true; + if (line.trim() !== "/sgconfig.yml") changed = true; + rewritten.push("/sgconfig.yml"); + continue; + } + rewritten.push(line); + } + if (!changed) return; + await writeFile(gitignorePath, rewritten.join("\n"), "utf8"); +} + +/** + * Refuse to start when a **file** sits where an engine directory belongs. + * + * Checked up front, before anything moves. Every one of these paths must end up + * a directory, and none of the steps below can merge a file into one: the move + * declines, then `mkdir` fails with a bare `EEXIST` — after earlier moves have + * already happened. Failing first keeps `.taskless/` in the state the user can + * still reason about, and says which path to deal with. + */ +async function assertNoDirectoryConflicts(directory: string): Promise { + const conflicts: string[] = []; + for (const segments of REQUIRED_DIRECTORIES) { + const path = join(directory, ...segments); + // `stat` throws ENOTDIR — not ENOENT — when an ancestor is a file, and + // treating that as "nothing there" is what let an occupied engine root + // through. Ancestors are checked before their children, so the root is + // reported and the children below it are skipped as unreachable rather + // than each producing a confusing second complaint. + let stats; + try { + stats = await stat(path); + } catch { + continue; + } + if (!stats.isDirectory()) conflicts.push(segments.join("/")); + } + if (conflicts.length === 0) return; + + throw new CLIError( + `Cannot partition .taskless/ by engine: ${conflicts + .map((path) => `.taskless/${path}`) + .join( + ", " + )} ${conflicts.length === 1 ? "is a file" : "are files"}, but ` + + `must be a directory. Move or delete it, then run the command again.`, + "SCAFFOLD_CONFLICT" + ); +} + +/** Create `path` and drop a `.gitkeep` in it when it would otherwise be empty. */ +async function ensureTrackedDirectory(path: string): Promise { + await mkdir(path, { recursive: true }); + const entries = await readdir(path); + if (entries.length === 0) { + await writeFile(join(path, ".gitkeep"), "", "utf8"); + } +} + +/** + * Migration 4 — partition rules into per-engine directories. + * + * `rules/`, `rule-tests/`, and `sgconfig.yml` move under `sg/`; the runtime + * tier moves to `runtime/rules/` and `runtime/rule-tests/`; `vale/` is + * scaffolded with its native config but stays inert (no engine reads it yet). + * + * The move edits no file contents. `sgconfig.yml`'s `ruleDirs: [rules]` is + * relative to the config file, so it stays valid after the move with no path + * rewriting, and runtime capture hashes are unchanged. + * + * Idempotent: re-running against an already-migrated `.taskless/` is a no-op + * beyond re-asserting the scaffold. + */ +const migration: Migration = async (directory) => { + await assertNoDirectoryConflicts(directory); + + for (const [from, to] of MOVES) { + await movePreservingContent( + join(directory, ...from), + join(directory, ...to) + ); + } + + await writeIfAbsent(join(directory, "sg", "sgconfig.yml"), SG_CONFIG_CONTENT); + await writeIfAbsent( + join(directory, "vale", ".vale.ini"), + VALE_CONFIG_CONTENT + ); + + for (const segments of SCAFFOLD_DIRECTORIES) { + await ensureTrackedDirectory(join(directory, ...segments)); + } + + await anchorSgConfigIgnore(directory); +}; + +export default migration; diff --git a/packages/cli/src/filesystem/sgconfig.ts b/packages/cli/src/filesystem/sgconfig.ts index 9849a319..0dfc07e2 100644 --- a/packages/cli/src/filesystem/sgconfig.ts +++ b/packages/cli/src/filesystem/sgconfig.ts @@ -2,17 +2,18 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { ensureTasklessDirectory } from "./directory"; +import { SG_RULES_DIRECTORY, SG_RULE_TESTS_DIRECTORY } from "./layout"; /** Build sgconfig contents pointing `ruleDirs` at the given directory. */ function sgConfigContent(rulesDirectory: string): string { - return `ruleDirs:\n - ${rulesDirectory}\ntestConfigs:\n - testDir: rule-tests\n`; + return `ruleDirs:\n - ${rulesDirectory}\ntestConfigs:\n - testDir: ${SG_RULE_TESTS_DIRECTORY}\n`; } export interface SgConfigOptions { /** * Directory (relative to `.taskless/`) that ast-grep should load rules from. - * Defaults to `rules`. Reconciliation points this at the ephemeral run - * directory so only the server-blessed run set is evaluated. + * Defaults to the `sg` engine directory. Reconciliation points this at the + * ephemeral run directory so only the server-blessed run set is evaluated. */ rulesDirectory?: string; } @@ -28,7 +29,7 @@ export async function generateSgConfig( await ensureTasklessDirectory(cwd); await writeFile( join(cwd, ".taskless", "sgconfig.yml"), - sgConfigContent(options.rulesDirectory ?? "rules"), + sgConfigContent(options.rulesDirectory ?? SG_RULES_DIRECTORY), "utf8" ); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 418eedbc..f385117a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -46,6 +46,12 @@ const main = defineCommand({ description: "Output as JSON", default: false, }, + "allow-version-mismatches": { + type: "boolean", + description: + "Proceed when .taskless/ is newer than this CLI understands (skips migrations)", + default: false, + }, }, subCommands: { ...subCommands, diff --git a/packages/cli/src/rules/files.ts b/packages/cli/src/rules/files.ts index a787dd3c..aecafcc4 100644 --- a/packages/cli/src/rules/files.ts +++ b/packages/cli/src/rules/files.ts @@ -6,6 +6,10 @@ import { parse, stringify } from "yaml"; import { ensureTasklessDirectory } from "../filesystem/directory"; import type { GeneratedRule, RuleMetadata } from "../api/rules"; import { isValidRuleId } from "./validate-id"; +import { + SG_RULES_DIRECTORY, + SG_RULE_TESTS_DIRECTORY, +} from "../filesystem/layout"; /** Write a generated rule's content to .taskless/rules/{kebab-id}.yml */ export async function writeRuleFile( @@ -16,7 +20,7 @@ export async function writeRuleFile( throw new Error(`Invalid rule ID "${rule.id}"`); } await ensureTasklessDirectory(cwd); - const directory = join(cwd, ".taskless", "rules"); + const directory = join(cwd, ".taskless", SG_RULES_DIRECTORY); const filePath = join(directory, `${rule.id}.yml`); await writeFile(filePath, stringify(rule.content, { lineWidth: 0 }), "utf8"); return filePath; @@ -32,7 +36,7 @@ export async function writeRuleTestFile( throw new Error(`Invalid rule ID "${rule.id}"`); } await ensureTasklessDirectory(cwd); - const directory = join(cwd, ".taskless", "rule-tests"); + const directory = join(cwd, ".taskless", SG_RULE_TESTS_DIRECTORY); const filePath = join(directory, `${rule.id}-${timestamp}-test.yml`); const content = { id: rule.id, @@ -96,7 +100,7 @@ export async function deleteRuleFiles( if (!isValidRuleId(id)) { return false; } - const rulesDirectory = join(cwd, ".taskless", "rules"); + const rulesDirectory = join(cwd, ".taskless", SG_RULES_DIRECTORY); const ruleFilePath = join(rulesDirectory, `${id}.yml`); let ruleExisted = false; @@ -108,7 +112,7 @@ export async function deleteRuleFiles( } // Remove matching test files - const testDirectory = join(cwd, ".taskless", "rule-tests"); + const testDirectory = join(cwd, ".taskless", SG_RULE_TESTS_DIRECTORY); try { const entries = await readdir(testDirectory); const matchingTests = entries.filter( diff --git a/packages/cli/src/rules/runtime/discover.ts b/packages/cli/src/rules/runtime/discover.ts index 65c516e8..a5c091a3 100644 --- a/packages/cli/src/rules/runtime/discover.ts +++ b/packages/cli/src/rules/runtime/discover.ts @@ -4,9 +4,10 @@ import { join } from "node:path"; import { parse } from "yaml"; import type { CaptureRule, MatchMode } from "../../types/runtime-rule"; +import { RUNTIME_RULES_DIRECTORY } from "../../filesystem/layout"; /** Directory (relative to `.taskless/`) that holds runtime rules. */ -export const RUNTIME_RULES_DIR = "runtime-rules"; +export const RUNTIME_RULES_DIR = RUNTIME_RULES_DIRECTORY; /** A parsed capture `*.yml` of a runtime rule, with the fields the harness needs. */ export interface LoadedCaptureRule { diff --git a/packages/cli/src/rules/verify.ts b/packages/cli/src/rules/verify.ts index 6ece1707..b35cc441 100644 --- a/packages/cli/src/rules/verify.ts +++ b/packages/cli/src/rules/verify.ts @@ -14,6 +14,10 @@ import { findSgBinary, buildPath } from "./scan"; import astGrepJsonSchema from "../generated/ast-grep-rule-schema.json"; import { RULE_EXAMPLES } from "./verify-examples"; import { isValidRuleId } from "./validate-id"; +import { + SG_RULES_DIRECTORY, + SG_RULE_TESTS_DIRECTORY, +} from "../filesystem/layout"; // --- Helpers --- @@ -130,7 +134,7 @@ async function validateRequirements( } // Check test file exists - const testDirectory = join(cwd, ".taskless", "rule-tests"); + const testDirectory = join(cwd, ".taskless", SG_RULE_TESTS_DIRECTORY); let hasTestFile = false; try { const entries = await readdir(testDirectory); @@ -243,7 +247,7 @@ export async function verifyRule( }; } - const rulePath = join(cwd, ".taskless", "rules", `${ruleId}.yml`); + const rulePath = join(cwd, ".taskless", SG_RULES_DIRECTORY, `${ruleId}.yml`); let ruleContent: string; try { diff --git a/packages/cli/src/types/errors.ts b/packages/cli/src/types/errors.ts index 1d788087..9711e6df 100644 --- a/packages/cli/src/types/errors.ts +++ b/packages/cli/src/types/errors.ts @@ -16,6 +16,8 @@ export type CLIErrorCode = | "NETWORK_ERROR" | "SCAN_FAILED" | "RECONCILE_FAILED" + | "SCAFFOLD_VERSION_MISMATCH" + | "SCAFFOLD_CONFLICT" | "INTERNAL_ERROR"; /** diff --git a/packages/cli/test/bootstrap.test.ts b/packages/cli/test/bootstrap.test.ts index edab9fc5..6be57689 100644 --- a/packages/cli/test/bootstrap.test.ts +++ b/packages/cli/test/bootstrap.test.ts @@ -50,10 +50,10 @@ describe("ensureTasklessDirectory", () => { expect(gitignore).toContain(".env.local.json"); expect(gitignore).toContain("sgconfig.yml"); - // Subdirectories exist - const rulesStat = await stat(join(tasklessDirectory, "rules")); + // Engine-partitioned subdirectories exist (migration 0004) + const rulesStat = await stat(join(tasklessDirectory, "sg", "rules")); expect(rulesStat.isDirectory()).toBe(true); - const testsStat = await stat(join(tasklessDirectory, "rule-tests")); + const testsStat = await stat(join(tasklessDirectory, "sg", "rule-tests")); expect(testsStat.isDirectory()).toBe(true); }); @@ -108,7 +108,7 @@ describe("ensureTasklessDirectory", () => { // Files from 001-init should exist const readmeStat = await stat(join(tasklessDirectory, "README.md")); expect(readmeStat.isFile()).toBe(true); - const rulesStat = await stat(join(tasklessDirectory, "rules")); + const rulesStat = await stat(join(tasklessDirectory, "sg", "rules")); expect(rulesStat.isDirectory()).toBe(true); }); @@ -228,7 +228,7 @@ describe("v0 → v1 migration", () => { await ensureTasklessDirectory(temporaryDirectory); const ruleContent = await readFile( - join(temporaryDirectory, ".taskless", "rules", "no-as-any.yml"), + join(temporaryDirectory, ".taskless", "sg", "rules", "no-as-any.yml"), "utf8" ); expect(ruleContent).toContain("no-as-any"); @@ -242,6 +242,7 @@ describe("v0 → v1 migration", () => { join( temporaryDirectory, ".taskless", + "sg", "rule-tests", "no-as-any-20260326-test.yml" ), @@ -256,7 +257,7 @@ describe("v0 → v1 migration", () => { await ensureTasklessDirectory(temporaryDirectory); const keepStat = await stat( - join(temporaryDirectory, ".taskless", "rules", ".gitkeep") + join(temporaryDirectory, ".taskless", "sg", "rules", ".gitkeep") ); expect(keepStat.isFile()).toBe(true); }); diff --git a/packages/cli/test/check.test.ts b/packages/cli/test/check.test.ts index d6bd81e7..dd621d7e 100644 --- a/packages/cli/test/check.test.ts +++ b/packages/cli/test/check.test.ts @@ -55,7 +55,7 @@ describe("check", () => { }); it("exits 0 with friendly message when rules directory is empty", async () => { - await mkdir(join(temporaryDirectory, ".taskless", "rules"), { + await mkdir(join(temporaryDirectory, ".taskless", "sg", "rules"), { recursive: true, }); @@ -124,11 +124,11 @@ describe("check", () => { it("exits 0 for warnings-only, exits 1 for errors", async () => { // Create a project with only a warning-level rule - await mkdir(join(temporaryDirectory, ".taskless", "rules"), { + await mkdir(join(temporaryDirectory, ".taskless", "sg", "rules"), { recursive: true, }); await writeFile( - join(temporaryDirectory, ".taskless", "rules", "warn-only.yml"), + join(temporaryDirectory, ".taskless", "sg", "rules", "warn-only.yml"), [ "id: no-console-warn", "language: javascript", diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 924d44bf..f8522df0 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -226,11 +226,11 @@ describe("taskless detect", () => { }); it("surfaces the repo's own Taskless rule styles", async () => { - await mkdir(join(cwd, ".taskless", "rules"), { recursive: true }); + await mkdir(join(cwd, ".taskless", "sg", "rules"), { recursive: true }); const result = await detect(cwd); - expect(result.ruleStyles.some((s) => s.source === ".taskless/rules")).toBe( - true - ); + expect( + result.ruleStyles.some((s) => s.source === ".taskless/sg/rules") + ).toBe(true); }); it("emits a stable JSON shape with only signal keys (no packaged-rule claims)", async () => { diff --git a/packages/cli/test/error-envelope.test.ts b/packages/cli/test/error-envelope.test.ts index 488eed06..2cc8cf50 100644 --- a/packages/cli/test/error-envelope.test.ts +++ b/packages/cli/test/error-envelope.test.ts @@ -176,7 +176,7 @@ describe("standardized error envelope (--json)", () => { }); it("is silent on stdout when a real rule is deleted in --json mode", async () => { - const rulesDirectory = join(cwd, ".taskless", "rules"); + const rulesDirectory = join(cwd, ".taskless", "sg", "rules"); await mkdir(rulesDirectory, { recursive: true }); await writeFile( join(rulesDirectory, "doomed.yml"), diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 12fd4602..afd044b8 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -139,7 +139,7 @@ describe("taskless init --no-interactive", () => { await readFile(join(cwd, ".taskless", "taskless.json"), "utf8") ) as { version: number; install: Record }; - expect(manifest.version).toBe(3); + expect(manifest.version).toBe(4); expect(manifest.install).toBeDefined(); }); diff --git a/packages/cli/test/migrate-engine-layout.test.ts b/packages/cli/test/migrate-engine-layout.test.ts new file mode 100644 index 00000000..7099d799 --- /dev/null +++ b/packages/cli/test/migrate-engine-layout.test.ts @@ -0,0 +1,482 @@ +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ensureTasklessDirectory } from "../src/filesystem/directory"; +import { runMigrations } from "../src/filesystem/migrate"; +import { CLIError } from "../src/util/cli-error"; + +/** Bytes of a runtime capture rule; its hash must survive the move. */ +const CAPTURE_YML = `id: no-eval-capture\nlanguage: typescript\nrule:\n pattern: eval($$$ARGS)\n`; +const CHECK_TS = `export function check() {\n return { ok: true };\n}\n`; + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function sha256(path: string): Promise { + return createHash("sha256") + .update(await readFile(path)) + .digest("hex"); +} + +async function writeTree( + root: string, + files: Record +): Promise { + for (const [relative, content] of Object.entries(files)) { + const target = join(root, relative); + await mkdir(join(target, ".."), { recursive: true }); + await writeFile(target, content, "utf8"); + } +} + +describe("migration 0004 — engine-partitioned layout", () => { + let temporaryDirectory: string; + let tasklessDirectory: string; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "taskless-0004-")); + tasklessDirectory = join(temporaryDirectory, ".taskless"); + await mkdir(tasklessDirectory, { recursive: true }); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + /** Seed a pre-0004 `.taskless/` at version 3 with rules and a runtime tier. */ + async function seedLegacyLayout(): Promise { + await writeFile( + join(tasklessDirectory, "taskless.json"), + JSON.stringify({ version: 3, install: {} }), + "utf8" + ); + await writeTree(tasklessDirectory, { + "sgconfig.yml": + "ruleDirs:\n - rules\ntestConfigs:\n - testDir: rule-tests\n", + "rules/no-eval.yml": + "id: no-eval\nlanguage: typescript\nrule:\n pattern: eval($A)\n", + "rule-tests/no-eval-test.yml": "id: no-eval\nvalid:\n - foo()\n", + "runtime-rules/no-eval-runtime/capture.yml": CAPTURE_YML, + "runtime-rules/no-eval-runtime/check.ts": CHECK_TS, + "runtime-rule-tests/no-eval-runtime/fixture-a/input.ts": "eval('x');\n", + }); + } + + it("moves the ast-grep tree under sg/ without editing contents", async () => { + await seedLegacyLayout(); + const beforeRule = await sha256( + join(tasklessDirectory, "rules", "no-eval.yml") + ); + const beforeConfig = await sha256(join(tasklessDirectory, "sgconfig.yml")); + + await ensureTasklessDirectory(temporaryDirectory); + + expect( + await sha256(join(tasklessDirectory, "sg", "rules", "no-eval.yml")) + ).toBe(beforeRule); + expect(await sha256(join(tasklessDirectory, "sg", "sgconfig.yml"))).toBe( + beforeConfig + ); + expect( + await exists( + join(tasklessDirectory, "sg", "rule-tests", "no-eval-test.yml") + ) + ).toBe(true); + + // Legacy locations are gone once fully moved. + expect(await exists(join(tasklessDirectory, "rules"))).toBe(false); + expect(await exists(join(tasklessDirectory, "rule-tests"))).toBe(false); + expect(await exists(join(tasklessDirectory, "sgconfig.yml"))).toBe(false); + }); + + it("moves the runtime tier byte-for-byte", async () => { + await seedLegacyLayout(); + const before = { + capture: await sha256( + join( + tasklessDirectory, + "runtime-rules", + "no-eval-runtime", + "capture.yml" + ) + ), + check: await sha256( + join(tasklessDirectory, "runtime-rules", "no-eval-runtime", "check.ts") + ), + fixture: await sha256( + join( + tasklessDirectory, + "runtime-rule-tests", + "no-eval-runtime", + "fixture-a", + "input.ts" + ) + ), + }; + + await ensureTasklessDirectory(temporaryDirectory); + + expect( + await sha256( + join( + tasklessDirectory, + "runtime", + "rules", + "no-eval-runtime", + "capture.yml" + ) + ) + ).toBe(before.capture); + expect( + await sha256( + join( + tasklessDirectory, + "runtime", + "rules", + "no-eval-runtime", + "check.ts" + ) + ) + ).toBe(before.check); + expect( + await sha256( + join( + tasklessDirectory, + "runtime", + "rule-tests", + "no-eval-runtime", + "fixture-a", + "input.ts" + ) + ) + ).toBe(before.fixture); + + // Contents, not just hashes: the capture bytes are what the server signs. + expect( + await readFile( + join( + tasklessDirectory, + "runtime", + "rules", + "no-eval-runtime", + "capture.yml" + ), + "utf8" + ) + ).toBe(CAPTURE_YML); + + expect(await exists(join(tasklessDirectory, "runtime-rules"))).toBe(false); + expect(await exists(join(tasklessDirectory, "runtime-rule-tests"))).toBe( + false + ); + }); + + it("anchors the sgconfig.yml gitignore pattern so the committed sg config is tracked", async () => { + await seedLegacyLayout(); + // Start at version 0 so 0001 runs too: it appends the anchored form, and + // 0004 must collapse that with the legacy unanchored line rather than + // leaving both. + await writeFile( + join(tasklessDirectory, "taskless.json"), + JSON.stringify({ version: 0 }), + "utf8" + ); + // The pattern 0001 used to write: unanchored, so it matches at any depth + // and would swallow `.taskless/sg/sgconfig.yml`. + await writeFile( + join(tasklessDirectory, ".gitignore"), + ".env.local.json\nsgconfig.yml\n", + "utf8" + ); + + await ensureTasklessDirectory(temporaryDirectory); + + const gitignore = await readFile( + join(tasklessDirectory, ".gitignore"), + "utf8" + ); + const entries = gitignore + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + expect(entries).toContain("/sgconfig.yml"); + expect(entries).not.toContain("sgconfig.yml"); + expect(entries).toContain(".env.local.json"); + // Exactly one entry, even though 0001 also appends the anchored form. + expect(entries.filter((entry) => entry === "/sgconfig.yml")).toHaveLength( + 1 + ); + }); + + it("leaves an already-anchored gitignore untouched", async () => { + await seedLegacyLayout(); + const original = ".env.local.json\n/sgconfig.yml\n.run/\n"; + await writeFile(join(tasklessDirectory, ".gitignore"), original, "utf8"); + + await ensureTasklessDirectory(temporaryDirectory); + + expect(await readFile(join(tasklessDirectory, ".gitignore"), "utf8")).toBe( + original + ); + }); + + it("scaffolds vale/ and gitkeeps every otherwise-empty directory", async () => { + await seedLegacyLayout(); + + await ensureTasklessDirectory(temporaryDirectory); + + expect(await exists(join(tasklessDirectory, "vale", ".vale.ini"))).toBe( + true + ); + for (const relative of [ + ["vale", "rules"], + ["vale", "rule-tests"], + ]) { + expect( + await exists(join(tasklessDirectory, ...relative, ".gitkeep")) + ).toBe(true); + } + + // Directories that received real content are not gitkeeped. + expect( + await exists(join(tasklessDirectory, "sg", "rules", ".gitkeep")) + ).toBe(false); + expect( + await exists(join(tasklessDirectory, "runtime", "rules", ".gitkeep")) + ).toBe(false); + }); + + it("scaffolds a full layout for a fresh project and bumps the version to 4", async () => { + await ensureTasklessDirectory(temporaryDirectory); + + const manifest = JSON.parse( + await readFile(join(tasklessDirectory, "taskless.json"), "utf8") + ) as { version: number }; + expect(manifest.version).toBe(4); + + for (const relative of [ + ["sg", "rules"], + ["sg", "rule-tests"], + ["vale", "rules"], + ["vale", "rule-tests"], + ["runtime", "rules"], + ["runtime", "rule-tests"], + ]) { + expect( + await exists(join(tasklessDirectory, ...relative, ".gitkeep")) + ).toBe(true); + } + expect(await exists(join(tasklessDirectory, "sg", "sgconfig.yml"))).toBe( + true + ); + + // The ignore pattern is anchored from the start for a fresh scaffold. + const gitignore = await readFile( + join(tasklessDirectory, ".gitignore"), + "utf8" + ); + expect(gitignore).toContain("/sgconfig.yml"); + expect( + gitignore + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + ).not.toContain("sgconfig.yml"); + }); + + it("is idempotent — a second run changes nothing", async () => { + await seedLegacyLayout(); + await ensureTasklessDirectory(temporaryDirectory); + const after = await sha256( + join(tasklessDirectory, "sg", "rules", "no-eval.yml") + ); + + // Re-run the migration directly (runMigrations would short-circuit on version). + const { default: migration } = + await import("../src/filesystem/migrations/0004-vale-engine"); + await migration(tasklessDirectory); + + expect( + await sha256(join(tasklessDirectory, "sg", "rules", "no-eval.yml")) + ).toBe(after); + expect(await exists(join(tasklessDirectory, "rules"))).toBe(false); + }); + + it("preserves an existing sg/sgconfig.yml rather than overwriting it", async () => { + await writeFile( + join(tasklessDirectory, "taskless.json"), + JSON.stringify({ version: 3 }), + "utf8" + ); + const custom = "ruleDirs:\n - rules\n# hand-edited\n"; + await writeTree(tasklessDirectory, { "sg/sgconfig.yml": custom }); + + await ensureTasklessDirectory(temporaryDirectory); + + expect( + await readFile(join(tasklessDirectory, "sg", "sgconfig.yml"), "utf8") + ).toBe(custom); + }); + + it("refuses to start when a file occupies an engine directory", async () => { + await writeFile( + join(tasklessDirectory, "taskless.json"), + JSON.stringify({ version: 3 }), + "utf8" + ); + // A *file* sits where `rules/` would land. Nothing can merge the two, so + // the migration must fail before it moves anything — a partial move would + // leave `.taskless/` split across both layouts. + await writeTree(tasklessDirectory, { + "rules/no-eval.yml": CAPTURE_YML, + "runtime-rules/no-eval-capture/rule.yml": CAPTURE_YML, + "sg/rules": "not a directory\n", + }); + + await expect(ensureTasklessDirectory(temporaryDirectory)).rejects.toThrow( + /\.taskless\/sg\/rules is a file/ + ); + + // Everything is exactly where it was: no half-migration. + expect(await sha256(join(tasklessDirectory, "rules", "no-eval.yml"))).toBe( + createHash("sha256").update(CAPTURE_YML).digest("hex") + ); + expect( + await exists(join(tasklessDirectory, "runtime-rules", "no-eval-capture")) + ).toBe(true); + expect(await exists(join(tasklessDirectory, "runtime", "rules"))).toBe( + false + ); + expect(await readFile(join(tasklessDirectory, "sg", "rules"), "utf8")).toBe( + "not a directory\n" + ); + }); +}); + +describe("scaffold version gating", () => { + let temporaryDirectory: string; + let tasklessDirectory: string; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp( + join(tmpdir(), "taskless-version-gate-") + ); + tasklessDirectory = join(temporaryDirectory, ".taskless"); + await mkdir(tasklessDirectory, { recursive: true }); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + async function seedFutureManifest(): Promise { + await writeFile( + join(tasklessDirectory, "taskless.json"), + JSON.stringify({ version: 99 }), + "utf8" + ); + } + + it("throws when the scaffold is newer than the CLI knows", async () => { + await seedFutureManifest(); + + await expect(runMigrations(tasklessDirectory)).rejects.toThrow(CLIError); + await expect(runMigrations(tasklessDirectory)).rejects.toThrow( + /Upgrade the CLI/i + ); + }); + + it("proceeds without migrating when the override is set", async () => { + await seedFutureManifest(); + + await expect( + runMigrations(tasklessDirectory, { allowVersionMismatches: true }) + ).resolves.toBeUndefined(); + + // Nothing was migrated and the version was left alone. + const manifest = JSON.parse( + await readFile(join(tasklessDirectory, "taskless.json"), "utf8") + ) as { version: number }; + expect(manifest.version).toBe(99); + expect(await exists(join(tasklessDirectory, "sg"))).toBe(false); + }); + + it("honours --allow-version-mismatches from argv", async () => { + await seedFutureManifest(); + const originalArgv = process.argv; + process.argv = [ + ...originalArgv.slice(0, 2), + "check", + "--allow-version-mismatches", + ]; + try { + await expect( + ensureTasklessDirectory(temporaryDirectory) + ).resolves.toBeUndefined(); + } finally { + process.argv = originalArgv; + } + }); + + it("does not throw when the scaffold matches the CLI's max version", async () => { + await ensureTasklessDirectory(temporaryDirectory); + await expect( + ensureTasklessDirectory(temporaryDirectory) + ).resolves.toBeUndefined(); + }); +}); + +describe("migration 0004 — engine root occupied by a file", () => { + let temporaryDirectory: string; + let tasklessDirectory: string; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "taskless-0004-root-")); + tasklessDirectory = join(temporaryDirectory, ".taskless"); + await mkdir(tasklessDirectory, { recursive: true }); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + it("refuses when an engine root itself is a file", async () => { + await writeFile( + join(tasklessDirectory, "taskless.json"), + JSON.stringify({ version: 3 }), + "utf8" + ); + await writeTree(tasklessDirectory, { + "rules/no-eval.yml": CAPTURE_YML, + "runtime-rules/demo/capture.yml": CAPTURE_YML, + }); + // `.taskless/runtime` is a FILE, so `runtime/rules` cannot be created. + await writeFile(join(tasklessDirectory, "runtime"), "not a dir\n", "utf8"); + + await expect(ensureTasklessDirectory(temporaryDirectory)).rejects.toThrow( + /\.taskless\/runtime is a file/ + ); + + // Nothing moved: the sg tree is still where it started. + expect(await exists(join(tasklessDirectory, "rules", "no-eval.yml"))).toBe( + true + ); + expect(await exists(join(tasklessDirectory, "sg", "rules"))).toBe(false); + }); +}); diff --git a/packages/cli/test/migrate-install.test.ts b/packages/cli/test/migrate-install.test.ts index 91354103..f70cc6be 100644 --- a/packages/cli/test/migrate-install.test.ts +++ b/packages/cli/test/migrate-install.test.ts @@ -19,7 +19,7 @@ describe("install-state migrations", () => { await rm(temporaryDirectory, { recursive: true, force: true }); }); - it("fresh project reaches { version: 3, install: {} }", async () => { + it("fresh project reaches { version: 4, install: {} }", async () => { await ensureTasklessDirectory(temporaryDirectory); const manifest = JSON.parse( @@ -29,7 +29,7 @@ describe("install-state migrations", () => { ) ) as { version: number; install: Record }; - expect(manifest.version).toBe(3); + expect(manifest.version).toBe(4); expect(manifest.install).toEqual({}); }); @@ -48,7 +48,7 @@ describe("install-state migrations", () => { await readFile(join(tasklessDirectory, "taskless.json"), "utf8") ) as { version: number; install: Record }; - expect(manifest.version).toBe(3); + expect(manifest.version).toBe(4); expect(manifest.install).toEqual({}); }); @@ -76,7 +76,7 @@ describe("install-state migrations", () => { ) as { version: number; install: Record }; // Migration 3 strips the unused timestamp; everything else survives. - expect(manifest.version).toBe(3); + expect(manifest.version).toBe(4); expect(manifest.install).toEqual({ cliVersion: "0.5.4", targets: { ".claude": { skills: ["taskless-check"] } }, @@ -102,7 +102,7 @@ describe("install-state migrations", () => { await readFile(join(tasklessDirectory, "taskless.json"), "utf8") ) as Record; - expect(manifest.version).toBe(3); + expect(manifest.version).toBe(4); expect(manifest.install).toEqual({}); expect(manifest.experimental).toEqual({ flag: true, @@ -124,7 +124,7 @@ describe("install-state migrations", () => { await readFile(join(tasklessDirectory, "taskless.json"), "utf8") ) as { version: number; install: Record }; - expect(manifest.version).toBe(3); + expect(manifest.version).toBe(4); expect(manifest.install).toEqual({}); }); @@ -189,7 +189,7 @@ describe("migration version matrix", () => { // Seed .taskless/ at every prior schema version and confirm each // forward-migrates cleanly to the latest. Catches a future migration that // forgets to handle an older starting point. - for (const startVersion of [0, 1, 2, 3]) { + for (const startVersion of [0, 1, 2, 3, 4]) { it(`forward-migrates a v${String(startVersion)} project to the latest schema`, async () => { const latest = await latestSchemaVersion(); const tasklessDirectory = join(temporaryDirectory, ".taskless"); diff --git a/packages/cli/test/onboard.test.ts b/packages/cli/test/onboard.test.ts index faa4a607..298547da 100644 --- a/packages/cli/test/onboard.test.ts +++ b/packages/cli/test/onboard.test.ts @@ -57,7 +57,7 @@ describe("taskless onboard", () => { expect(stdout).toContain("## Goal"); const manifest = await readJsonManifest(cwd); - expect(manifest.version).toBe(3); + expect(manifest.version).toBe(4); // init/onboard alone should not record onboarded const install = manifest.install as { onboarded?: boolean } | undefined; expect(install?.onboarded).toBeUndefined(); diff --git a/packages/cli/test/runtime-check.test.ts b/packages/cli/test/runtime-check.test.ts index dfe2aa4e..0e7e3f87 100644 --- a/packages/cli/test/runtime-check.test.ts +++ b/packages/cli/test/runtime-check.test.ts @@ -140,8 +140,8 @@ describe("check: static vs runtime dispatch", () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), "tskl-rt-check-")); - const rules = join(directory, ".taskless", "rules"); - const runtime = join(directory, ".taskless", "runtime-rules", "demo"); + const rules = join(directory, ".taskless", "sg", "rules"); + const runtime = join(directory, ".taskless", "runtime", "rules", "demo"); await mkdir(rules, { recursive: true }); await mkdir(runtime, { recursive: true }); await writeFile(join(rules, "no-console.yml"), STATIC_RULE, "utf8"); diff --git a/packages/cli/test/runtime-harness.test.ts b/packages/cli/test/runtime-harness.test.ts index 5568fef2..a4813048 100644 --- a/packages/cli/test/runtime-harness.test.ts +++ b/packages/cli/test/runtime-harness.test.ts @@ -38,7 +38,7 @@ async function writeRuntimeRule( captures: Record, check: string ): Promise { - const directory = join(root, ".taskless", "runtime-rules", name); + const directory = join(root, ".taskless", "runtime", "rules", name); await mkdir(directory, { recursive: true }); for (const [file, body] of Object.entries(captures)) { await writeFile(join(directory, file), body, "utf8"); diff --git a/packages/cli/test/sgconfig.test.ts b/packages/cli/test/sgconfig.test.ts index 35213f72..330372b4 100644 --- a/packages/cli/test/sgconfig.test.ts +++ b/packages/cli/test/sgconfig.test.ts @@ -25,9 +25,9 @@ describe("generateSgConfig", () => { "utf8" ); expect(content).toContain("ruleDirs:"); - expect(content).toContain("- rules"); + expect(content).toContain("- sg/rules"); expect(content).toContain("testConfigs:"); - expect(content).toContain("rule-tests"); + expect(content).toContain("sg/rule-tests"); }); it("creates .taskless/.gitignore with required entries", async () => { diff --git a/packages/cli/test/verify.test.ts b/packages/cli/test/verify.test.ts index fcddff85..a8c7fd29 100644 --- a/packages/cli/test/verify.test.ts +++ b/packages/cli/test/verify.test.ts @@ -19,8 +19,13 @@ describe("verifyRule", () => { it("passes all layers for a valid rule with tests", async () => { // Write a valid rule - const rulesDirectory = join(temporaryDirectory, ".taskless", "rules"); - const testsDirectory = join(temporaryDirectory, ".taskless", "rule-tests"); + const rulesDirectory = join(temporaryDirectory, ".taskless", "sg", "rules"); + const testsDirectory = join( + temporaryDirectory, + ".taskless", + "sg", + "rule-tests" + ); await mkdir(rulesDirectory, { recursive: true }); await mkdir(testsDirectory, { recursive: true }); @@ -60,8 +65,13 @@ describe("verifyRule", () => { }); it("isolates test results to the specified rule only", async () => { - const rulesDirectory = join(temporaryDirectory, ".taskless", "rules"); - const testsDirectory = join(temporaryDirectory, ".taskless", "rule-tests"); + const rulesDirectory = join(temporaryDirectory, ".taskless", "sg", "rules"); + const testsDirectory = join( + temporaryDirectory, + ".taskless", + "sg", + "rule-tests" + ); await mkdir(rulesDirectory, { recursive: true }); await mkdir(testsDirectory, { recursive: true }); @@ -124,7 +134,7 @@ describe("verifyRule", () => { }); it("reports schema errors for invalid rule structure", async () => { - const rulesDirectory = join(temporaryDirectory, ".taskless", "rules"); + const rulesDirectory = join(temporaryDirectory, ".taskless", "sg", "rules"); await mkdir(rulesDirectory, { recursive: true }); // Rule with missing required 'rule' field (required by ast-grep schema) @@ -147,7 +157,7 @@ describe("verifyRule", () => { }); it("reports missing test file", async () => { - const rulesDirectory = join(temporaryDirectory, ".taskless", "rules"); + const rulesDirectory = join(temporaryDirectory, ".taskless", "sg", "rules"); await mkdir(rulesDirectory, { recursive: true }); await writeFile( @@ -215,7 +225,7 @@ describe("verifyRule", () => { }); it("reports error for invalid YAML in rule file", async () => { - const rulesDirectory = join(temporaryDirectory, ".taskless", "rules"); + const rulesDirectory = join(temporaryDirectory, ".taskless", "sg", "rules"); await mkdir(rulesDirectory, { recursive: true }); await writeFile( @@ -240,8 +250,13 @@ describe("verifyRule", () => { }); it("reports regex-without-kind violation", async () => { - const rulesDirectory = join(temporaryDirectory, ".taskless", "rules"); - const testsDirectory = join(temporaryDirectory, ".taskless", "rule-tests"); + const rulesDirectory = join(temporaryDirectory, ".taskless", "sg", "rules"); + const testsDirectory = join( + temporaryDirectory, + ".taskless", + "sg", + "rule-tests" + ); await mkdir(rulesDirectory, { recursive: true }); await mkdir(testsDirectory, { recursive: true }); @@ -279,7 +294,7 @@ describe("verifyRule", () => { }); it("reports missing required Taskless fields", async () => { - const rulesDirectory = join(temporaryDirectory, ".taskless", "rules"); + const rulesDirectory = join(temporaryDirectory, ".taskless", "sg", "rules"); await mkdir(rulesDirectory, { recursive: true }); // Rule missing severity and message (Taskless requires them)