From 84999c163ffff8a314a7ea53d7bd03eb861c76cb Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 20:34:05 -0700 Subject: [PATCH 1/3] feat(check): fail on a copy of an issued rule, and report a rename once v2 reconcile marks an unknown rule that carries an issued rule's file with copyOf (taskless/taskless#262, #264). An sg or Vale copy no longer runs and fails check naming its source; when the source is also missing the pair is one rename naming `rule restore `. Runtime copies are unchanged (never executed) but name the source. A malformed copyOf fails closed. --- .changeset/cli-v2-rule-api.md | 2 +- .../cli-copy-of-issued-rule/.openspec.yaml | 2 + .../changes/cli-copy-of-issued-rule/design.md | 79 ++++++++ .../cli-copy-of-issued-rule/proposal.md | 71 +++++++ .../specs/cli-check/spec.md | 86 ++++++++ .../specs/cli-rule-reconciliation/spec.md | 97 +++++++++ .../changes/cli-copy-of-issued-rule/tasks.md | 34 ++++ packages/cli/src/agent/check.md | 51 ++++- packages/cli/src/agent/recover-rule.md | 4 +- packages/cli/src/rules/plan-check.ts | 7 + packages/cli/src/rules/verdicts.ts | 160 ++++++++++++++- packages/cli/src/schemas/check.ts | 24 ++- packages/cli/test/runtime-check.test.ts | 87 +++++++- packages/cli/test/verdicts.test.ts | 188 ++++++++++++++++++ 14 files changed, 877 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/cli-copy-of-issued-rule/.openspec.yaml create mode 100644 openspec/changes/cli-copy-of-issued-rule/design.md create mode 100644 openspec/changes/cli-copy-of-issued-rule/proposal.md create mode 100644 openspec/changes/cli-copy-of-issued-rule/specs/cli-check/spec.md create mode 100644 openspec/changes/cli-copy-of-issued-rule/specs/cli-rule-reconciliation/spec.md create mode 100644 openspec/changes/cli-copy-of-issued-rule/tasks.md diff --git a/.changeset/cli-v2-rule-api.md b/.changeset/cli-v2-rule-api.md index 5e2e217f..86d28ee4 100644 --- a/.changeset/cli-v2-rule-api.md +++ b/.changeset/cli-v2-rule-api.md @@ -6,7 +6,7 @@ The CLI now speaks the Taskless v2 rule API, which addresses rules by their own What you may need to react to: -- **`taskless check` fails when an issued ast-grep or Vale rule has been edited.** Logged in, every file of every rule is checked against what Taskless issued, and an edited rule of any engine does not run. For ast-grep and Vale rules the run also fails, naming each changed, removed, or added file. To fix it, run `taskless rule restore ` rather than editing the rule back by hand. Logged out, with `--anonymous`, or with `--dangerously-run-scripts`, nothing is checked, as before. Rules you wrote yourself are unaffected. +- **`taskless check` fails when an issued ast-grep or Vale rule has been edited.** Logged in, every file of every rule is checked against what Taskless issued, and an edited rule of any engine does not run. For ast-grep and Vale rules the run also fails, naming each changed, removed, or added file. To fix it, run `taskless rule restore ` rather than editing the rule back by hand. The same applies to an issued rule copied or renamed to a new id: the copy does not run, and the run fails naming the rule it came from. Logged out, with `--anonymous`, or with `--dangerously-run-scripts`, nothing is checked, as before. Rules you wrote yourself are unaffected. - **`taskless check` never rewrites your rules.** It used to try to repair a changed runtime rule in the middle of a run. It now reports the change and names the command to run. - **`taskless rule create --json` prints `requestId` instead of `ruleId`.** The old field always held the request id, never a rule id. The ids of the rules that were written are in `rules`, and those are what `taskless rule improve` takes. diff --git a/openspec/changes/cli-copy-of-issued-rule/.openspec.yaml b/openspec/changes/cli-copy-of-issued-rule/.openspec.yaml new file mode 100644 index 00000000..29382a2c --- /dev/null +++ b/openspec/changes/cli-copy-of-issued-rule/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-29 diff --git a/openspec/changes/cli-copy-of-issued-rule/design.md b/openspec/changes/cli-copy-of-issued-rule/design.md new file mode 100644 index 00000000..3ce14bf5 --- /dev/null +++ b/openspec/changes/cli-copy-of-issued-rule/design.md @@ -0,0 +1,79 @@ +## Context + +`applyVerdicts` (`packages/cli/src/rules/verdicts.ts`) is the pure policy that +turns a v2 reconcile answer into what `check` does with each reported rule. An +`unknown` static rule runs silently; an `unknown` runtime rule is not executed; +a `missing` rule warns. The service contract (taskless/taskless#262 design D7, +published by #264) adds `copyOf` to `unknown` entries: + +``` +unknown: [{ ruleId, copyOf?: { ruleId, revisionId, files: [{ path, expected?, got? }] } }] +``` + +`files` pairs by digest before path, so a file carried unchanged under a new +name is not listed. + +## Goals / Non-Goals + +**Goals:** an sg/Vale copy fails `check` naming its source and the differing +files; a copy whose source is `missing` is reported once, as a rename; nothing +changes for a response without `copyOf`. + +**Non-Goals:** detecting copies locally (the CLI has no issued digests); any +change to `missing` on its own; an override flag (the contract has none). + +## Decisions + +### D1. Read `copyOf` defensively; do not touch the vendored schema + +`applyVerdicts` already takes the response as `unknown` and parses every field +it uses, so the generated types add nothing to the policy. The vendored +`api-v2.schema.json` is documented as the live API's own account of itself; +hand-extending it with a field that is not deployed would make it claim what the +service does not yet serve, and would be overwritten by the next +`generate:api` anyway. It is re-vendored once #264 is live. + +### D2. A copy stays `unknown` in the disposition and in `integrity` + +The service keeps the copy in `unknown` (its D1), so the CLI does too, and adds +`copyOf` beside it. No new `IntegrityVerdict` value: a consumer keyed on the +verdict set keeps working, and `copyOf` is the additive signal. The diff goes in +the existing `files` field, which already means "what differs from what was +issued". + +### D3. Rename detection: the source is in this answer's `missing` set + +A copy whose `copyOf.ruleId` is answered `missing` (and was not itself reported) +is a rename. The copy's message says the source was deleted and names +`rule restore `, and the source's own `missing` notice is suppressed. +The source's `missing` entry stays in `integrity`: both facts are true, and that +entry carries the `revisionId` restore brings back. The "one finding" +obligation is about what a person reads; `integrity` is per-rule state, and the +copy's `copyOf.sourceMissing: true` links the two for a machine reader. + +### D4. Runtime copies: unchanged outcome, source named + +A runtime `unknown` rule is never executed without `--dangerously-run-scripts`, +so the outcome stays. Its skip reason names the source, since that is the first +thing a reader will want and costs nothing. A plain runtime copy gets no notice +(its skip reason covers it, as for any runtime `unknown`). A runtime rename is +one notice, because it replaces the `missing` warning that would otherwise have +been printed; it does not fail the run, matching `unsafe` runtime rules. + +### D5. A malformed `copyOf` fails closed + +`copyOf` absent or `null` is a local rule. Present but not an object with a +non-empty string `ruleId` is treated as unaccounted: not run, and the run fails. +The service sends `copyOf` only when it found issued content, so even an +unreadable one says "this is a copy"; ignoring it would run exactly the rule the +field exists to stop. This follows the policy's existing rule that an answer the +CLI cannot read never makes a run greener. `revisionId` and `files` are read +tolerantly: only the source id is needed to act. + +## Risks / Trade-offs + +- **A local rule legitimately started from an issued rule's file fails** until + that file is changed. Intended by the service contract; the message names the + source, and the recipe says to write the rule from scratch. +- **Before #264 deploys** no response carries `copyOf`, so the behavior is + inert, not wrong. diff --git a/openspec/changes/cli-copy-of-issued-rule/proposal.md b/openspec/changes/cli-copy-of-issued-rule/proposal.md new file mode 100644 index 00000000..778e66fb --- /dev/null +++ b/openspec/changes/cli-copy-of-issued-rule/proposal.md @@ -0,0 +1,71 @@ +## Why + +Renaming a rule's directory gets around tamper detection for ast-grep and Vale +rules (taskless/taskless#255). Copy `.taskless/rules/vale/foo-1/` to `bar-2/`, +loosen its `.vale.ini`, and delete `foo-1/`. v2 reconcile answers `bar-2` as +`unknown`, which the CLI runs silently as a locally written rule, and `foo-1` as +`missing`, which only warns. `check` passes with a tampered Taskless rule +running. + +The product decision on #255 keeps deletion legitimate (`missing` stays a +warning, there is no retirement) and closes the hole on the copy. The service +change (taskless/taskless#262, published in `__schema` by taskless/taskless#264) +annotates such an `unknown` rule with `copyOf: { ruleId, revisionId, files }` +when any file it reported has the content of a signed file issued in this +repository, at any path. Its `cli` spec delta states the client obligation this +change implements. 0.12.0 is the first v2 client and is unreleased, so it ships +with the obligation from the start. + +## What Changes + +- An sg or Vale `unknown` rule carrying `copyOf` does not run: it is removed + from the snapshot, and `check` fails naming the source rule and each file that + differs from it. +- When the source is also answered `missing`, the copy and the deletion are + reported as one rename, whose message names `taskless rule restore `. + The source gets no separate `missing` warning. +- A runtime `unknown` rule with `copyOf` is not executed, exactly as before; its + skip reason now names the source. A runtime rename is one notice in place of + the `missing` warning, and does not fail the run. +- A `copyOf` that is present but unreadable fails closed: the rule is + unaccounted, does not run, and fails the run. +- `check --json`'s `integrity` gains an optional `copyOf` on `unknown` entries + (`{ ruleId, revisionId?, sourceMissing }`), with the diff in the existing + `files`. `engine.log` records each copy and whether it is a rename. +- The `check` and `recover-rule` agent recipes describe the failure and the fix + (restore the source, delete the copy). +- Unchanged: `missing` without a copy, `unknown` without `copyOf`, every `run`, + `unsafe`, and withheld outcome, accounting, and the request. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `cli-rule-reconciliation`: the per-engine verdict policy gains the `copyOf` + row and the rename rule; `missing` stays a warning. +- `cli-check`: the exit code fails on a static copy, and `integrity` carries + `copyOf`. + +## Impact + +- `packages/cli/src/rules/verdicts.ts` (policy), `plan-check.ts` (log line), + `src/schemas/check.ts` (`--json` shape). +- Tests: `test/verdicts.test.ts` rows, one end-to-end case in + `test/runtime-check.test.ts`. +- Recipes: `src/agent/check.md`, `src/agent/recover-rule.md`. Both are already + new or bumped in this unreleased cycle, so their topic versions stay. +- The vendored v2 schema (`src/generated/api-v2.*`) is NOT changed: `copyOf` is + read defensively from the untyped response, and the vendored files are + re-generated once #264 is live. +- Changeset: one sentence added to the pending `.changeset/cli-v2-rule-api.md`. + +## Delivery shape + +**Single PR.** The change is one branch in a pure policy function plus its +reporting, tests, recipes, and spec, well under the review budget. It is safe on +its own: without `copyOf` in a response nothing changes, so it can land before +or after the service deploys #264. diff --git a/openspec/changes/cli-copy-of-issued-rule/specs/cli-check/spec.md b/openspec/changes/cli-copy-of-issued-rule/specs/cli-check/spec.md new file mode 100644 index 00000000..b8c36b1d --- /dev/null +++ b/openspec/changes/cli-copy-of-issued-rule/specs/cli-check/spec.md @@ -0,0 +1,86 @@ +## MODIFIED Requirements + +### Requirement: Check subcommand exit codes reflect error severity + +The CLI SHALL exit with code 0 when no error-severity matches are found (including when only warnings, info, or hints exist) and no reconcile outcome below requires failure. The CLI SHALL exit with code 1 when at least one error-severity match is found. The CLI SHALL also exit with code 1, whatever the findings and in both human and `--json` modes, when a completed reconcile: + +- returned a non-empty `entitlement.withheld`; +- returned an `unsafe` verdict for an `sg` or `vale` rule; +- returned an `sg` or `vale` rule in `unknown` carrying `copyOf`, or a rule of any engine carrying a `copyOf` the CLI cannot read; or +- left a reported rule unaccounted for (in none, or more than one, of `rules`, `unknown`, and `entitlement.withheld`). + +On an authenticated run that would reconcile, the CLI SHALL also exit with code 1 when two rule directories under different engines share an id. A logged-out run verifies nothing and does not fail on it. Under `--json`, `success` SHALL be `false` whenever the exit code is non-zero. + +#### Scenario: Exit 0 when clean + +- **WHEN** the scanner produces zero results +- **THEN** the process SHALL exit with code 0 + +#### Scenario: Exit 0 when only warnings + +- **WHEN** the scanner produces results but none have severity "error" +- **THEN** the process SHALL exit with code 0 + +#### Scenario: Exit 1 when errors found + +- **WHEN** the scanner produces at least one result with severity "error" +- **THEN** the process SHALL exit with code 1 + +#### Scenario: Exit 1 when a runtime rule is withheld for entitlement + +- **WHEN** reconciliation returns a non-empty `entitlement.withheld` and the scan produces zero results +- **THEN** the process SHALL exit with code 1 +- **AND** under `--json`, `success` SHALL be `false` + +#### Scenario: Entitlement without a withheld file does not fail + +- **WHEN** reconciliation returns `entitlement.runtimeSignatures: false` with an empty or absent `withheld`, and the scan produces zero results +- **THEN** the process SHALL exit with code 0 + +#### Scenario: Exit 1 when a static rule was edited + +- **WHEN** reconciliation returns an `unsafe` verdict for an `sg` or `vale` rule and the scan produces zero results +- **THEN** the process SHALL exit with code 1 + +#### Scenario: Exit 1 when a static rule is a copy of an issued rule + +- **WHEN** reconciliation returns an `sg` or `vale` rule in `unknown` with `copyOf`, and the scan produces zero results +- **THEN** the process SHALL exit with code 1 + +#### Scenario: Exit 1 when a reported rule is unaccounted for + +- **WHEN** a reported rule appears in none of `rules`, `unknown`, and `entitlement.withheld` +- **THEN** the process SHALL exit with code 1 + +#### Scenario: Missing does not fail + +- **WHEN** reconciliation returns only `run` and `missing` verdicts and the scan produces zero results +- **THEN** the process SHALL exit with code 0 + +### Requirement: Check reports rule integrity under --json + +Under `--json`, `taskless check` SHALL carry an additive, optional `integrity` array with one +entry per rule whose outcome is `unsafe`, `missing`, runtime `unknown`, `unknown` with +`copyOf` (any engine), `unaccounted`, or `duplicate`, each +`{ ruleId, engine?, verdict, files?, revisionId?, copyOf? }`. `files` SHALL list each +differing path with `expected` and `got` as the server returned them; for a copy it SHALL be +the server's `copyOf.files`. `copyOf` SHALL be `{ ruleId, revisionId?, sourceMissing }`, where +`sourceMissing` is whether the source rule was answered `missing`. Static `unknown` rules +without `copyOf` and `run` rules SHALL NOT appear. The field SHALL be omitted when there is +nothing to report. + +#### Scenario: An edited rule appears with its differing files + +- **WHEN** reconciliation returns `unsafe` for vale rule `no-simply-1a2b3c4d` with `.vale.ini` changed +- **THEN** `integrity` SHALL include `{ ruleId: "no-simply-1a2b3c4d", engine: "vale", verdict: "unsafe", files: [{ path: ".vale.ini", expected, got }] }` + +#### Scenario: A clean run omits the field + +- **WHEN** every reported rule is `run` or static `unknown` and nothing is `missing` +- **THEN** `check --json` SHALL NOT include `integrity` + +#### Scenario: A renamed rule appears as a copy beside its missing source + +- **WHEN** reconciliation returns vale rule `bar-2` in `unknown` with `copyOf: { ruleId: "foo-1", revisionId: "r1", files: [{ path: ".vale.ini", expected, got }] }` and returns `foo-1` as `missing` with `revisionId` `r2` +- **THEN** `integrity` SHALL include `{ ruleId: "bar-2", engine: "vale", verdict: "unknown", files: [{ path: ".vale.ini", expected, got }], copyOf: { ruleId: "foo-1", revisionId: "r1", sourceMissing: true } }` +- **AND** SHALL include `{ ruleId: "foo-1", engine: "vale", verdict: "missing", revisionId: "r2" }` diff --git a/openspec/changes/cli-copy-of-issued-rule/specs/cli-rule-reconciliation/spec.md b/openspec/changes/cli-copy-of-issued-rule/specs/cli-rule-reconciliation/spec.md new file mode 100644 index 00000000..d0c8ac3b --- /dev/null +++ b/openspec/changes/cli-copy-of-issued-rule/specs/cli-rule-reconciliation/spec.md @@ -0,0 +1,97 @@ +## MODIFIED Requirements + +### Requirement: Reconcile verdicts are applied per engine + +The CLI SHALL read the v2 reconcile response as a list of per-rule verdicts +(`rules[]`, each `{ ruleId, engine, verdict }` with `verdict` one of `run`, `unsafe`, +`missing`), a list of `unknown` rules (each `{ ruleId, copyOf? }`), and +`entitlement.withheld`, and SHALL apply this policy: + +| Verdict | runtime | sg / vale | +| -------------------- | -------------------------------------------------- | --------------------------------------------- | +| `run` | execute | run | +| `withheld` | do not execute; fail `check` | (never sent) | +| `unsafe` | do not execute; name `rule restore` | do not run; fail `check`; name `rule restore` | +| `missing` | warn; name `rule restore` | warn; name `rule restore` | +| `unknown` | do not execute (needs `--dangerously-run-scripts`) | run | +| `unknown` + `copyOf` | do not execute; name the source | do not run; fail `check`; name the source | + +The engine SHALL be taken from the verdict's `engine` for `rules[]` entries and from the +reporting directory for `unknown` entries. An `unsafe` notice SHALL name the rule and each +differing path, saying whether it changed, was removed, or was added. A signature SHALL +authorize running a runtime rule only through a `run` verdict, never by local comparison. + +#### Scenario: An edited static rule fails and does not run + +- **WHEN** reconcile returns `{ ruleId: "no-simply-1a2b3c4d", engine: "vale", verdict: "unsafe", files: [{ path: ".vale.ini", expected, got }] }` +- **THEN** the rule SHALL NOT run +- **AND** `check` SHALL exit non-zero naming the rule and `.vale.ini` as changed + +#### Scenario: A locally written static rule runs + +- **WHEN** reconcile lists a static rule's id in `unknown` without `copyOf` +- **THEN** that rule SHALL run +- **AND** the CLI SHALL emit no notice for it + +#### Scenario: A locally written runtime rule does not execute + +- **WHEN** reconcile lists a runtime rule's id in `unknown` and `--dangerously-run-scripts` is not set +- **THEN** the rule SHALL NOT execute +- **AND** its skip reason SHALL say it was not issued by the rule service + +#### Scenario: Missing warns and does not fail + +- **WHEN** reconcile returns a `missing` verdict for any engine, and no `unknown` rule names it in `copyOf` +- **THEN** the CLI SHALL warn naming the rule and `taskless rule restore ` +- **AND** SHALL NOT change the exit code because of it + +#### Scenario: An edited runtime rule is withheld, not failed + +- **WHEN** reconcile returns an `unsafe` verdict for a runtime rule +- **THEN** the rule SHALL NOT execute +- **AND** the exit code SHALL NOT change because of that verdict alone + +## ADDED Requirements + +### Requirement: A copy of an issued rule does not run as a local rule + +When reconcile returns an `unknown` rule carrying `copyOf` (taskless/taskless#255), the CLI +SHALL NOT run or execute it, and SHALL remove it from the snapshot the engines read. For an +`sg` or `vale` rule, `check` SHALL fail with one message naming the rule, the source rule +`copyOf.ruleId`, and each path in `copyOf.files` as changed, removed, or added. For a runtime +rule, the exit code SHALL NOT change, and its skip reason SHALL name the source and say it +was not issued by the rule service. + +When `copyOf.ruleId` is also returned as `missing`, the CLI SHALL report the pair as one +rename: the copy's message SHALL say the source was deleted and SHALL name +`taskless rule restore `, and the CLI SHALL NOT print a separate `missing` +warning for the source. A runtime rename SHALL be one notice and SHALL NOT change the exit +code. `copyOf` absent or `null` SHALL be treated as no copy. A `copyOf` that is present but +has no non-empty string `ruleId` SHALL fail closed: the rule SHALL be treated as +unaccounted. + +#### Scenario: A renamed and loosened Vale rule fails check as one rename + +- **WHEN** reconcile returns vale rule `bar-2` in `unknown` with `copyOf.ruleId` `foo-1` and `copyOf.files` listing `.vale.ini` changed, and returns `foo-1` as `missing` +- **THEN** `bar-2` SHALL NOT run +- **AND** `check` SHALL exit non-zero with one message saying `bar-2` is a copy of `foo-1`, which was deleted, naming `.vale.ini` as changed and `taskless rule restore foo-1` +- **AND** the CLI SHALL NOT print a separate warning that `foo-1` is missing + +#### Scenario: A copy beside its present source fails check + +- **WHEN** reconcile returns sg rule `bar-2` in `unknown` with `copyOf.ruleId` `foo-1`, and `foo-1` is not `missing` +- **THEN** `bar-2` SHALL NOT run +- **AND** `check` SHALL exit non-zero naming `bar-2` as a copy of `foo-1` + +#### Scenario: A runtime copy is not executed and does not fail + +- **WHEN** reconcile returns a runtime rule in `unknown` with `copyOf` +- **THEN** the rule SHALL NOT execute +- **AND** its skip reason SHALL name the source rule +- **AND** the exit code SHALL NOT change because of it + +#### Scenario: An unreadable copyOf fails closed + +- **WHEN** reconcile returns a rule in `unknown` whose `copyOf` is present but has no string `ruleId` +- **THEN** the rule SHALL NOT run or execute +- **AND** `check` SHALL exit non-zero naming it diff --git a/openspec/changes/cli-copy-of-issued-rule/tasks.md b/openspec/changes/cli-copy-of-issued-rule/tasks.md new file mode 100644 index 00000000..8cc32ded --- /dev/null +++ b/openspec/changes/cli-copy-of-issued-rule/tasks.md @@ -0,0 +1,34 @@ +## 1. Policy + +- [x] 1.1 `verdicts.ts`: read `copyOf` from each `unknown` entry (absent/`null` + → none; unreadable → unaccounted, fail closed) +- [x] 1.2 Static copy: not run, removed from the snapshot, one failure naming + the source and the differing files +- [x] 1.3 Rename: when the source is answered `missing`, one message naming + `rule restore `, and no separate `missing` notice +- [x] 1.4 Runtime copy: not executed, source named in the skip reason; a + runtime rename is one notice and does not fail +- [x] 1.5 `integrity`: `copyOf: { ruleId, revisionId?, sourceMissing }` on the + `unknown` entry, diff in `files`; `--json` schema in `schemas/check.ts` +- [x] 1.6 `plan-check.ts`: `engine.log` line per copy + +## 2. Tests + +- [x] 2.1 `verdicts.test.ts`: copy alone, copy + missing source (rename), a + missing rule no copy names, runtime copy, runtime rename, `copyOf: null`, + malformed `copyOf` for sg and runtime +- [x] 2.2 `runtime-check.test.ts`: end-to-end rename against the mock v2 + reconcile, including `engine.log` + +## 3. Docs and release note + +- [x] 3.1 `agent/check.md` and `agent/recover-rule.md` (no topic bump: both + are new or bumped since v0.11.2) +- [x] 3.2 One sentence in `.changeset/cli-v2-rule-api.md` +- [x] 3.3 Spec deltas; dry-run the archive and confirm every standing scenario + survives + +## 4. Checks + +- [x] 4.1 `pnpm typecheck`, `pnpm lint`, `pnpm --filter @taskless/cli test`, + `pnpm openspec validate --all --strict` diff --git a/packages/cli/src/agent/check.md b/packages/cli/src/agent/check.md index 99977da6..bde02a25 100644 --- a/packages/cli/src/agent/check.md +++ b/packages/cli/src/agent/check.md @@ -32,6 +32,7 @@ logged in: | **edited** since it was issued | **does not run, and `check` exits 1** | does not run (reported, exit unchanged) | | issued but missing from disk | warning only | warning only | | written locally (never issued) | runs, silently | does not run | + | a **copy** of an issued rule under a new id | **does not run, and `check` exits 1** | does not run | | withheld for the plan | never happens | does not run, and `check` exits 1 | `check` also exits 1 if the service's answer leaves out a rule it was @@ -76,11 +77,47 @@ rewritten as a local rule under a new id. An edited rule is exactly what an agent tuning a rule until its own violation passes looks like, which is why `check` refuses it. +## A copied or renamed rule + +A rule under a new id that still carries a file Taskless issued to +another rule of this repository is a copy, not a local rule. The +service names the rule it came from. An ast-grep or Vale copy does not +run and `check` exits 1; a runtime copy does not run either (no exit +change). When the source rule is also missing from disk, the two are +one rename and are reported once, in place of the source's missing +warning: + +``` +vale rule bar-2 is a copy of Taskless rule foo-1, which was deleted (changed .vale.ini), so it did not run and `check` fails. Run `%(TASKLESS_CLI)s rule restore foo-1` to put back the issued rule, then delete .taskless/rules/vale/bar-2/. +``` + +In `integrity` the copy is `unknown` with a `copyOf`, and `files` is +what differs from the source (a file carried unchanged under a new name +is not listed). The source keeps its own `missing` entry: + +```json +"integrity": [ + { "ruleId": "bar-2", "engine": "vale", "verdict": "unknown", + "files": [{ "path": ".vale.ini", "expected": "1;h=…", "got": "1;h=…" }], + "copyOf": { "ruleId": "foo-1", "revisionId": "…", "sourceMissing": true } }, + { "ruleId": "foo-1", "engine": "vale", "verdict": "missing", "revisionId": "…" } +] +``` + +**Restore the source and delete the copy.** Run +`%(TASKLESS_CLI)s rule restore `, then remove +`.taskless/rules///`. Do not restore the copy's own id: +it was never issued. If the source is still present +(`sourceMissing: false`), just delete the copy. If the user genuinely +wants a new local rule, it must not carry Taskless's files unchanged; +write it from scratch. Renaming an issued rule is not a way to edit it. + `integrity` also lists `missing` rules (with the `revisionId` restore -would bring back), runtime rules the service never issued (`unknown`), +would bring back), runtime rules the service never issued and copies of issued rules +(`unknown`), rules the answer did not account for (`unaccounted`), and ids shared across engines (`duplicate`). Locally written ast-grep and Vale rules -are never listed; they run. +are never listed; they run. A copy is listed with its `copyOf`. ## Withheld for the plan @@ -179,9 +216,10 @@ delete the rules to make `check` pass, and do not suggest useful line/column to surface. The `success` field reflects error-severity findings: `success: false` means at least one `severity: "error"` finding exists, runtime rules were withheld for - the plan (check `entitlement`), or a rule was edited or unaccounted - for (check `failures` and `integrity`) (exit code 1); `success: true` - with a non-empty `results` array means there are only + the plan (check `entitlement`), or a rule was edited, copied from an + issued rule, or unaccounted for (check `failures` and `integrity`) + (exit code 1); `success: true` with a non-empty `results` array + means there are only warning/info/hint findings (exit code 0); `success: true` with an empty `results` array means the codebase is clean. Findings are never reported via the `{ ok: false, code, message }` envelope: @@ -194,7 +232,8 @@ delete the rules to make `check` pass, and do not suggest paths missing - `1`: Errors detected, scan failed, runtime rules withheld because the plan does not include them, an issued ast-grep or Vale rule was - edited, a rule was unaccounted for, or two engines share a rule id + edited or copied under a new id, a rule was unaccounted for, or two + engines share a rule id ## Errors diff --git a/packages/cli/src/agent/recover-rule.md b/packages/cli/src/agent/recover-rule.md index eb1fe5a1..1f2629b3 100644 --- a/packages/cli/src/agent/recover-rule.md +++ b/packages/cli/src/agent/recover-rule.md @@ -29,7 +29,9 @@ write nothing they cannot verify. 1. **Take the rule id from `check`.** Under `--json`, each entry in `integrity` with `verdict` `unsafe` or `missing` names a rule - `rule restore` repairs. + `rule restore` repairs. An `unknown` entry with a `copyOf` is a copy + of an issued rule under a new id: restore `copyOf.ruleId` (the + source), never the copy's own id, then delete the copy's directory. 2. **Restore it.** ``` diff --git a/packages/cli/src/rules/plan-check.ts b/packages/cli/src/rules/plan-check.ts index c0eeb04d..6d7ce628 100644 --- a/packages/cli/src/rules/plan-check.ts +++ b/packages/cli/src/rules/plan-check.ts @@ -262,6 +262,13 @@ export async function planCheck( `missing: ${entry.ruleId} (revision ${entry.revisionId ?? "unknown"})` ); } + if (entry.copyOf !== undefined) { + log.write( + `copy: ${entry.ruleId} carries files of ${entry.copyOf.ruleId} (revision ${ + entry.copyOf.revisionId ?? "unknown" + })${entry.copyOf.sourceMissing ? ", which is missing: a rename" : ""}` + ); + } } const execute = await discoverRuntimeRulesIn(runtimeRoot); diff --git a/packages/cli/src/rules/verdicts.ts b/packages/cli/src/rules/verdicts.ts index 8ede493d..ff09c29a 100644 --- a/packages/cli/src/rules/verdicts.ts +++ b/packages/cli/src/rules/verdicts.ts @@ -19,6 +19,7 @@ import type { ReportedRule } from "./report"; * | `unsafe` | not executed; restore offered | not run; FAILS; restore offered | * | `missing` | warn; restore offered | warn; restore offered | * | `unknown` | not executed | run, silently | + * | ... `copyOf` | not executed; source named | not run; FAILS; source named | * | unaccounted | not executed; fails | not run; fails | * * **Accounting is computed, not trusted.** Every reported rule must land in @@ -26,6 +27,15 @@ import type { ReportedRule } from "./report"; * answer drops, or answers twice, is not run and fails the run. That is what * turns a parser that silently drops withheld entries (#403), or a service * that forgets a rule, into a red run instead of a green one. + * + * **A copy of an issued rule is not a local rule** (taskless/taskless#255). + * Copying an issued rule to a new directory, loosening it, and deleting the + * original once produced an `unknown` rule that ran silently plus a `missing` + * warning. The service now marks such an `unknown` rule with `copyOf`, naming + * the issued rule whose file it carries. A static copy does not run and fails + * the run. When its source is also `missing`, the two are one event, a rename, + * and are reported once: the copy's failure says the source was deleted, and + * the source gets no separate warning. */ /** A differing file, as the service reported it. */ @@ -44,6 +54,15 @@ export type IntegrityVerdict = | "unaccounted" | "duplicate"; +/** The issued rule an `unknown` rule was copied from, as `check --json` reports it. */ +export interface IntegrityCopyOf { + ruleId: string; + /** The source revision the copy matches best. */ + revisionId?: string; + /** Whether the source was answered `missing`: the copy is a rename. */ + sourceMissing: boolean; +} + /** A non-`run` outcome worth reporting, for `check --json`'s `integrity`. */ export interface IntegrityEntry { ruleId: string; @@ -51,6 +70,8 @@ export interface IntegrityEntry { verdict: IntegrityVerdict; files?: DifferingFile[]; revisionId?: string; + /** For an `unknown` rule that carries an issued rule's file. */ + copyOf?: IntegrityCopyOf; } /** What happens to one reported rule. */ @@ -101,6 +122,40 @@ function readFiles(value: unknown): DifferingFile[] { })); } +/** An `unknown` entry's `copyOf`, as far as it could be read. */ +type CopyOfRead = + | { status: "none" } + | { status: "malformed" } + | { + status: "copy"; + ruleId: string; + revisionId?: string; + files: DifferingFile[]; + }; + +/** + * Read `copyOf` from an `unknown` entry. + * + * Absent (or `null`) is a local rule. Present but unreadable is + * `malformed`, which fails closed: the service only sends `copyOf` when it + * found issued content, so an unreadable one still says "this is a copy", and + * running the rule would ignore exactly that. + */ +function readCopyOf(value: unknown): CopyOfRead { + if (value === undefined || value === null) return { status: "none" }; + if (!isRecord(value) || typeof value.ruleId !== "string" || !value.ruleId) { + return { status: "malformed" }; + } + return { + status: "copy", + ruleId: value.ruleId, + ...(typeof value.revisionId === "string" + ? { revisionId: value.revisionId } + : {}), + files: readFiles(value.files), + }; +} + /** "changed .vale.ini; removed captures/a.yml; added extra.yml" */ export function describeDifferences(files: readonly DifferingFile[]): string { if (files.length === 0) return "its files differ from what was issued"; @@ -129,6 +184,73 @@ function unaccounted(plan: VerdictPlan, rule: ReportedRule, why: string): void { plan.failures.push(`${engine} rule ${ruleId} did not run: ${why}.`); } +/** + * Apply `copyOf` to an `unknown` rule. The rule never runs. A static copy fails + * the run; a runtime one is not executed, as any `unknown` runtime rule, and + * fails nothing. When the source is `missing`, the message describes a rename + * and points at restoring the source. + */ +function applyCopy( + plan: VerdictPlan, + rule: ReportedRule, + copy: Extract, + sourceMissing: boolean, + restoreCommand: (ruleId: string) => string +): void { + const { ruleId, engine } = rule; + const source = copy.ruleId; + const changes = + copy.files.length === 0 ? "" : ` (${describeDifferences(copy.files)})`; + const directory = `.taskless/rules/${engine}/${ruleId}/`; + const what = sourceMissing + ? `is a copy of Taskless rule ${source}, which was deleted${changes}` + : `is a copy of Taskless rule ${source}${changes}`; + const fix = sourceMissing + ? `Run \`${restoreCommand(source)}\` to put back the issued rule, then delete ${directory}.` + : `Delete ${directory}, or rewrite the files it carries from ${source} so it is your own rule.`; + + plan.integrity.push({ + ruleId, + engine, + verdict: "unknown", + files: copy.files, + copyOf: { + ruleId: source, + ...(copy.revisionId === undefined ? {} : { revisionId: copy.revisionId }), + sourceMissing, + }, + }); + + if (engine === "runtime") { + plan.dispositions.push({ + ruleId, + engine, + run: false, + verdict: "unknown", + reason: `a copy of Taskless rule ${source}${changes}, not issued by the rule service for this repository, so it runs only with --dangerously-run-scripts`, + }); + // A plain runtime copy is already covered by its skip reason. A rename + // takes the place of the source's `missing` warning, so it is a notice. + if (sourceMissing) { + plan.notices.push( + `runtime rule ${ruleId} ${what}, so it did not run. ${fix}` + ); + } + return; + } + + plan.dispositions.push({ + ruleId, + engine, + run: false, + verdict: "unknown", + reason: `a copy of Taskless rule ${source}${changes}`, + }); + plan.failures.push( + `${engine} rule ${ruleId} ${what}, so it did not run and \`check\` fails. ${fix}` + ); +} + /** * Apply a reconcile response to the rules that were reported. * @@ -145,9 +267,10 @@ export function applyVerdicts( const withheldIds = (entitlement?.withheld ?? []).map( (entry) => entry.ruleId ); - const unknownIds = records(body.unknown) - .map((entry) => entry.ruleId) - .filter((id): id is string => typeof id === "string"); + const unknownEntries = records(body.unknown).filter( + (entry) => typeof entry.ruleId === "string" + ); + const unknownIds = unknownEntries.map((entry) => entry.ruleId as string); const verdicts = records(body.rules).filter( (entry) => typeof entry.ruleId === "string" ); @@ -162,6 +285,17 @@ export function applyVerdicts( }; const reportedIds = new Set(reported.map((rule) => rule.ruleId)); + // Rules answered `missing` that were not reported: a copy naming one of + // these as its source is a rename. + const missingIds = new Set( + verdicts + .filter((entry) => entry.verdict === "missing") + .map((entry) => entry.ruleId as string) + .filter((id) => !reportedIds.has(id)) + ); + // Sources already reported as half of a rename, so their `missing` + // notice is not repeated. + const renamed = new Set(); for (const rule of reported) { const { ruleId, engine } = rule; @@ -194,6 +328,24 @@ export function applyVerdicts( } if (inUnknown === 1) { + const entry = unknownEntries.find( + (candidate) => candidate.ruleId === ruleId + ); + const copy = readCopyOf(entry?.copyOf); + if (copy.status === "malformed") { + unaccounted( + plan, + rule, + "the rule service marked it as a copy of an issued rule without saying which" + ); + continue; + } + if (copy.status === "copy") { + const sourceMissing = missingIds.has(copy.ruleId); + if (sourceMissing) renamed.add(copy.ruleId); + applyCopy(plan, rule, copy, sourceMissing, restoreCommand); + continue; + } if (engine === "runtime") { const reason = "not issued by the rule service for this repository, so it runs only with --dangerously-run-scripts"; @@ -298,6 +450,8 @@ export function applyVerdicts( verdict: "missing", ...(revisionId === undefined ? {} : { revisionId }), }); + // Half of a rename: the copy's own message already says it was deleted. + if (renamed.has(ruleId)) continue; plan.notices.push( `${engine ?? "A"} rule ${ruleId} was issued for this repository but is not in .taskless/rules/. Run \`${restoreCommand(ruleId)}\` to bring it back, or ignore this if it was removed on purpose.` ); diff --git a/packages/cli/src/schemas/check.ts b/packages/cli/src/schemas/check.ts index 000aa841..edc020e5 100644 --- a/packages/cli/src/schemas/check.ts +++ b/packages/cli/src/schemas/check.ts @@ -77,6 +77,7 @@ export const outputSchema = z.object({ // a runtime rule the service never issued, unaccounted for, or an id shared // across engines. Locally written ast-grep and Vale rules are `unknown` too // and are deliberately NOT listed: they run, and every run would repeat them. + // The exception is a copy of an issued rule (`copyOf`), which does not run. runDirectory: z .string() .optional() @@ -91,7 +92,7 @@ export const outputSchema = z.object({ verdict: z .enum(["unsafe", "missing", "unknown", "unaccounted", "duplicate"]) .describe( - "unsafe: edited since issued; missing: issued but not on disk; unknown: a runtime rule the service never issued; unaccounted: the service's answer did not account for it; duplicate: its id is used by more than one engine" + "unsafe: edited since issued; missing: issued but not on disk; unknown: a runtime rule the service never issued, or a rule of any engine carrying an issued rule's file (see copyOf); unaccounted: the service's answer did not account for it; duplicate: its id is used by more than one engine" ), files: z .array( @@ -108,11 +109,30 @@ export const outputSchema = z.object({ }) ) .optional() - .describe("For unsafe: each file that differs from what was issued"), + .describe( + "For unsafe: each file that differs from what was issued. For a copy: each file that differs from its source revision" + ), revisionId: z .string() .optional() .describe("For missing: the revision `rule restore` brings back"), + copyOf: z + .object({ + ruleId: z.string().describe("The issued rule it was copied from"), + revisionId: z + .string() + .optional() + .describe("The source revision it matches best"), + sourceMissing: z + .boolean() + .describe( + "The source is also missing from disk: the copy is a rename, and restoring the source is the fix" + ), + }) + .optional() + .describe( + "For unknown: the rule carries a file Taskless issued to another rule. An ast-grep or Vale copy does not run and fails the check" + ), }) ) .optional() diff --git a/packages/cli/test/runtime-check.test.ts b/packages/cli/test/runtime-check.test.ts index cf1e2053..5491aeb7 100644 --- a/packages/cli/test/runtime-check.test.ts +++ b/packages/cli/test/runtime-check.test.ts @@ -96,7 +96,8 @@ type Answer = | "unknown" | "withheld" | "omit" - | { unsafe: { path: string; expected?: string; got?: string }[] }; + | { unsafe: { path: string; expected?: string; got?: string }[] } + | { copyOf: { ruleId: string; revisionId: string; files: unknown[] } }; const UPGRADE_URL = "https://app.taskless.io/o/acme/upgrade?from=reconcile"; @@ -112,7 +113,7 @@ function answer( } = {} ) { const rules: unknown[] = []; - const unknown: { ruleId: string }[] = []; + const unknown: { ruleId: string; copyOf?: unknown }[] = []; const withheld: { ruleId: string; revisionId: string }[] = []; for (const { ruleId } of request.rules) { const verdict = answers[ruleId] ?? "unknown"; @@ -134,6 +135,10 @@ function answer( break; } default: { + if ("copyOf" in verdict) { + unknown.push({ ruleId, copyOf: verdict.copyOf }); + break; + } rules.push({ ruleId, engine, @@ -211,6 +216,7 @@ interface CheckJson { verdict: string; files?: unknown[]; revisionId?: string; + copyOf?: unknown; }[]; entitlement?: { runtimeSignatures: false; @@ -482,6 +488,83 @@ describe("check: static vs runtime dispatch", () => { expect(output.notices?.join("\n")).toMatch(/rule restore gone-3fa9c21b/); }); + it("a renamed copy of an issued rule does not run, fails once as a rename, and logs it", async () => { + await migrateFixture(["-d", directory]); + const before = await treeDigest(directory); + const copyOf = { + ruleId: "no-console-3fa9c21b", + revisionId: "rev-4", + files: [ + { + path: "no-console.yml", + expected: "1;h=sha-256;d=00", + got: "1;h=sha-256;d=11", + }, + ], + }; + const { stdout, exitCode } = await authedCheck( + (request) => ({ + statusCode: 200, + body: answer( + request, + { demo: "run", "no-console": { copyOf } }, + { + missing: [ + { + ruleId: "no-console-3fa9c21b", + engine: "sg", + revisionId: "rev-5", + }, + ], + } + ), + }), + ["--json", "--preserve-logs"] + ); + const output = parseJson(stdout) as CheckJson & { runDirectory?: string }; + expect(exitCode).toBe(1); + expect(output.success).toBe(false); + // Removed from the snapshot: the copy found nothing, the runtime rule ran. + expect(output.results.some((r) => r.ruleId === "no-console")).toBe(false); + expect(output.results.some((r) => r.source === "taskless-runtime")).toBe( + true + ); + expect(output.failures).toHaveLength(1); + expect(output.failures?.[0]).toMatch( + /sg rule no-console is a copy of Taskless rule no-console-3fa9c21b, which was deleted \(changed no-console\.yml\).*rule restore no-console-3fa9c21b/ + ); + // One finding: the source's own missing warning is folded into the rename. + expect(output.notices ?? []).toEqual([]); + expect(output.integrity).toEqual([ + { + ruleId: "no-console", + engine: "sg", + verdict: "unknown", + files: copyOf.files, + copyOf: { + ruleId: "no-console-3fa9c21b", + revisionId: "rev-4", + sourceMissing: true, + }, + }, + { + ruleId: "no-console-3fa9c21b", + engine: "sg", + verdict: "missing", + revisionId: "rev-5", + }, + ]); + const engineLog = await readFile( + join(directory, output.runDirectory ?? "", "engine.log"), + "utf8" + ); + expect(engineLog).toContain( + "copy: no-console carries files of no-console-3fa9c21b (revision rev-4), which is missing: a rename" + ); + expect(engineLog).toMatch(/sg\/no-console: unknown, excluded \(a copy of/); + expect(await treeDigest(directory)).toBe(before); + }); + it("a reported rule the answer does not account for does not run and fails the run", async () => { const { stdout, exitCode } = await authedCheck((request) => ({ statusCode: 200, diff --git a/packages/cli/test/verdicts.test.ts b/packages/cli/test/verdicts.test.ts index c93eddf9..b88aa2b7 100644 --- a/packages/cli/test/verdicts.test.ts +++ b/packages/cli/test/verdicts.test.ts @@ -124,6 +124,194 @@ describe("applyVerdicts", () => { ]); }); + describe("copyOf (taskless/taskless#255)", () => { + const SOURCE = "no-simply-00000000"; + const COPY_OF = { + ruleId: SOURCE, + revisionId: "r7", + files: [{ path: ".vale.ini", expected: "e", got: "g" }], + }; + const missingSource = { + ruleId: SOURCE, + engine: "vale", + verdict: "missing", + revisionId: "r8", + }; + + it("a static copy whose source is still present does not run and fails, naming the source", () => { + const plan = applyVerdicts( + [VALE], + { + rules: [], + unknown: [{ ruleId: VALE.ruleId, copyOf: COPY_OF }], + entitlement: entitled, + }, + restore + ); + expect(plan.dispositions).toEqual([ + { + ruleId: VALE.ruleId, + engine: "vale", + run: false, + verdict: "unknown", + reason: `a copy of Taskless rule ${SOURCE} (changed .vale.ini)`, + }, + ]); + expect(plan.failures).toEqual([ + `vale rule ${VALE.ruleId} is a copy of Taskless rule ${SOURCE} (changed .vale.ini), so it did not run and \`check\` fails. Delete .taskless/rules/vale/${VALE.ruleId}/, or rewrite the files it carries from ${SOURCE} so it is your own rule.`, + ]); + expect(plan.notices).toEqual([]); + expect(plan.integrity).toEqual([ + { + ruleId: VALE.ruleId, + engine: "vale", + verdict: "unknown", + files: COPY_OF.files, + copyOf: { ruleId: SOURCE, revisionId: "r7", sourceMissing: false }, + }, + ]); + }); + + it("a static copy whose source is missing is ONE rename failure, not a copy plus a missing warning", () => { + const plan = applyVerdicts( + [VALE], + { + rules: [missingSource], + unknown: [{ ruleId: VALE.ruleId, copyOf: COPY_OF }], + entitlement: entitled, + }, + restore + ); + expect(plan.dispositions).toMatchObject([{ run: false }]); + expect(plan.failures).toEqual([ + `vale rule ${VALE.ruleId} is a copy of Taskless rule ${SOURCE}, which was deleted (changed .vale.ini), so it did not run and \`check\` fails. Run \`${restore(SOURCE)}\` to put back the issued rule, then delete .taskless/rules/vale/${VALE.ruleId}/.`, + ]); + expect(plan.notices).toEqual([]); + // Both facts stay machine-readable: the missing entry carries the + // revision restore brings back. + expect(plan.integrity).toEqual([ + expect.objectContaining({ + ruleId: VALE.ruleId, + copyOf: { ruleId: SOURCE, revisionId: "r7", sourceMissing: true }, + }), + { + ruleId: SOURCE, + engine: "vale", + verdict: "missing", + revisionId: "r8", + }, + ]); + }); + + it("a missing rule no copy names still warns on its own", () => { + const plan = applyVerdicts( + [SG], + { + rules: [{ ...missingSource, ruleId: "other-11111111" }], + unknown: [{ ruleId: SG.ruleId, copyOf: COPY_OF }], + entitlement: entitled, + }, + restore + ); + expect(plan.failures).toHaveLength(1); + expect(plan.failures[0]).toContain(`sg rule ${SG.ruleId} is a copy of`); + expect(plan.failures[0]).not.toContain("deleted"); + expect(plan.notices).toHaveLength(1); + expect(plan.notices[0]).toContain(restore("other-11111111")); + }); + + it("a runtime copy is not executed and does not fail; its skip reason names the source", () => { + const plan = applyVerdicts( + [RT], + { + rules: [], + unknown: [{ ruleId: RT.ruleId, copyOf: { ...COPY_OF, files: [] } }], + entitlement: entitled, + }, + restore + ); + expect(plan.failures).toEqual([]); + expect(plan.notices).toEqual([]); + expect(plan.dispositions).toEqual([ + { + ruleId: RT.ruleId, + engine: "runtime", + run: false, + verdict: "unknown", + reason: `a copy of Taskless rule ${SOURCE}, not issued by the rule service for this repository, so it runs only with --dangerously-run-scripts`, + }, + ]); + expect(plan.integrity[0]).toMatchObject({ + verdict: "unknown", + copyOf: { ruleId: SOURCE, sourceMissing: false }, + }); + }); + + it("a runtime rename is one notice in place of the missing warning, and does not fail", () => { + const plan = applyVerdicts( + [RT], + { + rules: [{ ...missingSource, engine: "runtime" }], + unknown: [{ ruleId: RT.ruleId, copyOf: COPY_OF }], + entitlement: entitled, + }, + restore + ); + expect(plan.failures).toEqual([]); + expect(plan.notices).toEqual([ + `runtime rule ${RT.ruleId} is a copy of Taskless rule ${SOURCE}, which was deleted (changed .vale.ini), so it did not run. Run \`${restore(SOURCE)}\` to put back the issued rule, then delete .taskless/rules/runtime/${RT.ruleId}/.`, + ]); + }); + + it("copyOf: null is a local rule, as if absent", () => { + const plan = applyVerdicts( + [SG], + { + rules: [], + unknown: [{ ruleId: SG.ruleId, copyOf: null }], + entitlement: entitled, + }, + restore + ); + expect(plan.dispositions).toMatchObject([{ run: true }]); + expect(plan.failures).toEqual([]); + }); + + it.each([ + ["a string", "no-simply-00000000"], + ["an object without ruleId", { revisionId: "r7", files: [] }], + ["an empty ruleId", { ruleId: "" }], + ])( + "a copyOf that is %s fails closed: the rule does not run and the run fails", + (_label, copyOf) => { + for (const rule of [SG, RT]) { + const plan = applyVerdicts( + [rule], + { + rules: [], + unknown: [{ ruleId: rule.ruleId, copyOf }], + entitlement: entitled, + }, + restore + ); + expect(plan.dispositions).toMatchObject([ + { run: false, verdict: "unaccounted" }, + ]); + expect(plan.failures[0]).toContain( + "marked it as a copy of an issued rule without saying which" + ); + expect(plan.integrity).toEqual([ + { + ruleId: rule.ruleId, + engine: rule.engine, + verdict: "unaccounted", + }, + ]); + } + } + ); + }); + it("withheld is matched by rule id, never runs, and is not offered restore", () => { const plan = applyVerdicts( [RT], From ff127d2179b12ed1b59c53eee128a7b18920be78 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 20:35:20 -0700 Subject: [PATCH 2/3] docs(openspec): archive cli-copy-of-issued-rule --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/cli-check/spec.md | 0 .../specs/cli-rule-reconciliation/spec.md | 0 .../tasks.md | 0 openspec/specs/cli-check/spec.md | 26 +++++-- .../specs/cli-rule-reconciliation/spec.md | 68 +++++++++++++++---- 8 files changed, 77 insertions(+), 17 deletions(-) rename openspec/changes/{cli-copy-of-issued-rule => archive/2026-09-29-cli-copy-of-issued-rule}/.openspec.yaml (100%) rename openspec/changes/{cli-copy-of-issued-rule => archive/2026-09-29-cli-copy-of-issued-rule}/design.md (100%) rename openspec/changes/{cli-copy-of-issued-rule => archive/2026-09-29-cli-copy-of-issued-rule}/proposal.md (100%) rename openspec/changes/{cli-copy-of-issued-rule => archive/2026-09-29-cli-copy-of-issued-rule}/specs/cli-check/spec.md (100%) rename openspec/changes/{cli-copy-of-issued-rule => archive/2026-09-29-cli-copy-of-issued-rule}/specs/cli-rule-reconciliation/spec.md (100%) rename openspec/changes/{cli-copy-of-issued-rule => archive/2026-09-29-cli-copy-of-issued-rule}/tasks.md (100%) diff --git a/openspec/changes/cli-copy-of-issued-rule/.openspec.yaml b/openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/.openspec.yaml similarity index 100% rename from openspec/changes/cli-copy-of-issued-rule/.openspec.yaml rename to openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/.openspec.yaml diff --git a/openspec/changes/cli-copy-of-issued-rule/design.md b/openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/design.md similarity index 100% rename from openspec/changes/cli-copy-of-issued-rule/design.md rename to openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/design.md diff --git a/openspec/changes/cli-copy-of-issued-rule/proposal.md b/openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/proposal.md similarity index 100% rename from openspec/changes/cli-copy-of-issued-rule/proposal.md rename to openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/proposal.md diff --git a/openspec/changes/cli-copy-of-issued-rule/specs/cli-check/spec.md b/openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/specs/cli-check/spec.md similarity index 100% rename from openspec/changes/cli-copy-of-issued-rule/specs/cli-check/spec.md rename to openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/specs/cli-check/spec.md diff --git a/openspec/changes/cli-copy-of-issued-rule/specs/cli-rule-reconciliation/spec.md b/openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/specs/cli-rule-reconciliation/spec.md similarity index 100% rename from openspec/changes/cli-copy-of-issued-rule/specs/cli-rule-reconciliation/spec.md rename to openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/specs/cli-rule-reconciliation/spec.md diff --git a/openspec/changes/cli-copy-of-issued-rule/tasks.md b/openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/tasks.md similarity index 100% rename from openspec/changes/cli-copy-of-issued-rule/tasks.md rename to openspec/changes/archive/2026-09-29-cli-copy-of-issued-rule/tasks.md diff --git a/openspec/specs/cli-check/spec.md b/openspec/specs/cli-check/spec.md index b0688867..3da426dd 100644 --- a/openspec/specs/cli-check/spec.md +++ b/openspec/specs/cli-check/spec.md @@ -116,7 +116,8 @@ When the `--json` flag is set, the CLI SHALL output each `CheckResult` as a JSON The CLI SHALL exit with code 0 when no error-severity matches are found (including when only warnings, info, or hints exist) and no reconcile outcome below requires failure. The CLI SHALL exit with code 1 when at least one error-severity match is found. The CLI SHALL also exit with code 1, whatever the findings and in both human and `--json` modes, when a completed reconcile: - returned a non-empty `entitlement.withheld`; -- returned an `unsafe` verdict for an `sg` or `vale` rule; or +- returned an `unsafe` verdict for an `sg` or `vale` rule; +- returned an `sg` or `vale` rule in `unknown` carrying `copyOf`, or a rule of any engine carrying a `copyOf` the CLI cannot read; or - left a reported rule unaccounted for (in none, or more than one, of `rules`, `unknown`, and `entitlement.withheld`). On an authenticated run that would reconcile, the CLI SHALL also exit with code 1 when two rule directories under different engines share an id. A logged-out run verifies nothing and does not fail on it. Under `--json`, `success` SHALL be `false` whenever the exit code is non-zero. @@ -152,6 +153,11 @@ On an authenticated run that would reconcile, the CLI SHALL also exit with code - **WHEN** reconciliation returns an `unsafe` verdict for an `sg` or `vale` rule and the scan produces zero results - **THEN** the process SHALL exit with code 1 +#### Scenario: Exit 1 when a static rule is a copy of an issued rule + +- **WHEN** reconciliation returns an `sg` or `vale` rule in `unknown` with `copyOf`, and the scan produces zero results +- **THEN** the process SHALL exit with code 1 + #### Scenario: Exit 1 when a reported rule is unaccounted for - **WHEN** a reported rule appears in none of `rules`, `unknown`, and `entitlement.withheld` @@ -696,10 +702,14 @@ directory. ### Requirement: Check reports rule integrity under --json Under `--json`, `taskless check` SHALL carry an additive, optional `integrity` array with one -entry per rule whose outcome is `unsafe`, `missing`, runtime `unknown`, `unaccounted`, or -`duplicate`, each `{ ruleId, engine?, verdict, files?, revisionId? }`. `files` SHALL list each -differing path with `expected` and `got` as the server returned them. Static `unknown` rules -and `run` rules SHALL NOT appear. The field SHALL be omitted when there is nothing to report. +entry per rule whose outcome is `unsafe`, `missing`, runtime `unknown`, `unknown` with +`copyOf` (any engine), `unaccounted`, or `duplicate`, each +`{ ruleId, engine?, verdict, files?, revisionId?, copyOf? }`. `files` SHALL list each +differing path with `expected` and `got` as the server returned them; for a copy it SHALL be +the server's `copyOf.files`. `copyOf` SHALL be `{ ruleId, revisionId?, sourceMissing }`, where +`sourceMissing` is whether the source rule was answered `missing`. Static `unknown` rules +without `copyOf` and `run` rules SHALL NOT appear. The field SHALL be omitted when there is +nothing to report. #### Scenario: An edited rule appears with its differing files @@ -710,3 +720,9 @@ and `run` rules SHALL NOT appear. The field SHALL be omitted when there is nothi - **WHEN** every reported rule is `run` or static `unknown` and nothing is `missing` - **THEN** `check --json` SHALL NOT include `integrity` + +#### Scenario: A renamed rule appears as a copy beside its missing source + +- **WHEN** reconciliation returns vale rule `bar-2` in `unknown` with `copyOf: { ruleId: "foo-1", revisionId: "r1", files: [{ path: ".vale.ini", expected, got }] }` and returns `foo-1` as `missing` with `revisionId` `r2` +- **THEN** `integrity` SHALL include `{ ruleId: "bar-2", engine: "vale", verdict: "unknown", files: [{ path: ".vale.ini", expected, got }], copyOf: { ruleId: "foo-1", revisionId: "r1", sourceMissing: true } }` +- **AND** SHALL include `{ ruleId: "foo-1", engine: "vale", verdict: "missing", revisionId: "r2" }` diff --git a/openspec/specs/cli-rule-reconciliation/spec.md b/openspec/specs/cli-rule-reconciliation/spec.md index e27c8145..a0834b8e 100644 --- a/openspec/specs/cli-rule-reconciliation/spec.md +++ b/openspec/specs/cli-rule-reconciliation/spec.md @@ -231,16 +231,17 @@ and SHALL NOT resolve the collision by skipping one of them. The CLI SHALL read the v2 reconcile response as a list of per-rule verdicts (`rules[]`, each `{ ruleId, engine, verdict }` with `verdict` one of `run`, `unsafe`, -`missing`), a list of `unknown` rule ids, and `entitlement.withheld`, and SHALL apply this -policy: - -| Verdict | runtime | sg / vale | -| ---------- | -------------------------------------------------- | --------------------------------------------- | -| `run` | execute | run | -| `withheld` | do not execute; fail `check` | (never sent) | -| `unsafe` | do not execute; name `rule restore` | do not run; fail `check`; name `rule restore` | -| `missing` | warn; name `rule restore` | warn; name `rule restore` | -| `unknown` | do not execute (needs `--dangerously-run-scripts`) | run | +`missing`), a list of `unknown` rules (each `{ ruleId, copyOf? }`), and +`entitlement.withheld`, and SHALL apply this policy: + +| Verdict | runtime | sg / vale | +| -------------------- | -------------------------------------------------- | --------------------------------------------- | +| `run` | execute | run | +| `withheld` | do not execute; fail `check` | (never sent) | +| `unsafe` | do not execute; name `rule restore` | do not run; fail `check`; name `rule restore` | +| `missing` | warn; name `rule restore` | warn; name `rule restore` | +| `unknown` | do not execute (needs `--dangerously-run-scripts`) | run | +| `unknown` + `copyOf` | do not execute; name the source | do not run; fail `check`; name the source | The engine SHALL be taken from the verdict's `engine` for `rules[]` entries and from the reporting directory for `unknown` entries. An `unsafe` notice SHALL name the rule and each @@ -255,7 +256,7 @@ authorize running a runtime rule only through a `run` verdict, never by local co #### Scenario: A locally written static rule runs -- **WHEN** reconcile lists a static rule's id in `unknown` +- **WHEN** reconcile lists a static rule's id in `unknown` without `copyOf` - **THEN** that rule SHALL run - **AND** the CLI SHALL emit no notice for it @@ -267,7 +268,7 @@ authorize running a runtime rule only through a `run` verdict, never by local co #### Scenario: Missing warns and does not fail -- **WHEN** reconcile returns a `missing` verdict for any engine +- **WHEN** reconcile returns a `missing` verdict for any engine, and no `unknown` rule names it in `copyOf` - **THEN** the CLI SHALL warn naming the rule and `taskless rule restore ` - **AND** SHALL NOT change the exit code because of it @@ -354,3 +355,46 @@ restore. - **WHEN** `entitlement.upgradeUrl` is not an absolute `https:` URL - **THEN** the CLI SHALL omit it from human and `--json` output + +### Requirement: A copy of an issued rule does not run as a local rule + +When reconcile returns an `unknown` rule carrying `copyOf` (taskless/taskless#255), the CLI +SHALL NOT run or execute it, and SHALL remove it from the snapshot the engines read. For an +`sg` or `vale` rule, `check` SHALL fail with one message naming the rule, the source rule +`copyOf.ruleId`, and each path in `copyOf.files` as changed, removed, or added. For a runtime +rule, the exit code SHALL NOT change, and its skip reason SHALL name the source and say it +was not issued by the rule service. + +When `copyOf.ruleId` is also returned as `missing`, the CLI SHALL report the pair as one +rename: the copy's message SHALL say the source was deleted and SHALL name +`taskless rule restore `, and the CLI SHALL NOT print a separate `missing` +warning for the source. A runtime rename SHALL be one notice and SHALL NOT change the exit +code. `copyOf` absent or `null` SHALL be treated as no copy. A `copyOf` that is present but +has no non-empty string `ruleId` SHALL fail closed: the rule SHALL be treated as +unaccounted. + +#### Scenario: A renamed and loosened Vale rule fails check as one rename + +- **WHEN** reconcile returns vale rule `bar-2` in `unknown` with `copyOf.ruleId` `foo-1` and `copyOf.files` listing `.vale.ini` changed, and returns `foo-1` as `missing` +- **THEN** `bar-2` SHALL NOT run +- **AND** `check` SHALL exit non-zero with one message saying `bar-2` is a copy of `foo-1`, which was deleted, naming `.vale.ini` as changed and `taskless rule restore foo-1` +- **AND** the CLI SHALL NOT print a separate warning that `foo-1` is missing + +#### Scenario: A copy beside its present source fails check + +- **WHEN** reconcile returns sg rule `bar-2` in `unknown` with `copyOf.ruleId` `foo-1`, and `foo-1` is not `missing` +- **THEN** `bar-2` SHALL NOT run +- **AND** `check` SHALL exit non-zero naming `bar-2` as a copy of `foo-1` + +#### Scenario: A runtime copy is not executed and does not fail + +- **WHEN** reconcile returns a runtime rule in `unknown` with `copyOf` +- **THEN** the rule SHALL NOT execute +- **AND** its skip reason SHALL name the source rule +- **AND** the exit code SHALL NOT change because of it + +#### Scenario: An unreadable copyOf fails closed + +- **WHEN** reconcile returns a rule in `unknown` whose `copyOf` is present but has no string `ruleId` +- **THEN** the rule SHALL NOT run or execute +- **AND** `check` SHALL exit non-zero naming it From 863a9206bf76b435f53b1e79cc2f16e6b7fd6846 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 22:09:33 -0700 Subject: [PATCH 3/3] chore(api): re-vendor the v2 schema now that copyOf is live Picks up copyOf on reconcile's unknown rules (taskless/taskless#264), the revisions route (#261), whoami's per-org entitlements (#265), and EntitlementAnnotation on served sets (#260). All additive; copyOf is still read defensively, so nothing in the CLI changes. --- packages/cli/src/generated/api-v2.d.ts | 156 ++++++++++- packages/cli/src/generated/api-v2.schema.json | 259 +++++++++++++++++- 2 files changed, 407 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/generated/api-v2.d.ts b/packages/cli/src/generated/api-v2.d.ts index 51f17e78..20383cfd 100644 --- a/packages/cli/src/generated/api-v2.d.ts +++ b/packages/cli/src/generated/api-v2.d.ts @@ -46,6 +46,15 @@ export interface paths { source: "github"; /** @description Canonical owner URL the client matches its repository against */ url: string; + /** @description What the organization's plan grants, one boolean per entitlement. A hint for the client, never a gate: every endpoint an entitlement gates refuses on its own. Absent only when reading the plan failed (a transport or Durable Object error), which means unknown, not refused. An organization with no plan configured still reports the default plan's entitlements. */ + entitlements?: { + /** @description Taskless Cloud may generate rules for this organization */ + remoteGeneration: boolean; + /** @description Reconcile may place this organization's runtime rules in `run` */ + runtimeSignatures: boolean; + /** @description Restore, rollback, and fetching a revision other than the head are served */ + restoreRules: boolean; + }; }[]; }; }; @@ -86,7 +95,7 @@ export interface paths { put?: never; /** * Judge each rule the client holds, as a whole: run only on an exact match with one issued revision - * @description Every reported rule is placed in exactly one of `rules`, `unknown`, or `entitlement.withheld`; a client must fail its check on any reported rule the response does not place. + * @description Every reported rule is placed in exactly one of `rules`, `unknown`, or `entitlement.withheld`; a client must fail its check on any reported rule the response does not place. An sg or vale `unknown` rule that carries `copyOf` is a copy of an issued rule: a client must not run it and must fail its check. */ post: { parameters: { @@ -183,6 +192,21 @@ export interface paths { /** @description Reported rules that are not rules of this repository (locally written, or issued before rule storage) */ unknown: { ruleId: string; + /** @description Present when the rule carries a file Taskless issued to another rule of this repository: a renamed copy. For sg and vale, never run it and fail the check; a runtime unknown rule is never executed anyway */ + copyOf?: { + /** @description The issued rule it carries files of */ + ruleId: string; + /** @description The revision of that rule it matches best */ + revisionId: string; + /** @description The diff against that revision, pairing files by signature before path: a file carried under a new name is not listed */ + files: { + path: string; + /** @description The issued signature, when the file was issued */ + expected?: string; + /** @description The reported signature, when the file was reported */ + got?: string; + }[]; + }; }[]; entitlement: components["schemas"]["EntitlementV2"]; }; @@ -762,7 +786,7 @@ export interface paths { }[]; } )[]; - entitlement?: components["schemas"]["Entitlement"]; + entitlement?: components["schemas"]["EntitlementAnnotation"]; /** * @description Rolled back: write this file set in place of the rule directory * @constant @@ -862,6 +886,124 @@ export interface paths { patch?: never; trace?: never; }; + "/cli/api/v2/rule/{ruleId}/revisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A rule’s newest revisions and which one is current, on every plan, to choose one to roll back to */ + get: { + parameters: { + query: { + /** @description Full repository URL the rule belongs to */ + repositoryUrl: string; + /** @description Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim */ + orgId?: string; + }; + header?: never; + path: { + /** @description The rule id reconcile returns: the rule’s directory name, stable for the life of the rule. NOT a request id, unlike the legacy v1 `rule/{ruleId}`. */ + ruleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description The rule listed */ + ruleId: string; + /** @description The newest 10 revisions, newest first, followed by the current revision when it is older than those. A rollback writes no revision: it shows only as `current` moving. The cap may change. */ + revisions: { + /** @description Pass to rollback, or to fetch as `revision` */ + revisionId: string; + /** @description When the revision was generated */ + createdAt: string; + /** + * @description How the revision was delivered + * @enum {string} + */ + delivery: "cli" | "pull-request"; + /** @description The request that produced it */ + requestId: string; + /** @description The pull request that delivered it; only for pull-request delivery */ + prUrl?: string; + /** @description Whether it is the rule's effective current revision; at most one is */ + current: boolean; + }[]; + /** @description Whether the rule has older revisions this response omits; the dashboard's rule page lists them all */ + truncated: boolean; + }; + }; + }; + /** @description `validation_error`: The body or query failed validation, or the body is not JSON; `details` lists why. Not retryable as sent. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "validation_error"; + /** @description Human-readable reasons, when the code carries them */ + details?: string[]; + }; + }; + }; + /** @description `unauthorized`: Missing or invalid bearer token. Log in again. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "unauthorized"; + }; + }; + }; + /** + * @description `organization_not_found`: The organization is not accessible to this user, or its GitHub App installation does not cover the repository. Deliberately indistinguishable, so a probe cannot tell 'not yours' from 'does not exist'. + * + * `rule_not_found`: Not a rule of this repository. On restore, also a rule with no current revision (it exists only on an open pull request). Answered on every plan, before any plan refusal. + */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "organization_not_found" | "rule_not_found"; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/cli/api/v2/rule/{ruleId}": { parameters: { query?: never; @@ -975,7 +1117,7 @@ export interface paths { }[]; } )[]; - entitlement?: components["schemas"]["Entitlement"]; + entitlement?: components["schemas"]["EntitlementAnnotation"]; } | { /** @constant */ @@ -1190,7 +1332,7 @@ export interface paths { }[]; } )[]; - entitlement?: components["schemas"]["Entitlement"]; + entitlement?: components["schemas"]["EntitlementAnnotation"]; /** * @description The rule was restored: its file set follows * @constant @@ -1354,6 +1496,12 @@ export interface components { file: string; }[]; }; + /** @description The entitlement annotation on a served runtime file set: whether it will be blessed on this plan. Never carries `withheld`, which only reconcile reports. */ + EntitlementAnnotation: { + runtimeSignatures: components["schemas"]["Entitlement"]["runtimeSignatures"]; + reason?: components["schemas"]["Entitlement"]["reason"]; + upgradeUrl?: components["schemas"]["Entitlement"]["upgradeUrl"]; + }; EntitlementV2: { runtimeSignatures: components["schemas"]["Entitlement"]["runtimeSignatures"]; reason?: components["schemas"]["Entitlement"]["reason"]; diff --git a/packages/cli/src/generated/api-v2.schema.json b/packages/cli/src/generated/api-v2.schema.json index 54b66ea1..df4aa4c8 100644 --- a/packages/cli/src/generated/api-v2.schema.json +++ b/packages/cli/src/generated/api-v2.schema.json @@ -48,6 +48,24 @@ "additionalProperties": false, "description": "The organization's entitlement to have runtime rules blessed. Additive: the rest of the response means what it did without it. Always present on reconcile; on restore and request retrieval, present whenever the response carries a runtime file set." }, + "EntitlementAnnotation": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "runtimeSignatures": { + "$ref": "#/components/schemas/Entitlement/properties/runtimeSignatures" + }, + "reason": { + "$ref": "#/components/schemas/Entitlement/properties/reason" + }, + "upgradeUrl": { + "$ref": "#/components/schemas/Entitlement/properties/upgradeUrl" + } + }, + "required": ["runtimeSignatures"], + "additionalProperties": false, + "description": "The entitlement annotation on a served runtime file set: whether it will be blessed on this plan. Never carries `withheld`, which only reconcile reports." + }, "EntitlementV2": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -145,6 +163,30 @@ "url": { "type": "string", "description": "Canonical owner URL the client matches its repository against" + }, + "entitlements": { + "type": "object", + "properties": { + "remoteGeneration": { + "type": "boolean", + "description": "Taskless Cloud may generate rules for this organization" + }, + "runtimeSignatures": { + "type": "boolean", + "description": "Reconcile may place this organization's runtime rules in `run`" + }, + "restoreRules": { + "type": "boolean", + "description": "Restore, rollback, and fetching a revision other than the head are served" + } + }, + "required": [ + "remoteGeneration", + "runtimeSignatures", + "restoreRules" + ], + "additionalProperties": false, + "description": "What the organization's plan grants, one boolean per entitlement. A hint for the client, never a gate: every endpoint an entitlement gates refuses on its own. Absent only when reading the plan failed (a transport or Durable Object error), which means unknown, not refused. An organization with no plan configured still reports the default plan's entitlements." } }, "required": ["orgId", "id", "name", "source", "url"], @@ -184,7 +226,7 @@ "/cli/api/v2/reconcile": { "post": { "summary": "Judge each rule the client holds, as a whole: run only on an exact match with one issued revision", - "description": "Every reported rule is placed in exactly one of `rules`, `unknown`, or `entitlement.withheld`; a client must fail its check on any reported rule the response does not place.", + "description": "Every reported rule is placed in exactly one of `rules`, `unknown`, or `entitlement.withheld`; a client must fail its check on any reported rule the response does not place. An sg or vale `unknown` rule that carries `copyOf` is a copy of an issued rule: a client must not run it and must fail its check.", "requestBody": { "description": "OK", "content": { @@ -378,6 +420,44 @@ "properties": { "ruleId": { "type": "string" + }, + "copyOf": { + "description": "Present when the rule carries a file Taskless issued to another rule of this repository: a renamed copy. For sg and vale, never run it and fail the check; a runtime unknown rule is never executed anyway", + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The issued rule it carries files of" + }, + "revisionId": { + "type": "string", + "description": "The revision of that rule it matches best" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "expected": { + "description": "The issued signature, when the file was issued", + "type": "string" + }, + "got": { + "description": "The reported signature, when the file was reported", + "type": "string" + } + }, + "required": ["path"], + "additionalProperties": false + }, + "description": "The diff against that revision, pairing files by signature before path: a file carried under a new name is not listed" + } + }, + "required": ["ruleId", "revisionId", "files"], + "additionalProperties": false } }, "required": ["ruleId"], @@ -1308,7 +1388,7 @@ "description": "Exactly one file set: the requested rule, never its siblings" }, "entitlement": { - "$ref": "#/components/schemas/Entitlement" + "$ref": "#/components/schemas/EntitlementAnnotation" }, "restoreRules": { "type": "boolean", @@ -1460,6 +1540,177 @@ } } }, + "/cli/api/v2/rule/{ruleId}/revisions": { + "get": { + "summary": "A rule’s newest revisions and which one is current, on every plan, to choose one to roll back to", + "parameters": [ + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The rule id reconcile returns: the rule’s directory name, stable for the life of the rule. NOT a request id, unlike the legacy v1 `rule/{ruleId}`." + }, + { + "name": "repositoryUrl", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Full repository URL the rule belongs to" + }, + "description": "Full repository URL the rule belongs to" + }, + { + "name": "orgId", + "in": "query", + "required": false, + "schema": { + "description": "Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim", + "type": "string" + }, + "description": "Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The rule listed" + }, + "revisions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "revisionId": { + "type": "string", + "description": "Pass to rollback, or to fetch as `revision`" + }, + "createdAt": { + "type": "string", + "description": "When the revision was generated" + }, + "delivery": { + "type": "string", + "enum": ["cli", "pull-request"], + "description": "How the revision was delivered" + }, + "requestId": { + "type": "string", + "description": "The request that produced it" + }, + "prUrl": { + "description": "The pull request that delivered it; only for pull-request delivery", + "type": "string" + }, + "current": { + "type": "boolean", + "description": "Whether it is the rule's effective current revision; at most one is" + } + }, + "required": [ + "revisionId", + "createdAt", + "delivery", + "requestId", + "current" + ], + "additionalProperties": false + }, + "description": "The newest 10 revisions, newest first, followed by the current revision when it is older than those. A rollback writes no revision: it shows only as `current` moving. The cap may change." + }, + "truncated": { + "type": "boolean", + "description": "Whether the rule has older revisions this response omits; the dashboard's rule page lists them all" + } + }, + "required": ["ruleId", "revisions", "truncated"], + "additionalProperties": false, + "description": "A rule's recent revisions, without their files, on every plan. Fetching an older revision or rolling back to one requires restoreRules." + } + } + } + }, + "400": { + "description": "`validation_error`: The body or query failed validation, or the body is not JSON; `details` lists why. Not retryable as sent.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["validation_error"], + "description": "Machine-readable error code" + }, + "details": { + "description": "Human-readable reasons, when the code carries them", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "401": { + "description": "`unauthorized`: Missing or invalid bearer token. Log in again.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["unauthorized"], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "404": { + "description": "`organization_not_found`: The organization is not accessible to this user, or its GitHub App installation does not cover the repository. Deliberately indistinguishable, so a probe cannot tell 'not yours' from 'does not exist'.\n\n`rule_not_found`: Not a rule of this repository. On restore, also a rule with no current revision (it exists only on an open pull request). Answered on every plan, before any plan refusal.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["organization_not_found", "rule_not_found"], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + } + } + } + }, "/cli/api/v2/rule/{ruleId}": { "get": { "summary": "A rule by its id: its head on every plan, or an older revision with restoreRules", @@ -1715,7 +1966,7 @@ "description": "Exactly one file set: the requested rule, never its siblings" }, "entitlement": { - "$ref": "#/components/schemas/Entitlement" + "$ref": "#/components/schemas/EntitlementAnnotation" } }, "required": ["ruleId", "revisionId", "rules"], @@ -2112,7 +2363,7 @@ "description": "Exactly one file set: the requested rule, never its siblings" }, "entitlement": { - "$ref": "#/components/schemas/Entitlement" + "$ref": "#/components/schemas/EntitlementAnnotation" }, "restoreRules": { "type": "boolean",