diff --git a/.changeset/engine-partitioned-layout.md b/.changeset/engine-partitioned-layout.md index 4dde3696..597179b8 100644 --- a/.changeset/engine-partitioned-layout.md +++ b/.changeset/engine-partitioned-layout.md @@ -10,4 +10,6 @@ A rule engine the CLI does not recognize is now rejected with a message instead 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. +`check` and `rule verify` read the committed `.taskless/sg/sgconfig.yml` rather than writing an ephemeral config on every run, so the config ast-grep uses is the one you can edit and review. A pre-migration rule set still gets a generated config, so an unmigrated project keeps running. + 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/archive/2026-08-02-partition-rules-by-engine/.openspec.yaml similarity index 100% rename from openspec/changes/partition-rules-by-engine/.openspec.yaml rename to openspec/changes/archive/2026-08-02-partition-rules-by-engine/.openspec.yaml diff --git a/openspec/changes/partition-rules-by-engine/design.md b/openspec/changes/archive/2026-08-02-partition-rules-by-engine/design.md similarity index 100% rename from openspec/changes/partition-rules-by-engine/design.md rename to openspec/changes/archive/2026-08-02-partition-rules-by-engine/design.md diff --git a/openspec/changes/partition-rules-by-engine/proposal.md b/openspec/changes/archive/2026-08-02-partition-rules-by-engine/proposal.md similarity index 100% rename from openspec/changes/partition-rules-by-engine/proposal.md rename to openspec/changes/archive/2026-08-02-partition-rules-by-engine/proposal.md diff --git a/openspec/changes/partition-rules-by-engine/specs/cli-check/spec.md b/openspec/changes/archive/2026-08-02-partition-rules-by-engine/specs/cli-check/spec.md similarity index 99% rename from openspec/changes/partition-rules-by-engine/specs/cli-check/spec.md rename to openspec/changes/archive/2026-08-02-partition-rules-by-engine/specs/cli-check/spec.md index cc8bf7a4..886392d1 100644 --- a/openspec/changes/partition-rules-by-engine/specs/cli-check/spec.md +++ b/openspec/changes/archive/2026-08-02-partition-rules-by-engine/specs/cli-check/spec.md @@ -1,4 +1,4 @@ -## ADDED Requirements +## MODIFIED Requirements ### Requirement: Check subcommand executes ast-grep scan diff --git a/openspec/changes/partition-rules-by-engine/specs/cli-rule-format/spec.md b/openspec/changes/archive/2026-08-02-partition-rules-by-engine/specs/cli-rule-format/spec.md similarity index 100% rename from openspec/changes/partition-rules-by-engine/specs/cli-rule-format/spec.md rename to openspec/changes/archive/2026-08-02-partition-rules-by-engine/specs/cli-rule-format/spec.md diff --git a/openspec/changes/partition-rules-by-engine/specs/cli-runtime-rule-execution/spec.md b/openspec/changes/archive/2026-08-02-partition-rules-by-engine/specs/cli-runtime-rule-execution/spec.md similarity index 100% rename from openspec/changes/partition-rules-by-engine/specs/cli-runtime-rule-execution/spec.md rename to openspec/changes/archive/2026-08-02-partition-rules-by-engine/specs/cli-runtime-rule-execution/spec.md diff --git a/openspec/changes/partition-rules-by-engine/tasks.md b/openspec/changes/archive/2026-08-02-partition-rules-by-engine/tasks.md similarity index 75% rename from openspec/changes/partition-rules-by-engine/tasks.md rename to openspec/changes/archive/2026-08-02-partition-rules-by-engine/tasks.md index 0e49f246..a08f8f83 100644 --- a/openspec/changes/partition-rules-by-engine/tasks.md +++ b/openspec/changes/archive/2026-08-02-partition-rules-by-engine/tasks.md @@ -44,12 +44,18 @@ ## 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 +- [x] 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 — `runAstGrepScan` takes the config path and defaults to the committed one; `resolveSgConfigPath` returns it without writing anything +- [x] 4.2 Update `rules/verify.ts` to run `sg test -c .taskless/sg/sgconfig.yml` over `sg/rule-tests/` — through the same resolver, so verify and check agree on which config a rule set uses +- [x] 4.3 Tests: scan/verify run against the committed `sg/` config; `sg` binary-not-found prints an error and exits 1 — `test/sg-committed-config.test.ts`, including a rule directory only the committed config declares (proving it is read, not reconstructed) and the bundled CLI run from outside the workspace with an empty `PATH` + +> `generateSgConfig` survives, narrowed to exactly one caller: `resolveSgConfigPath` generating a config +> for the pre-migration `.taskless/rules/` layout, which has no committed config of its own. Its +> `rulesDirectory` / `testDirectory` options are required for that alone. The materialized `.run/` set is +> **not** a second caller — the runtime narrow writes its own `sgconfig.yml` (`rules/runtime/narrow.ts`) +> and never goes through `generateSgConfig`. ## 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 +- [x] 5.1 `pnpm --filter @taskless/cli typecheck && lint && test` clean — 421 passed, 0 failed +- [x] 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. Ran against this repo's own rule set: the pre-change scan (ast-grep over the old ephemeral `ruleDirs: [rules]` config) and the post-migration CLI produced the same 2 findings — same rule, file, position, severity, message — and the same exit code 1. The published CLI was not used as the baseline because `pnpm dlx` is unavailable here; the reproduction is the exact command the old check path ran +- [x] 5.3 Update CLI help/onboarding text that names `.taskless/rules/` for the engine-partitioned layout, and `.taskless/.gitignore` handling — 12 `help/*.txt` recipes, `packages/cli/README.md`, and the `.taskless/README.md` that `0001` writes (now describing the per-engine directories). `.gitignore` handling landed in 1.7 diff --git a/openspec/specs/cli-check/spec.md b/openspec/specs/cli-check/spec.md index f6cde842..5f510b96 100644 --- a/openspec/specs/cli-check/spec.md +++ b/openspec/specs/cli-check/spec.md @@ -8,12 +8,12 @@ TBD — Defines the `taskless check` subcommand that validates project setup and ### Requirement: Check subcommand works without taskless.json -The `check` command SHALL NOT require `.taskless/taskless.json` to exist. The command SHALL only require the presence of rule files in `.taskless/rules/`. +The `check` command SHALL NOT require `.taskless/taskless.json` to exist. The command SHALL only require the presence of ast-grep rule files, in the `sg` engine directory `.taskless/sg/rules/` or the pre-migration `.taskless/rules/`. #### Scenario: Check succeeds without taskless.json -- **WHEN** a user runs `taskless check` in a directory with `.taskless/rules/*.yml` files but no `taskless.json` -- **THEN** the CLI SHALL proceed to generate `sgconfig.yml` and run the scanner +- **WHEN** a user runs `taskless check` in a directory with `.taskless/sg/rules/*.yml` files but no `taskless.json` +- **THEN** the CLI SHALL run the scanner against the committed `.taskless/sg/sgconfig.yml` #### Scenario: Check exits cleanly with no .taskless/ directory @@ -23,63 +23,40 @@ The `check` command SHALL NOT require `.taskless/taskless.json` to exist. The co #### Scenario: Check exits cleanly with empty rules directory -- **WHEN** a user runs `taskless check` and `.taskless/rules/` contains no `.yml` files +- **WHEN** a user runs `taskless check` and no engine directory holds any rule files - **THEN** the CLI SHALL print a warning that no rules were found - **AND** the CLI SHALL exit with code 0 -### Requirement: Check subcommand generates ephemeral sgconfig.yml - -The `check` command SHALL generate an `sgconfig.yml` file in `.taskless/` before invoking ast-grep. The generated config SHALL set `ruleDirs` to `['rules']` and `testConfigs` to `[{testDir: 'rule-tests'}]`. The file SHALL be written to `.taskless/sgconfig.yml` which is gitignored via `.taskless/.gitignore`. If `.taskless/.gitignore` does not exist, the CLI SHALL create it before writing the config. - -#### Scenario: sgconfig.yml is generated at check time - -- **WHEN** a user runs `taskless check` -- **AND** `.taskless/rules/` contains rule files -- **THEN** the CLI SHALL write `.taskless/sgconfig.yml` with `ruleDirs: ['rules']` -- **AND** the CLI SHALL pass `--config .taskless/sgconfig.yml` to ast-grep - -#### Scenario: Existing sgconfig.yml is overwritten - -- **WHEN** `.taskless/sgconfig.yml` already exists (from a previous run or legacy scaffold) -- **THEN** the CLI SHALL overwrite it with the freshly generated content - ### Requirement: Check subcommand warns when no rules exist -The CLI SHALL check for the presence of YAML rule files in the `.taskless/rules/` directory. When no rule files are found, the CLI SHALL warn the user and exit cleanly. +The CLI SHALL check for the presence of YAML rule files in the `sg` engine directory `.taskless/sg/rules/`, and in the pre-migration `.taskless/rules/` where a project has not been migrated. When no rule files are found in either, the CLI SHALL warn the user and exit cleanly. -#### Scenario: No rule files in rules directory +#### Scenario: No rule files in any rules directory -- **WHEN** a user runs `taskless check` and `.taskless/rules/` contains no `.yml` files +- **WHEN** a user runs `taskless check` and neither `.taskless/sg/rules/` nor `.taskless/rules/` contains `.yml` files - **THEN** the CLI SHALL print a warning message indicating no rules were found - **AND** the CLI SHALL exit with code 0 -#### Scenario: Rules directory contains rule files +#### Scenario: Engine rules directory contains rule files -- **WHEN** a user runs `taskless check` and `.taskless/rules/` contains one or more `.yml` files +- **WHEN** a user runs `taskless check` and `.taskless/sg/rules/` contains one or more `.yml` files - **THEN** the CLI SHALL proceed to run the scanner -### Requirement: Check subcommand executes ast-grep scan +#### Scenario: Only the pre-migration rules directory contains rule files -The CLI SHALL generate an ephemeral `sgconfig.yml` in `.taskless/` and execute -`sg scan --config .taskless/sgconfig.yml --json=stream` using `child_process.spawn` with -`shell: true` for cross-platform binary resolution. The `sg` binary SHALL be resolved from -the `@ast-grep/cli` dependency via PATH. When reconciliation succeeds, the scan SHALL cover -only the blessed `run`-set rule files; on the unauthenticated/`--anonymous` path, or when an -authenticated reconciliation degrades to a local scan, the scan SHALL cover all local rule -files as before. +- **WHEN** a user runs `taskless check` and only `.taskless/rules/` contains `.yml` files +- **THEN** the CLI SHALL run the scanner against a generated config for that layout, so an unmigrated rule set still runs -#### Scenario: ast-grep scan runs with generated config +### Requirement: Check subcommand executes ast-grep scan -- **WHEN** the CLI executes the scanner -- **THEN** it SHALL first write `.taskless/sgconfig.yml` -- **AND** it SHALL invoke `sg scan` with `--config .taskless/sgconfig.yml` and `--json=stream` -- **AND** the working directory for the spawned process SHALL be the resolved project directory +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: Scan is limited to the run set when reconciled +#### Scenario: ast-grep scan runs with the committed config -- **WHEN** reconciliation succeeded and returned a `run` set -- **THEN** the generated scan configuration SHALL cause `sg scan` to evaluate only the - `run`-set rule files +- **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 @@ -248,7 +225,7 @@ When `taskless check --json` exits with an error, the output SHALL conform to th ### Requirement: Check selects what it runs from auth state `taskless check` SHALL NOT require authentication, and it SHALL choose what it runs from the -current auth state. **Static ast-grep rules** (single `*.yml` files under `.taskless/rules/`) +current auth state. **Static ast-grep rules** (single `*.yml` files under `.taskless/sg/rules/`, or the pre-migration `.taskless/rules/`) SHALL always run without contacting the server, on every path (the offline linter posture). **Runtime rules** (directories with `metadata.taskless.kind: runtime`) SHALL run only on a signature-validated path: when a token is available and `--anonymous` is not set the CLI SHALL @@ -321,16 +298,12 @@ non-zero code solely because reconciliation failed, and the warning SHALL be sup ### Requirement: Check dispatches static and runtime rules to distinct executors -`taskless check` SHALL execute **static** ast-grep rules under `.taskless/rules/` with the -ast-grep scanner as before, and **runtime** rules under `.taskless/runtime-rules/` (directories -with `metadata.taskless.kind: runtime`, per the `cli-runtime-rule-execution` capability) with -the runtime harness. Findings from both executors SHALL be aggregated into the same result set -and SHALL count toward the exit code identically. +`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 both executors +#### Scenario: Mixed corpus runs all executors -- **WHEN** `.taskless/rules/` contains static rules and `.taskless/runtime-rules/` contains runtime rules -- **THEN** the CLI SHALL run static rules through `sg scan` and runtime rules through the runtime harness +- **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 ### Requirement: Check runs runtime rules only on a signature-validated path diff --git a/openspec/specs/cli-rule-format/spec.md b/openspec/specs/cli-rule-format/spec.md new file mode 100644 index 00000000..364a6007 --- /dev/null +++ b/openspec/specs/cli-rule-format/spec.md @@ -0,0 +1,120 @@ +# cli-rule-format Specification + +## Purpose + +TBD - created by archiving change partition-rules-by-engine. Update Purpose after archive. + +## 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/specs/cli-runtime-rule-execution/spec.md b/openspec/specs/cli-runtime-rule-execution/spec.md index 263516f5..b4b8d546 100644 --- a/openspec/specs/cli-runtime-rule-execution/spec.md +++ b/openspec/specs/cli-runtime-rule-execution/spec.md @@ -8,25 +8,24 @@ Defines how the CLI executes a **runtime rule** in `taskless check`: the on-disk ### Requirement: Runtime rules are directories recognized by metadata -The CLI SHALL recognize a **runtime rule** as a directory under `.taskless/runtime-rules//` +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/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`. +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-rules directory entry is a runtime rule +#### 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` +- **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 .taskless/rules remain static +#### Scenario: Rules under sg remain static -- **WHEN** a rule file lives under `.taskless/rules/` +- **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 ### Requirement: The harness narrows with one ast-grep scan and gates on matches diff --git a/packages/cli/README.md b/packages/cli/README.md index db8635e2..eb27e363 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -95,7 +95,7 @@ bare CLI; when the install only wrote skills (OpenCode, Codex, the ### `taskless check` -Run ast-grep rules from `.taskless/rules/` against the codebase. Exits with code 1 if any error-severity matches are found. +Run ast-grep rules from `.taskless/sg/rules/` against the codebase. Exits with code 1 if any error-severity matches are found. ```bash taskless check # human-readable output, scans whole project @@ -121,7 +121,7 @@ Authenticate with taskless.io using the device flow. Tokens are stored in `~/.co ### `taskless rule create` -Generate ast-grep rules via the taskless.io API. Reads a JSON request from stdin, submits it, polls for results, and writes rule and test files to `.taskless/rules/` and `.taskless/rule-tests/`. +Generate ast-grep rules via the taskless.io API. Reads a JSON request from stdin, submits it, polls for results, and writes rule and test files to `.taskless/sg/rules/` and `.taskless/sg/rule-tests/`. ```bash echo '{"prompt": "detect console.log usage"}' | taskless rule create diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 4e445530..a2709cba 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -5,7 +5,7 @@ import { defineCommand } from "citty"; import { runAstGrepScan } from "../rules/scan"; import type { CheckResult } from "../types/check"; import { formatText } from "../util/format"; -import { generateSgConfig } from "../filesystem/sgconfig"; +import { resolveSgConfigPath } from "../filesystem/sgconfig"; import { ensureTasklessDirectory } from "../filesystem/directory"; import { dedupeFindings, @@ -316,8 +316,9 @@ export const checkCommand = defineCommand({ } // Rules dispatch by the engine directory that contains them. This is also - // the migration trigger: `generateSgConfig` is leaving the check path, so - // without this call an upgraded CLI would keep reading a stale layout. +// the migration trigger: no config is generated on the check path any + // more, so without this call an upgraded CLI would keep reading a stale + // layout. // // Only an existing `.taskless/` is migrated. `ensureTasklessDirectory` // creates the scaffold, and `check` is a read-only command — running it in @@ -371,11 +372,8 @@ export const checkCommand = defineCommand({ // is reported once. const staticResults: CheckResult[] = []; for (const source of astGrepSources) { - await generateSgConfig(cwd, { - rulesDirectory: source.rulesDirectory, - testDirectory: source.ruleTestsDirectory, - }); - const scan = await runAstGrepScan(cwd, existingPaths); + const configPath = await resolveSgConfigPath(cwd, source); + const scan = await runAstGrepScan(cwd, existingPaths, { configPath }); staticResults.push(...scan.results); } results.push(...dedupeFindings(staticResults)); diff --git a/packages/cli/src/filesystem/migrations/0001-init.ts b/packages/cli/src/filesystem/migrations/0001-init.ts index 8f7bfa8d..f1ac49df 100644 --- a/packages/cli/src/filesystem/migrations/0001-init.ts +++ b/packages/cli/src/filesystem/migrations/0001-init.ts @@ -31,8 +31,13 @@ npx @taskless/cli@latest check - \`.env.local.json\` - Local authentication credentials (git-ignored) - \`skills/\` - Canonical Taskless skill content; tool directories hold thin stubs that delegate here (managed by Taskless) - \`commands/\` - Canonical Taskless command content (managed by Taskless) -- \`rules/\` - Generated ast-grep rules (managed by Taskless) -- \`rule-tests/\` - Rule tests containing pass/fail examples for your rules + +Rules are partitioned by the engine that runs them. Each engine directory holds +that tool's own native config, its \`rules/\`, and its \`rule-tests/\`: + +- \`sg/\` - ast-grep: \`sgconfig.yml\`, generated rules (managed by Taskless), and their pass/fail test cases +- \`vale/\` - Vale prose rules: \`.vale.ini\`. Scaffolded and inert; nothing runs it yet +- \`runtime/\` - Rules that execute a \`check.ts\`, each in its own \`rules//\` directory `; const migration: Migration = async (directory) => { diff --git a/packages/cli/src/filesystem/sgconfig.ts b/packages/cli/src/filesystem/sgconfig.ts index ca649864..99b8ca6c 100644 --- a/packages/cli/src/filesystem/sgconfig.ts +++ b/packages/cli/src/filesystem/sgconfig.ts @@ -2,7 +2,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { ensureTasklessDirectory } from "./directory"; -import { ENGINE_LAYOUTS } from "../rules/engines"; +import { COMMITTED_SG_CONFIG, ENGINE_LAYOUTS } from "../rules/engines"; /** Build sgconfig contents pointing `ruleDirs` at the given directory. */ function sgConfigContent( @@ -31,6 +31,15 @@ export interface SgConfigOptions { /** * Generate an ephemeral `sgconfig.yml` in `.taskless/` for ast-grep. * Runs migrations and ensures the directory structure is up-to-date. + * + * The `sg` engine no longer goes through here — it reads its committed + * `sg/sgconfig.yml` ({@link COMMITTED_SG_CONFIG}). What remains is exactly one + * caller: {@link resolveSgConfigPath} generating a config for the pre-migration + * `.taskless/rules/` layout, which has no committed config of its own. + * + * The runtime narrow is NOT a second caller, despite looking like one — it + * writes its own `sgconfig.yml` into the materialized run directory + * (`rules/runtime/narrow.ts`) rather than coming through here. */ export async function generateSgConfig( cwd: string, @@ -38,7 +47,7 @@ export async function generateSgConfig( ): Promise { await ensureTasklessDirectory(cwd); await writeFile( - join(cwd, ".taskless", "sgconfig.yml"), + join(cwd, ".taskless", EPHEMERAL_SG_CONFIG_FILE), sgConfigContent( options.rulesDirectory ?? ENGINE_LAYOUTS.sg.rulesDirectory, options.testDirectory ?? ENGINE_LAYOUTS.sg.ruleTestsDirectory @@ -46,3 +55,38 @@ export async function generateSgConfig( "utf8" ); } + +/** Filename of the generated config, inside `.taskless/` (git-ignored). */ +const EPHEMERAL_SG_CONFIG_FILE = "sgconfig.yml"; + +/** The generated config's path relative to the project root. */ +export const EPHEMERAL_SG_CONFIG = `.taskless/${EPHEMERAL_SG_CONFIG_FILE}`; + +/** A rule set to point ast-grep at, as returned by engine discovery. */ +export interface SgConfigSource { + /** Rules directory, relative to `.taskless/`. */ + rulesDirectory: string; + /** Rule-tests directory, relative to `.taskless/`. */ + ruleTestsDirectory: string; + /** Whether this is the pre-migration layout. */ + legacy: boolean; +} + +/** + * The `--config` path to run this rule set with, relative to the project root. + * + * The engine-partitioned source resolves to its committed config and nothing is + * written. Only the legacy layout — which by definition predates that config — + * still needs one generated for it. + */ +export async function resolveSgConfigPath( + cwd: string, + source: SgConfigSource +): Promise { + if (!source.legacy) return COMMITTED_SG_CONFIG; + await generateSgConfig(cwd, { + rulesDirectory: source.rulesDirectory, + testDirectory: source.ruleTestsDirectory, + }); + return EPHEMERAL_SG_CONFIG; +} diff --git a/packages/cli/src/help/check.txt b/packages/cli/src/help/check.txt index 10f38fec..9523aa32 100644 --- a/packages/cli/src/help/check.txt +++ b/packages/cli/src/help/check.txt @@ -2,15 +2,15 @@ ## Goal Run the applicable rules against the codebase and report matches. Two -rule kinds run: **static** ast-grep rules in `.taskless/rules/`, and -**runtime** rules in `.taskless/runtime-rules/` (a directory of ast-grep +rule kinds run: **static** ast-grep rules in `.taskless/sg/rules/`, and +**runtime** rules in `.taskless/runtime/rules/` (a directory of ast-grep capture rules plus a `check.ts`). Used standalone (full project scan), in CI (diff-only scan), or after rule create/improve to validate. ## Preconditions - `.taskless/` directory exists. -- At least one rule exists in `.taskless/rules/` or - `.taskless/runtime-rules/`. (If none exist, the CLI exits 0 with a +- At least one rule exists in `.taskless/sg/rules/` or + `.taskless/runtime/rules/`. (If none exist, the CLI exits 0 with a friendly message suggesting `taskless rule create`.) - No auth required. Static rules always run; whether runtime rules run depends on auth state — see "What runs". @@ -19,10 +19,10 @@ in CI (diff-only scan), or after rule create/improve to validate. `check` never requires auth. The two rule kinds run differently: -- **Static rules** (`.taskless/rules/*.yml`) are inert ast-grep patterns +- **Static rules** (`.taskless/sg/rules/*.yml`) are inert ast-grep patterns and **always run**, in every mode, with no network call. The offline linter posture. -- **Runtime rules** (`.taskless/runtime-rules//`) execute a +- **Runtime rules** (`.taskless/runtime/rules//`) execute a `check.ts` — arbitrary code — so they run ONLY when that code is verified: - **Logged in** (token or API key) — each rule's `check.ts` is diff --git a/packages/cli/src/help/ci.txt b/packages/cli/src/help/ci.txt index ded38138..64f5d1cd 100644 --- a/packages/cli/src/help/ci.txt +++ b/packages/cli/src/help/ci.txt @@ -162,7 +162,7 @@ different structure for CircleCI. The six steps stay the same. config works out of the box with no secrets and scans all local rules. Static ast-grep rules always run in CI with no secrets. **Runtime -rules** (`.taskless/runtime-rules/`, which execute a `check.ts`) only +rules** (`.taskless/runtime/rules/`, which execute a `check.ts`) only run when their code is server-verified — so an unauthenticated CI job runs the static rules and skips the runtime ones. diff --git a/packages/cli/src/help/detect.txt b/packages/cli/src/help/detect.txt index 62c671cd..a8036054 100644 --- a/packages/cli/src/help/detect.txt +++ b/packages/cli/src/help/detect.txt @@ -30,7 +30,7 @@ routing flow reads `detect` to decide where a new rule should live. "languages": ["typescript", "javascript"], "ruleStyles": [ { - "source": ".taskless/rules", + "source": ".taskless/sg/rules", "description": "ast-grep rules with YAML metadata sidecars" } ] diff --git a/packages/cli/src/help/remote.txt b/packages/cli/src/help/remote.txt index 823cffc2..4188ad21 100644 --- a/packages/cli/src/help/remote.txt +++ b/packages/cli/src/help/remote.txt @@ -49,7 +49,7 @@ confirmed. - Do NOT pre-build the rule yourself on this path — submit the request and let the service generate it. - The service owns rule-type selection. Today it generates ast-grep - rules written under `.taskless/rules/`, the same shape the local + rules written under `.taskless/sg/rules/`, the same shape the local `static` path produces. ## See Also diff --git a/packages/cli/src/help/route.txt b/packages/cli/src/help/route.txt index b1ece4ed..363abc03 100644 --- a/packages/cli/src/help/route.txt +++ b/packages/cli/src/help/route.txt @@ -31,7 +31,7 @@ failed and the user confirms. "linters": [{ "name": "eslint", "evidence": ["packages/api/.eslintrc.json"] }], "languages": ["JavaScript", "TypeScript"], "ruleStyles": [ - { "source": ".taskless/rules", "description": "Existing Taskless ast-grep rules." } + { "source": ".taskless/sg/rules", "description": "Existing Taskless ast-grep rules." } ] } ``` diff --git a/packages/cli/src/help/rule-create.anonymous.txt b/packages/cli/src/help/rule-create.anonymous.txt index 5d2fe312..74c88de1 100644 --- a/packages/cli/src/help/rule-create.anonymous.txt +++ b/packages/cli/src/help/rule-create.anonymous.txt @@ -26,14 +26,14 @@ using `rule verify` in a feedback loop. - Are there exceptions or edge cases where the pattern is OK? - Can they show valid and invalid examples? -3. **Check for similar existing rules.** Scan `.taskless/rules/`. If +3. **Check for similar existing rules.** Scan `.taskless/sg/rules/`. If anything overlaps, point it out and ask whether the user wants to improve an existing rule via `taskless help rule improve --anonymous`. 4. **Search the codebase for real instances.** Show the user what you found and confirm any exclusions. -5. **Derive the rule.** Write a YAML file at `.taskless/rules/.yml` +5. **Derive the rule.** Write a YAML file at `.taskless/sg/rules/.yml` with at minimum: - `id`: kebab-case identifier (e.g. `no-eval`, `prefer-const`) - `language`: target language @@ -44,7 +44,7 @@ using `rule verify` in a feedback loop. Optional but useful: `note` (multi-line guidance, supports markdown), `fix` (auto-fix pattern), `ignores` (file patterns to skip). -6. **Write test cases.** Create `.taskless/rule-tests/-YYYYMMDD-test.yml` +6. **Write test cases.** Create `.taskless/sg/rule-tests/-YYYYMMDD-test.yml` with `id`, `valid: [...]`, and `invalid: [...]` arrays. Include at least 2 valid and 2 invalid cases. Use real patterns from the codebase where possible. diff --git a/packages/cli/src/help/rule-create.txt b/packages/cli/src/help/rule-create.txt index 3b65fec8..6cbc60e9 100644 --- a/packages/cli/src/help/rule-create.txt +++ b/packages/cli/src/help/rule-create.txt @@ -29,7 +29,7 @@ If the user wants the local-only flow (no API call), fetch - In what language? - Are there contexts where the pattern is acceptable? -3. **Check for similar existing rules.** Scan `.taskless/rules/` for +3. **Check for similar existing rules.** Scan `.taskless/sg/rules/` for rule files. Read each rule's `message`, `note`, and `rule` fields. If any overlap with the user's request, show the user and ask: "It looks like you already have a rule that covers something @@ -64,8 +64,8 @@ If the user wants the local-only flow (no API call), fetch of success or failure. 9. **Report results.** The CLI writes the generated rule to - `.taskless/rules/.yml`, tests to - `.taskless/rule-tests/-YYYYMMDD-test.yml` (timestamped per + `.taskless/sg/rules/.yml`, tests to + `.taskless/sg/rule-tests/-YYYYMMDD-test.yml` (timestamped per generation), and metadata to `.taskless/rule-metadata/.yml`. Show the user the file paths. Suggest fetching `taskless help check` to validate. diff --git a/packages/cli/src/help/rule-delete.txt b/packages/cli/src/help/rule-delete.txt index c4bbfc75..1bdec306 100644 --- a/packages/cli/src/help/rule-delete.txt +++ b/packages/cli/src/help/rule-delete.txt @@ -6,13 +6,13 @@ not contact the Taskless API; purely a local filesystem operation. ## Preconditions - `.taskless/` directory exists. -- The target rule file exists at `.taskless/rules/.yml`. +- The target rule file exists at `.taskless/sg/rules/.yml`. - No auth required. ## Steps 1. **Identify the rule.** If the user named one, use it. Otherwise, - list `.taskless/rules/` and ask which one. Confirm the user's + list `.taskless/sg/rules/` and ask which one. Confirm the user's intent — deletion is destructive. 2. **Invoke the CLI.** Run: @@ -21,8 +21,8 @@ not contact the Taskless API; purely a local filesystem operation. ``` The CLI removes: - - `.taskless/rules/.yml` - - All matching `.taskless/rule-tests/-*.yml` files + - `.taskless/sg/rules/.yml` + - All matching `.taskless/sg/rule-tests/-*.yml` files - `.taskless/rule-metadata/.yml` (if present) 3. **Report results.** Show the user what was deleted. If the rule diff --git a/packages/cli/src/help/rule-improve.anonymous.txt b/packages/cli/src/help/rule-improve.anonymous.txt index f8b8b75e..d2160cbf 100644 --- a/packages/cli/src/help/rule-improve.anonymous.txt +++ b/packages/cli/src/help/rule-improve.anonymous.txt @@ -13,8 +13,8 @@ validate with `rule verify` in a feedback loop. ## Steps 1. **Identify the target rule.** If the user named one, use it. - Otherwise, list `.taskless/rules/` and ask which one. Read the - rule file and any test file (`.taskless/rule-tests/-*.yml`). + Otherwise, list `.taskless/sg/rules/` and ask which one. Read the + rule file and any test file (`.taskless/sg/rule-tests/-*.yml`). 2. **Gather improvement guidance.** Ask: - Are there false positives (cases currently flagged but @@ -31,13 +31,13 @@ validate with `rule verify` in a feedback loop. Summarize the planned change before editing. 4. **Edit the rule file.** Make the change directly in - `.taskless/rules/.yml`. Preserve the existing `id`, + `.taskless/sg/rules/.yml`. Preserve the existing `id`, `language`, and `severity` unless the user explicitly asked to change them. 5. **Update test cases.** Add the new false-positive examples to the `valid:` list and the new false-negative examples to the - `invalid:` list in `.taskless/rule-tests/-*.yml`. + `invalid:` list in `.taskless/sg/rule-tests/-*.yml`. 6. **Run the verify feedback loop.** Run: ``` diff --git a/packages/cli/src/help/rule-improve.txt b/packages/cli/src/help/rule-improve.txt index bc40ff65..5c12f0a9 100644 --- a/packages/cli/src/help/rule-improve.txt +++ b/packages/cli/src/help/rule-improve.txt @@ -13,7 +13,7 @@ If the user wants the local-only flow (no API call), fetch ## Preconditions - User is logged in. - Repository has a GitHub origin remote. -- The target rule exists in `.taskless/rules/.yml`. +- The target rule exists in `.taskless/sg/rules/.yml`. - The rule has metadata at `.taskless/rule-metadata/.yml` (the `ticketId` is required by the iterate endpoint). If metadata is missing, the rule was created in anonymous mode and cannot be @@ -25,7 +25,7 @@ If the user wants the local-only flow (no API call), fetch `loggedIn`. If false, fetch `taskless help auth`. 2. **Identify the rule to improve.** If the user named one, use it. - Otherwise, list rules in `.taskless/rules/` and ask which one. + Otherwise, list rules in `.taskless/sg/rules/` and ask which one. Read the existing rule file so you can summarize what it does. 3. **Fetch the rule's metadata.** Run: diff --git a/packages/cli/src/help/rule-verify.txt b/packages/cli/src/help/rule-verify.txt index 4517d3fc..4cd7aa8b 100644 --- a/packages/cli/src/help/rule-verify.txt +++ b/packages/cli/src/help/rule-verify.txt @@ -6,8 +6,8 @@ Primary consumer is the anonymous create/improve flow (the verify feedback loop), but agents can also call it directly to lint a rule. ## Preconditions -- `.taskless/rules/.yml` exists. -- Optionally `.taskless/rule-tests/-*.yml` exists (test cases run +- `.taskless/sg/rules/.yml` exists. +- Optionally `.taskless/sg/rule-tests/-*.yml` exists (test cases run if present). - No auth required. diff --git a/packages/cli/src/help/static.txt b/packages/cli/src/help/static.txt index 2332e570..c4cf9cd6 100644 --- a/packages/cli/src/help/static.txt +++ b/packages/cli/src/help/static.txt @@ -25,10 +25,10 @@ writes, so `check`, `improve`, and `verify` treat them identically. the codebase for real instances and confirm exclusions with the user. 3. **Author the rule in the canonical shape.** Write the rule to - `.taskless/rules/.yml` with at minimum `id` (kebab-case), + `.taskless/sg/rules/.yml` with at minimum `id` (kebab-case), `language`, `severity` (`error`/`warning`/`info`/`hint`), `message`, and the `rule` object. Write tests to - `.taskless/rule-tests/-YYYYMMDD-test.yml` with the matching `id` + `.taskless/sg/rule-tests/-YYYYMMDD-test.yml` with the matching `id` field plus `valid` and `invalid` arrays (at least two of each). The `id` must match the rule's `id` so ast-grep test filtering pairs them. These paths and shape are the same ones the service writes — do not @@ -50,7 +50,7 @@ writes, so `check`, `improve`, and `verify` treat them identically. 6. **On failure, escalate via the route fallback — with confirmation.** If after the feedback loop the rule still cannot capture the user's cases, this is the try-verify-escalate fallback: - - Delete the candidate `.taskless/rules/.yml` and its test file so + - Delete the candidate `.taskless/sg/rules/.yml` and its test file so the repo is not left with a broken rule (guaranteed cleanup of the abandoned candidate). - Tell the user the local rule could not capture the cases, and that diff --git a/packages/cli/src/rules/engines.ts b/packages/cli/src/rules/engines.ts index 9f066418..37232337 100644 --- a/packages/cli/src/rules/engines.ts +++ b/packages/cli/src/rules/engines.ts @@ -27,7 +27,7 @@ export interface EngineLayout { executor: EngineExecutor; } -export const ENGINE_LAYOUTS: Record = { +export const ENGINE_LAYOUTS = { sg: { engine: "sg", rulesDirectory: "sg/rules", @@ -50,7 +50,18 @@ export const ENGINE_LAYOUTS: Record = { configFile: undefined, executor: "runtime-harness", }, -}; +} satisfies Record; + +/** + * The committed ast-grep config, relative to the project root. It is authored + * and persisted, never generated at check time: its `ruleDirs`/`testConfigs` + * are relative to the config file, so it needs no rewriting to stay valid. + * + * Declared here beside the layout it derives from. `engines.ts` imports nothing + * of ours but the error type, so both the filesystem and rules layers can reach + * this constant without either pulling in the other's machinery. + */ +export const COMMITTED_SG_CONFIG = `.taskless/${ENGINE_LAYOUTS.sg.configFile}`; /** * The pre-`0004` ast-grep locations. Still dispatched as ast-grep so an diff --git a/packages/cli/src/rules/scan.ts b/packages/cli/src/rules/scan.ts index 1f14e347..584ca7c5 100644 --- a/packages/cli/src/rules/scan.ts +++ b/packages/cli/src/rules/scan.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import type { AstGrepMatch } from "../types/check"; import { toCheckResult, type CheckResult } from "../types/check"; +import { COMMITTED_SG_CONFIG } from "./engines"; export interface ScanResult { results: CheckResult[]; @@ -145,10 +146,21 @@ export function findSgBinary(): string { ); } +export interface ScanOptions { + /** + * ast-grep config to scan with, relative to `cwd`. Defaults to the committed + * `sg` engine config — the source of truth for the ast-grep engine, read + * as-is rather than generated per run. Callers scanning the pre-migration + * layout pass the ephemeral config written for it instead. + */ + configPath?: string; +} + /** Run ast-grep scan and return parsed results */ export async function runAstGrepScan( cwd: string, - paths: string[] = [] + paths: string[] = [], + options: ScanOptions = {} ): Promise { return new Promise((resolve, reject) => { const sgBinary = findSgBinary(); @@ -157,7 +169,7 @@ export async function runAstGrepScan( const argv = [ "scan", "--config", - ".taskless/sgconfig.yml", + options.configPath ?? COMMITTED_SG_CONFIG, "--json=stream", ...(paths.length > 0 ? ["--", ...paths] : []), ]; diff --git a/packages/cli/src/rules/verify.ts b/packages/cli/src/rules/verify.ts index 3c15a7dd..c07c4709 100644 --- a/packages/cli/src/rules/verify.ts +++ b/packages/cli/src/rules/verify.ts @@ -9,7 +9,10 @@ import { findRegexWithoutKind, } from "../schemas/ast-grep-rule"; import { ensureTasklessDirectory } from "../filesystem/directory"; -import { generateSgConfig } from "../filesystem/sgconfig"; +import { + resolveSgConfigPath, + type SgConfigSource, +} from "../filesystem/sgconfig"; import { astGrepRuleFileCandidates, astGrepRuleTestDirectories, @@ -169,14 +172,12 @@ async function validateRequirements( async function runTests( cwd: string, ruleId: string, - layout: { rulesDirectory: string; ruleTestsDirectory: string } + layout: SgConfigSource ): Promise { - // Point ast-grep at the layout the rule was actually resolved from, so a - // rule still living at the legacy path is tested rather than reported absent. - await generateSgConfig(cwd, { - rulesDirectory: layout.rulesDirectory, - testDirectory: layout.ruleTestsDirectory, - }); + // Point ast-grep at the layout the rule was actually resolved from: the + // committed `sg/sgconfig.yml` over `sg/rule-tests/`, or a generated config + // when the rule still lives at the pre-migration path. + const configPath = await resolveSgConfigPath(cwd, layout); const sgBinary = findSgBinary(); @@ -186,7 +187,7 @@ async function runTests( [ "test", "-c", - ".taskless/sgconfig.yml", + configPath, "--skip-snapshot-tests", "--filter", `^${escapeRegExp(ruleId)}$`, @@ -276,9 +277,10 @@ export async function verifyRule( // and remember which layout won so the test run points ast-grep at it. const candidates = astGrepRuleFileCandidates(cwd, ruleId); let ruleContent: string | undefined; - let layout = { + let layout: SgConfigSource = { rulesDirectory: ENGINE_LAYOUTS.sg.rulesDirectory, ruleTestsDirectory: ENGINE_LAYOUTS.sg.ruleTestsDirectory, + legacy: false, }; for (const [index, candidate] of candidates.entries()) { try { @@ -287,6 +289,7 @@ export async function verifyRule( layout = { rulesDirectory: LEGACY_RULES_DIRECTORY, ruleTestsDirectory: LEGACY_RULE_TESTS_DIRECTORY, + legacy: true, }; } break; diff --git a/packages/cli/test/sg-committed-config.test.ts b/packages/cli/test/sg-committed-config.test.ts new file mode 100644 index 00000000..7e2c6daf --- /dev/null +++ b/packages/cli/test/sg-committed-config.test.ts @@ -0,0 +1,294 @@ +import { execFile } from "node:child_process"; +import { + copyFile, + mkdir, + mkdtemp, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ensureTasklessDirectory } from "../src/filesystem/directory"; +import { verifyRule } from "../src/rules/verify"; + +const execFileAsync = promisify(execFile); +const binPath = resolve(import.meta.dirname, "../dist/index.js"); + +/** `.taskless/sgconfig.yml` — written only when a rule set has no committed config. */ +const EPHEMERAL_CONFIG = ["sgconfig.yml"]; + +function rule(id: string, pattern: string): string { + return [ + `id: ${id}`, + "language: typescript", + "severity: error", + "rule:", + ` pattern: ${pattern}`, + `message: avoid ${id}`, + "", + ].join("\n"); +} + +function ruleTest(id: string, valid: string, invalid: string): string { + return [ + `id: ${id}`, + "valid:", + ` - ${valid}`, + "invalid:", + ` - ${invalid}`, + "", + ].join("\n"); +} + +async function runCli( + args: string[] +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const { stdout, stderr } = await execFileAsync(process.execPath, [ + binPath, + ...args, + ]); + return { stdout, stderr, exitCode: 0 }; + } catch (error) { + // `code` is a number for a normal exit, but null when the child was killed + // by a signal and a string for spawn failures (e.g. ENOENT) — coerce so a + // signal death cannot read as exit 0. + const execError = error as { + stdout?: string; + stderr?: string; + code?: number | string | null; + }; + return { + stdout: execError.stdout ?? "", + stderr: execError.stderr ?? "", + exitCode: typeof execError.code === "number" ? execError.code : 1, + }; + } +} + +function parseJson(stdout: string): { + success: boolean; + results: { ruleId: string }[]; +} { + const line = stdout + .trim() + .split("\n") + .findLast((l) => l.trim().startsWith("{")); + return JSON.parse(line ?? "{}") as { + success: boolean; + results: { ruleId: string }[]; + }; +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +describe("ast-grep runs over the committed sg config", () => { + let temporaryDirectory: string; + let tasklessDirectory: string; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "tskl-sg-config-")); + await ensureTasklessDirectory(temporaryDirectory); + tasklessDirectory = join(temporaryDirectory, ".taskless"); + await writeFile( + join(temporaryDirectory, "src.ts"), + 'eval("danger");\nwith (x) {}\n', + "utf8" + ); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + it("scans without generating an ephemeral config", async () => { + await writeFile( + join(tasklessDirectory, "sg", "rules", "no-eval.yml"), + rule("no-eval", "eval($A)"), + "utf8" + ); + + const { stdout, exitCode } = await runCli([ + "check", + "-d", + temporaryDirectory, + "--json", + ]); + + expect(exitCode).toBe(1); + expect(parseJson(stdout).results.map((r) => r.ruleId)).toEqual(["no-eval"]); + // The committed config is the source of truth; nothing is written for it. + expect(await exists(join(tasklessDirectory, ...EPHEMERAL_CONFIG))).toBe( + false + ); + }); + + it("honours a rule directory the committed config declares", async () => { + // A second ruleDirs entry, of the kind a human or the generator authors. + await writeFile( + join(tasklessDirectory, "sg", "sgconfig.yml"), + "ruleDirs:\n - rules\n - extra\ntestConfigs:\n - testDir: rule-tests\n", + "utf8" + ); + await writeFile( + join(tasklessDirectory, "sg", "rules", "no-eval.yml"), + rule("no-eval", "eval($A)"), + "utf8" + ); + await mkdir(join(tasklessDirectory, "sg", "extra"), { recursive: true }); + await writeFile( + join(tasklessDirectory, "sg", "extra", "no-with.yml"), + rule("no-with", "with ($A) { $$$B }"), + "utf8" + ); + + const { stdout } = await runCli([ + "check", + "-d", + temporaryDirectory, + "--json", + ]); + + // Both run: the config was read as written, not reconstructed from the + // directory listing. + expect( + parseJson(stdout) + .results.map((r) => r.ruleId) + .toSorted() + ).toEqual(["no-eval", "no-with"]); + }); + + it("generates a config only for the pre-migration layout", async () => { + await mkdir(join(tasklessDirectory, "rules"), { recursive: true }); + await writeFile( + join(tasklessDirectory, "rules", "no-eval.yml"), + rule("no-eval", "eval($A)"), + "utf8" + ); + + const { stdout } = await runCli([ + "check", + "-d", + temporaryDirectory, + "--json", + ]); + + expect(parseJson(stdout).results.map((r) => r.ruleId)).toEqual(["no-eval"]); + // That layout has no committed config of its own, so one is written for it. + expect(await exists(join(tasklessDirectory, ...EPHEMERAL_CONFIG))).toBe( + true + ); + }); + + it("verifies a rule against the committed config and sg/rule-tests", async () => { + await writeFile( + join(tasklessDirectory, "sg", "rules", "no-eval.yml"), + rule("no-eval", "eval($$$)"), + "utf8" + ); + await writeFile( + join(tasklessDirectory, "sg", "rule-tests", "no-eval-20260801-test.yml"), + ruleTest("no-eval", "const x = 1;", "eval('alert(1)')"), + "utf8" + ); + + const result = await verifyRule(temporaryDirectory, "no-eval"); + + expect(result.tests.errors).toEqual([]); + expect(result.tests.valid).toBe(true); + expect(result.tests.passed).toBe(1); + expect(result.success).toBe(true); + // `sg test` read the committed config — nothing was generated. + expect(await exists(join(tasklessDirectory, ...EPHEMERAL_CONFIG))).toBe( + false + ); + }); +}); + +describe("ast-grep binary is missing", () => { + let temporaryDirectory: string; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "tskl-sg-missing-")); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + it("prints an error and exits 1", async () => { + // Run the bundled CLI from outside the workspace with an empty PATH, so + // every candidate location misses: the platform package no longer resolves, + // there is no node_modules/.bin beside it, and PATH holds nothing. + // + // The child gets a *minimal* env rather than an override on top of + // `process.env`. Inheriting the parent's environment leaks `NODE_PATH` and + // `HOME` into module resolution — node consults `$HOME/.node_modules` — so + // on a machine where either points at a real ast-grep the resolver finds + // one, the scan succeeds, and the test fails asserting an error that never + // needed to be printed. + const isolated = join(temporaryDirectory, "cli"); + const project = join(temporaryDirectory, "project"); + const emptyPath = join(temporaryDirectory, "empty"); + await mkdir(isolated, { recursive: true }); + await mkdir(emptyPath, { recursive: true }); + await mkdir(project, { recursive: true }); + await copyFile(binPath, join(isolated, "index.js")); + + await ensureTasklessDirectory(project); + await writeFile( + join(project, ".taskless", "sg", "rules", "no-eval.yml"), + rule("no-eval", "eval($A)"), + "utf8" + ); + await writeFile(join(project, "src.ts"), 'eval("danger");\n', "utf8"); + + let stderr = ""; + let stdout = ""; + let exitCode = 0; + try { + const result = await execFileAsync( + process.execPath, + [join(isolated, "index.js"), "check", "-d", project], + { + env: { + PATH: emptyPath, + HOME: emptyPath, + // Telemetry would otherwise try to reach the network from a test. + TASKLESS_TELEMETRY_DISABLED: "1", + }, + } + ); + stdout = result.stdout; + } catch (error) { + const execError = error as { + stderr: string; + stdout: string; + code: number; + }; + stderr = execError.stderr ?? ""; + stdout = execError.stdout ?? ""; + exitCode = execError.code; + } + + // Named so a failure reports which path the CLI actually took: finding a + // binary makes this the findings path (exit 1, empty stderr), which looks + // identical to the error path on the exit code alone. + expect(exitCode).toBe(1); + expect( + stderr, + `stderr was empty; the CLI printed to stdout instead:\n${stdout}` + ).toContain("ast-grep binary not found"); + }); +});