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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/error-remedies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

Four error messages now give a remedy that works as written. On a plan without rule recovery, `check`'s git steps restore a rule from the commit before the one that changed it (`<commit>~1`), and from `HEAD` when the change is not committed yet; restoring from the change itself put back nothing for a deleted rule. A rule id held by two engines now says to rename the locally written rule rather than the issued one, and lists where the id appears inside it. Migration 5 no longer asks for migration 4 to be re-run, which nothing can do, and says to move the loose rule files by hand and run `init` again. `update --rules` on a project with no `.taskless/` names `init` instead of "run the CLI once". Error codes are unchanged. The `check` agent recipe moves to topic v6.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## Why

Four error messages give a remedy that does not work as written (#452):

- Migration 0005 tells the user to run migration 0004. No command runs one
migration, and 0004 is already recorded as done, so it never runs again.
- On a plan without rule recovery, `check` says to restore a rule from the
commit `git log` lists. For a deleted rule that commit is the deletion, so
`git restore --source=<commit>` puts back nothing.
- `update --rules` on a project with no `.taskless/` says "Run the CLI once".
`check`, `verify` and `test` refuse a missing scaffold; only `init` makes one.
- `check` answers a rule id held by two engines with "Rename one." Renaming the
issued rule turns it into a copy, which does not run either, and a rename has
to reach the id inside the rule as well as its directory.

## What Changes

- Migration 0005 says to move the loose rule files into `.taskless/sg/rules/`
by hand, then run `init` again, and names no migration number.
- The git recovery steps restore from `<commit>~1`, the commit before the
change, and give `git restore --source=HEAD` for a change not yet committed.
`~1` rather than `^`, which zsh's `extendedglob` reads as a glob.
- `update --rules` names `init`.
- The duplicate-id failure says to rename the locally written rule, not the
issued one, and lists where the id appears for each engine involved.
- The `check` recipe goes to topic v6, with the new git steps in its example.

## Capabilities

### Modified Capabilities

- `cli-rule-recovery`: the git steps restore from the commit before a change,
and from `HEAD` for an uncommitted one.

## Delivery

Single PR. Four message fixes, their tests, and one spec delta fit one
reviewable diff, and none depends on another.

## Impact

- `packages/cli/src/filesystem/migrations/0005-rule-directories.ts`
- `packages/cli/src/rules/recovery-advice.ts`
- `packages/cli/src/rules/reconcile-marker.ts`
- `packages/cli/src/rules/plan-check.ts`
- `packages/cli/src/agent/check.md`
- `--json` envelopes keep their codes; only message text changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
## MODIFIED Requirements

### Requirement: Recovery suggestions follow the plan

The CLI SHALL read the acting organization's `entitlements.restoreRules` from the
`GET /cli/api/v2/whoami` response it already fetches to resolve the organization, and SHALL
NOT make another request for it. The value SHALL be treated as tri-state:

- `true`: the plan includes rule recovery.
- `false`: the plan is known to exclude rule recovery.
- unknown: whoami failed, the matched organization carried no `entitlements` or no boolean
`restoreRules`, or no organization matched the repository's remotes and the CLI fell back
to the token's claim.

Only `false` SHALL change what the CLI suggests. Wherever the CLI would name
`taskless rule restore <ruleId>` as the way to repair a rule (an `unsafe` or `missing` rule
reported by `check`, or the source of a rename), a `false` plan SHALL instead be told that
restoring rules is not included in the plan, and given git steps for the rule's directory
under `.taskless/rules/`: a `git restore --source=HEAD` command for a change not yet committed,
and for a committed one a `git log` command that lists the commits that changed it, with a
`git restore --source=<commit>~1` command that puts it back as it was before one of them. The
steps SHALL restore from the commit before a change, never from the change itself: the newest
commit `git log` lists for a deleted rule is the deletion, which does not contain the rule. The
parent SHALL be spelled `~1`, not `^`, which zsh reads as a glob under `extendedglob`. When the rule's
engine is not known, the directory SHALL be given as a pathspec matching the rule id under any
engine. `true` and unknown SHALL produce the suggestions the CLI produced before this
requirement.

This is a suggestion, never a gate. `rule restore` and `rule rollback` SHALL call the service
whatever `restoreRules` says, and SHALL relay a plan refusal as "A plan refusal is an answer,
not a failure of the service" requires. `--json` output SHALL NOT change.

#### Scenario: An edited rule on a plan without recovery gets git steps

- **WHEN** `check` reports sg rule `no-eval-3fa9c21b` as `unsafe` and `restoreRules` is `false`
- **THEN** the message SHALL say restoring rules is not included in the plan
- **AND** SHALL give `git restore --source=HEAD`, `git log` and `git restore --source=<commit>~1` commands for `.taskless/rules/sg/no-eval-3fa9c21b/`
- **AND** SHALL NOT name `taskless rule restore`

#### Scenario: A missing rule of unknown engine gets a pathspec for any engine

- **WHEN** `check` reports rule `foo-1` as `missing` with no known engine and `restoreRules` is `false`
- **THEN** the git steps SHALL name a pathspec matching `foo-1` under any engine directory in `.taskless/rules/`

#### Scenario: A rename on a plan without recovery gets git steps for the source

- **WHEN** `check` reports vale rule `bar-2` as a copy of `foo-1`, `foo-1` is `missing`, and `restoreRules` is `false`
- **THEN** the message SHALL give the git steps for `foo-1`'s directory, then say to delete `.taskless/rules/vale/bar-2/`
- **AND** SHALL NOT name `taskless rule restore`

#### Scenario: Unknown keeps today's suggestion

- **WHEN** whoami fails, or the matched organization has no `entitlements`, or no organization matches the repository
- **THEN** every suggestion SHALL name `taskless rule restore <ruleId>` as before

#### Scenario: The entitlement never blocks a recovery command

- **WHEN** `restoreRules` is `false` and the user runs `taskless rule restore no-eval-3fa9c21b`
- **THEN** the CLI SHALL call the service's restore endpoint
- **AND** SHALL relay its refusal as it does today

#### Scenario: No extra request is made

- **WHEN** `check` or a `rule` subcommand resolves the acting organization
- **THEN** the CLI SHALL call `GET /cli/api/v2/whoami` at most once for that resolution

#### Scenario: A committed deletion is restored from the commit before it

- **WHEN** `check` reports sg rule `foo-1` as `missing`, its deletion is committed, and `restoreRules` is `false`
- **THEN** the `git restore` command for a committed change SHALL take `--source=<commit>~1`
- **AND** following it with the newest commit `git log` lists SHALL put the rule's files back
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## 1. Spec

- [x] 1.1 Restate "Recovery suggestions follow the plan" in full as a MODIFIED
block, keeping all six scenarios.
- [x] 1.2 Dry-run `openspec archive` and confirm every scenario survives.

## 2. Messages

- [x] 2.1 Migration 0005: move by hand, then run `init`.
- [x] 2.2 Git steps: `HEAD` for an uncommitted change, `<commit>~1` otherwise.
- [x] 2.3 `update --rules`: name `init`.
- [x] 2.4 Duplicate id: rename the local rule, and where its id appears.
- [x] 2.5 `check` recipe example and commit guidance; topic v6.

## 3. Tests

- [x] 3.1 Migration 0005 refusal names the hand move and `init`, not 0004.
- [x] 3.2 Verdict tests assert the new git steps.
- [x] 3.3 Reconcile-marker refusal names `init`.
- [x] 3.4 Duplicate-id failure names the local rule and each engine's places.
16 changes: 13 additions & 3 deletions openspec/specs/cli-rule-recovery/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,12 @@ Only `false` SHALL change what the CLI suggests. Wherever the CLI would name
`taskless rule restore <ruleId>` as the way to repair a rule (an `unsafe` or `missing` rule
reported by `check`, or the source of a rename), a `false` plan SHALL instead be told that
restoring rules is not included in the plan, and given git steps for the rule's directory
under `.taskless/rules/`: a `git log` command that lists the commits that changed it, and a
`git restore --source=<commit>` command that puts it back as of one of them. When the rule's
under `.taskless/rules/`: a `git restore --source=HEAD` command for a change not yet committed,
and for a committed one a `git log` command that lists the commits that changed it, with a
`git restore --source=<commit>~1` command that puts it back as it was before one of them. The
steps SHALL restore from the commit before a change, never from the change itself: the newest
commit `git log` lists for a deleted rule is the deletion, which does not contain the rule. The
parent SHALL be spelled `~1`, not `^`, which zsh reads as a glob under `extendedglob`. When the rule's
engine is not known, the directory SHALL be given as a pathspec matching the rule id under any
engine. `true` and unknown SHALL produce the suggestions the CLI produced before this
requirement.
Expand All @@ -219,7 +223,7 @@ not a failure of the service" requires. `--json` output SHALL NOT change.

- **WHEN** `check` reports sg rule `no-eval-3fa9c21b` as `unsafe` and `restoreRules` is `false`
- **THEN** the message SHALL say restoring rules is not included in the plan
- **AND** SHALL give `git log` and `git restore --source=<commit>` commands for `.taskless/rules/sg/no-eval-3fa9c21b/`
- **AND** SHALL give `git restore --source=HEAD`, `git log` and `git restore --source=<commit>~1` commands for `.taskless/rules/sg/no-eval-3fa9c21b/`
- **AND** SHALL NOT name `taskless rule restore`

#### Scenario: A missing rule of unknown engine gets a pathspec for any engine
Expand Down Expand Up @@ -248,3 +252,9 @@ not a failure of the service" requires. `--json` output SHALL NOT change.

- **WHEN** `check` or a `rule` subcommand resolves the acting organization
- **THEN** the CLI SHALL call `GET /cli/api/v2/whoami` at most once for that resolution

#### Scenario: A committed deletion is restored from the commit before it

- **WHEN** `check` reports sg rule `foo-1` as `missing`, its deletion is committed, and `restoreRules` is `false`
- **THEN** the `git restore` command for a committed change SHALL take `--source=<commit>~1`
- **AND** following it with the newest commit `git log` lists SHALL put the rule's files back
12 changes: 7 additions & 5 deletions packages/cli/src/agent/check.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,20 @@ When the organization's plan is known not to include restoring rules,
every notice above gives git steps where it would name `rule restore`:

```
sg rule no-eval-3fa9c21b was edited since Taskless issued it (changed no-eval-3fa9c21b.yml), so it did not run and `check` fails. Restoring rules is not included in your organization's plan, so recover no-eval-3fa9c21b from git: `git log -- .taskless/rules/sg/no-eval-3fa9c21b/` lists the commits that changed it, and `git restore --source=<commit> -- .taskless/rules/sg/no-eval-3fa9c21b/` puts it back as of one of them.
sg rule no-eval-3fa9c21b was edited since Taskless issued it (changed no-eval-3fa9c21b.yml), so it did not run and `check` fails. Restoring rules is not included in your organization's plan, so recover no-eval-3fa9c21b from git. If the change is not committed yet, `git restore --source=HEAD -- .taskless/rules/sg/no-eval-3fa9c21b/` puts it back. If it is, `git log -- .taskless/rules/sg/no-eval-3fa9c21b/` lists the commits that changed it, newest first, and `git restore --source=<commit>~1 -- .taskless/rules/sg/no-eval-3fa9c21b/` puts it back as it was before <commit>.
```

Follow the git steps, then run `check` again. Do not run
`rule restore` or `rule rollback` instead: the service refuses both on
this plan and answers with the same git steps. `integrity` is the same
on every plan.

Choosing the commit: for an edited rule, restore from the commit just
before the edit, or from `HEAD` when the edit is not committed yet. For
a deleted rule, the newest commit is the one
that deleted it, so restore from its parent (`<commit>~1`). A rule
Choosing the commit: `<commit>` is the commit that made the change,
and `~1` restores from its parent, the last commit that still held the
rule as issued. Restoring from `<commit>` itself puts back nothing for a
deleted rule, because the rule is not in that commit. When `git log`
lists several edits since the rule was issued, use the oldest of them.
For a change that is not committed yet, use the `HEAD` step. A rule
whose engine is not known is given as a quoted pathspec,
`'.taskless/rules/*/<ruleId>/*'`; pass it to git as written. For a
rename, recover the source, then delete the copy, as above.
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/filesystem/migrations/0005-rule-directories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { dirname, join } from "node:path";

import type { Migration } from "../types";
import { CLIError } from "../../util/cli-error";
import { getCliPrefix } from "../../util/package-manager";
import { escapeRegExp } from "../../util/regex";
import {
ENGINES,
Expand Down Expand Up @@ -64,6 +65,11 @@ async function move(source: string, destination: string): Promise<void> {
* `sg/rules/`; a `*.yml` still sitting there means `0004` did not complete, and
* creating `rules/sg/` around it would interleave two layouts in one tree with
* no way to tell them apart afterwards.
*
* The remedy is a hand move, not "run 0004". No command runs one migration,
* and by the time this runs `0004` is recorded as done, so nothing would ever
* run it again. The message also names no migration number: the runner
* prefixes it with `Migration 5 failed:`, and a second number reads as noise.
*/
async function assertRootIsFree(directory: string): Promise<void> {
const root = join(directory, RULES_DIRECTORY);
Expand All @@ -75,8 +81,9 @@ async function assertRootIsFree(directory: string): Promise<void> {

throw new CLIError(
`Cannot create the rule directories: .taskless/${RULES_DIRECTORY}/ still contains ` +
`${stray.join(", ")} from the pre-migration layout. Migration 0004 moves those to ` +
`.taskless/sg/rules/; run it to completion first.`,
`${stray.join(", ")} from the pre-migration layout. Move ` +
`${stray.length === 1 ? "it" : "them"} into .taskless/sg/rules/ by hand, ` +
`then run \`${getCliPrefix()} init\` again.`,
"SCAFFOLD_CONFLICT"
);
}
Expand Down
34 changes: 33 additions & 1 deletion packages/cli/src/rules/plan-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "../util/git-remote";
import { getCliPrefix } from "../util/package-manager";
import { orgNotFoundRemedy } from "./generate";
import type { EngineName } from "./layout";
import { recoveryAdvice } from "./recovery-advice";
import { reportRules } from "./report";
import type { RunDirectory } from "./run-directory";
Expand Down Expand Up @@ -192,7 +193,7 @@ export async function planCheck(
duplicate.engines
.map((engine) => `.taskless/rules/${engine}/${duplicate.ruleId}/`)
.join(", ") +
"), so neither can be verified and neither ran. Rename one."
`), so neither can be verified and neither ran. ${renameAdvice(duplicate.ruleId, duplicate.engines)}`
);
}
for (const rule of report.unreadable) {
Expand Down Expand Up @@ -340,6 +341,37 @@ export async function planCheck(
};
}

/**
* What a user does about a duplicate id: which rule to rename, and where the id
* lives inside it.
*
* The local rule, never the issued one. Renaming an issued rule turns it into
* a copy of the rule service's id, and a copy does not run: `check` fails one
* under `sg` or `vale`, and skips one under `runtime`. Which side is issued is
* not known here, because the pair is refused before reconcile, so the user is
* told how to tell rather than told a path.
*
* The places are the ones migration `9` rewrites. That migration does this
* automatically, but only once: it does not run again on a current scaffold,
* so a collision made afterwards is renamed by hand.
*/
function renameAdvice(ruleId: string, engines: readonly EngineName[]): string {
const places: Record<EngineName, string> = {
sg:
`under sg, the directory, ${ruleId}.yml and its \`id:\`, and each ` +
`.tests/${ruleId}-*-test.yml and its \`id:\``,
vale:
`under vale, the directory, ${ruleId}.yml, and in .vale.ini the ` +
`\`tskl) rule = ${ruleId}\` breadcrumb and the \`${ruleId}.${ruleId}\` key`,
runtime: "under runtime, the directory alone",
};
return (
"Rename the rule you wrote locally, not the one Taskless issued: a renamed " +
"issued rule becomes a copy, and a copy does not run. The id appears " +
`${engines.map((engine) => places[engine]).join("; ")}.`
);
}

/** The one notice a withheld run prints, so the upgrade URL appears once. */
function withheldNotice(entitlement: PlanEntitlement): string {
const count = entitlement.withheld.length;
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/rules/reconcile-marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities";
import { readManifest, writeManifest } from "../filesystem/manifest";
import { TASKLESS_DIRECTORY } from "./vale/formats";
import { CLIError } from "../util/cli-error";
import { getCliPrefix } from "../util/package-manager";
import { compareVersions } from "../util/version-compare";
import { getCliVersion } from "../wizard/intro";

Expand Down Expand Up @@ -79,7 +80,7 @@ export async function recordReconciliation(
// states this precondition; this is the code holding to it.
if (!(await pathExists(tasklessDirectory))) {
throw new CLIError(
`No \`${TASKLESS_DIRECTORY}/\` in this project, so there are no rules to reconcile. Run the CLI once to set it up before recording a reconciliation.`,
`No \`${TASKLESS_DIRECTORY}/\` in this project, so there are no rules to reconcile. Run \`${getCliPrefix()} init\` to set it up before recording a reconciliation.`,
"INVALID_INPUT"
);
}
Expand Down
21 changes: 17 additions & 4 deletions packages/cli/src/rules/recovery-advice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,26 @@ function ruleDirectory(ruleId: string, engine?: EngineName): string {
: `.taskless/rules/${engine}/${ruleId}/`;
}

/** The sentence for a plan known not to include rule recovery. */
/**
* The sentence for a plan known not to include rule recovery.
*
* It restores from the commit BEFORE a change, never from the change itself.
* `git log` lists the commit that deleted or edited the rule first, and
* restoring from that commit restores the damage: a deleted rule is not in
* it at all, so `git restore` puts nothing back. A change not yet committed
* appears in no commit, so it gets `HEAD`, which still holds the rule as
* issued. One sentence covers an edited, a deleted and a renamed rule alike.
*
* `~1` rather than `^`: zsh with `extendedglob` reads a bare `^` as a glob
* negation, and the `check` recipe already spells the parent `~1`.
*/
function gitSteps({ ruleId, engine, afterwards, otherwise }: RecoveryTarget) {
const directory = ruleDirectory(ruleId, engine);
return (
`Restoring rules is not included in your organization's plan, so recover ${ruleId} from git: ` +
`\`git log -- ${directory}\` lists the commits that changed it, and ` +
`\`git restore --source=<commit> -- ${directory}\` puts it back as of one of them.` +
`Restoring rules is not included in your organization's plan, so recover ${ruleId} from git. ` +
`If the change is not committed yet, \`git restore --source=HEAD -- ${directory}\` puts it back. ` +
`If it is, \`git log -- ${directory}\` lists the commits that changed it, newest first, and ` +
`\`git restore --source=<commit>~1 -- ${directory}\` puts it back as it was before <commit>.` +
(afterwards === undefined ? "" : ` Then ${afterwards}.`) +
(otherwise === undefined ? "" : ` Or ${otherwise}.`)
);
Expand Down
Loading
Loading