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
13 changes: 13 additions & 0 deletions .changeset/ledger-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@taskless/cli": patch
---

A project with no recorded rules marker now walks the ledger from the beginning, and `taskless update --rules` replaces `--reconciledTo=<version>`.

Previously an absent `rules.reconciledTo` meant "nothing to walk", on the reasoning that a project created at the installed version has no history. That was right about new projects and wrong about every existing one: a project that predates the ledger has had none of its entries applied, so reading absence as up to date silently excused exactly the population the entries were written for. The 0.11.x entry would have reached nobody.

Absent now means `0.0.0`, so every section applies. New projects stay correct because `init` stamps the marker at creation, which is what makes the two distinguishable: present means accounted for, absent means predates the ledger. The stamp never overwrites an existing marker, so re-running setup cannot reset one a real walk earned.

`--reconciledTo=<version>` is replaced by the flag `--rules`, which stamps the running CLI's version. The value was never load-bearing: the CLI knows its own version, the only sensible endpoint of a walk is the installed one, and accepting a value only made it possible to claim a walk that did not finish. Removing it removes the two guards that existed to police it and every way of supplying it wrongly. The backwards guard remains, because an older CLI running on the same project would otherwise rewind the marker.

The ledger heading is now `Migrating to 0.11.x`, since the entry describes the release series rather than one patch.
109 changes: 81 additions & 28 deletions packages/cli/src/agent/update.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: update (CLI v%(CLI_VERSION)s / topic v2)
# Topic: update (CLI v%(CLI_VERSION)s / topic v3)

## You are here
This is `update`. It tells you what an upgrade changed for the rules
Expand Down Expand Up @@ -34,11 +34,16 @@ that you finished.
`rules.engines.vale` for the engine versions the rules were built
against.

**No `rules.reconciledTo` at all** means this project has never
recorded a reconciliation. That is NOT the same as being behind. A
project created at the installed version has no history to walk, so
do nothing except step 4. Only walk sections if you can see an
earlier version to walk FROM.
**No `rules.reconciledTo` at all** means this project PREDATES the
ledger, so treat it as `0.0.0` and walk every section. It does not
mean the project is new: a project this CLI set up has the field
stamped at creation, so an absent marker is the one case where none
of the entries below have ever been applied.

`rules.walk` on that same payload is this decision already made:
`{ "from": ..., "to": ... }` when there is something to walk, and
`null` when there is not. Prefer it to re-deriving the boundary
yourself, so the offer and the enforcement cannot disagree.

2. **Walk forward, in order.** Start at the section after
`reconciledTo` and continue to the installed version. Sections are
Expand All @@ -51,21 +56,24 @@ that you finished.

4. **Record that you finished.** Run:
```
%(TASKLESS_CLI)s update --reconciledTo=<installed version>
%(TASKLESS_CLI)s update --rules
```
Only after the walk is complete. A partial or abandoned walk must not
be recorded: the next agent will start from what you wrote and skip
everything you did not do, and it will look like there was nothing to
do. The CLI rejects a version it has no entries for, and refuses to
move the marker backwards, but it cannot tell whether you actually
read the sections.
Only after the walk is complete. There is no version to pass: the CLI
stamps its own, because the only sensible endpoint of a walk is the
version you are running.

A partial or abandoned walk must not be recorded. The next agent
starts from what you wrote and skips everything you did not do, and
it will look like there was nothing to do. Nothing can check this for
you: the CLI refuses to move the marker backwards, but it cannot tell
whether you read the sections.

This also records the engine versions the rules are now valid
against, which is what lets a later upgrade say what moved.

## The ledger

### Migrating to 0.11.0
### Migrating to 0.11.x

The vendored ast-grep moves from 0.41.0 to 0.45.2, and Vale is at
3.18.0. Four things follow for existing rules.
Expand Down Expand Up @@ -104,24 +112,69 @@ This affects the flag only, not a rule's `language:` field, so no rule
file needs changing. It is recorded because the divergence used to be
documented as a thing to work around.

**Matching semantics moved, and this is the part you cannot check
locally.** Metavariable bindings no longer leak across `nthChild`
siblings, nor out of a negated `not`, and root metavariables now match
comments. A valid, unchanged rule can match a DIFFERENT SET OF NODES
with no error and no warning.

There is no differential tooling yet. If a rule uses `nthChild`, a
negated `not`, or a bare root metavariable, re-run its fixtures and read
the findings rather than the exit code: the rule can still pass its
tests while reporting differently on real code.
**Matching semantics moved, and a valid, unchanged rule can now match a
DIFFERENT SET OF NODES with no error and no warning.** Two shapes are
affected. Both were measured against the two binaries, so this section
says what to do rather than what to watch for.

**A rule was silently dead and now fires.** If a rule uses `nthChild`
with an `ofRule` whose body binds a metavariable:

```
nthChild:
position: 2
ofRule: { pattern: $S }
```

then at 0.41.0 it matched **nothing**, because `ofRule` reused one
environment across siblings: the first match committed `$S` and every
later sibling failed the consistency check and went uncounted. It now
counts correctly and reports.

So the rule is not broken, it was inert, and the findings it produces on
this version have never been seen. Run `%(TASKLESS_CLI)s check` and read
them as new: they are real matches the rule was always meant to make,
and they may be numerous on a codebase that has never been checked
against it. Fixtures are no help here, since a rule that matched nothing
passed the `pass/` side of its own tests.

A rule whose `ofRule` used a non-binding matcher, `kind:` or `regex:`
with no metavariable, is unaffected: only binding was broken.

**A metavariable is now empty where it used to carry a value.** If a
rule binds a metavariable inside a negated `not` and then references it
in `message`, `fix`, or a constraint:

```
follows:
not:
pattern: return $A
stopBy: end
message: "found after $A"
```

then at 0.41.0 `$A` rendered with a value leaked from the candidate the
negation REJECTED. A `not` contributes no bindings by definition, since
a successful negation means the inner rule did not match, so that value
was never meaningful. It is now unbound and renders empty.

This one will not change your finding counts. The match is identical:
same file, same range, same rule. Only the rendered output differs, so
grep your rules for a metavariable that appears both inside a `not` and
in a `message` or `fix`. A `fix` in that shape has been writing the
leaked text into people's files.

**Root metavariables and comments** also changed upstream
(ast-grep/ast-grep#2868), but no shape we tried reproduced a difference,
including the TSX case that PR names. Nothing to do unless you see one.

## Errors

With `--json`, `--reconciledTo` failures emit `{ ok: false, code, message }`:
With `--json`, `--rules` failures emit `{ ok: false, code, message }`:

| code | meaning | fix |
|-----------------|--------------------------------------------------|------------------------------------------|
| `INVALID_INPUT` | version is ahead of this CLI, or moves backwards | reconcile to the installed version |
| code | meaning | fix |
|-----------------|------------------------------------------------|--------------------------------------------|
| `INVALID_INPUT` | no `.taskless/`, or an older CLI would rewind the marker | set the project up, or upgrade first |

## See Also

Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { outputSchema as infoOutputSchema } from "../schemas/info";
import { makeErrorEnvelope } from "../types/errors";
import { resolveRepositoryContext } from "../util/git-remote";
import { readManifest } from "../filesystem/migrate";
import { reconciliationStart } from "../rules/reconcile-marker";
import { TASKLESS_DIRECTORY } from "../rules/vale/formats";

export const infoCommand = defineCommand({
Expand Down Expand Up @@ -86,6 +87,10 @@ export const infoCommand = defineCommand({
sg: manifest?.rules?.engines?.sg ?? null,
vale: manifest?.rules?.engines?.vale ?? null,
},
// The walk boundary, decided once here rather than by each caller.
// A missing marker resolves to the baseline, so a project predating
// the ledger reports a walk rather than "nothing to do".
walk: reconciliationStart(manifest?.rules?.reconciledTo) ?? null,
},
};

Expand Down
39 changes: 28 additions & 11 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import {
processLauncherContext,
} from "../util/package-manager";
import {
pathExists,
recordReconciliation,
reconciliationStart,
stampNewProjectRules,
} from "../rules/reconcile-marker";
import { readManifest } from "../filesystem/migrate";
import { TASKLESS_DIRECTORY } from "../rules/vale/formats";
Expand Down Expand Up @@ -103,8 +105,8 @@ export const initCommand = defineCommand({
* rewriting after an engine or CLI upgrade.
*
* With no flags it serves the ledger recipe, so `taskless update` and
* `taskless agent update` are the same thing. With `--reconciledTo` it records
* that a walk completed.
* `taskless agent update` are the same thing. With `--rules` it stamps the
* walk as complete.
*/
export const updateCommand = defineCommand({
meta: {
Expand All @@ -118,15 +120,16 @@ export const updateCommand = defineCommand({
alias: "d",
description: "Working directory",
},
reconciledTo: {
type: "string",
rules: {
type: "boolean",
description:
"Record that the ledger walk completed up to this CLI version",
"Stamp the rules as reconciled to this CLI, after completing the ledger walk",
default: false,
},
json: {
type: "boolean",
description:
"Output as JSON: the recipe plus where the walk starts, or the recorded result with --reconciledTo",
"Output as JSON: the recipe plus where the walk starts, or the stamped result with --rules",
default: false,
},
anonymous: {
Expand All @@ -138,10 +141,10 @@ export const updateCommand = defineCommand({
async run({ args }) {
const cwd = resolve(args.dir ?? process.cwd());

// No `--reconciledTo`: this is the teaching path. Serve the SAME recipe
// No `--rules`: this is the teaching path. Serve the SAME recipe
// `agent update` serves, from the same renderer, so the two spellings
// cannot drift into two different sets of instructions.
if (args.reconciledTo === undefined) {
if (!args.rules) {
const telemetry = await getTelemetry(cwd);
const recipe = getRecipe("update", {
anonymous: args.anonymous,
Expand Down Expand Up @@ -190,14 +193,14 @@ export const updateCommand = defineCommand({

const telemetry = await getTelemetry(cwd);
try {
const result = await recordReconciliation(cwd, args.reconciledTo);
const result = await recordReconciliation(cwd);
if (args.json) {
console.log(JSON.stringify({ ok: true, ...result }));
} else {
console.log(
result.previous === undefined
? `Recorded: rules reconciled to ${result.reconciledTo} (ast-grep ${result.engines.sg}, Vale ${result.engines.vale}).`
: `Recorded: rules reconciled to ${result.reconciledTo}, was ${result.previous} (ast-grep ${result.engines.sg}, Vale ${result.engines.vale}).`
? `Rules reconciled to ${result.reconciledTo} (ast-grep ${result.engines.sg}, Vale ${result.engines.vale}).`
: `Rules reconciled to ${result.reconciledTo}, was ${result.previous} (ast-grep ${result.engines.sg}, Vale ${result.engines.vale}).`
);
}
telemetry.capture("cli_rules_reconciled");
Expand All @@ -218,7 +221,21 @@ export const updateCommand = defineCommand({
async function runNonInteractive(
cwd: string
): Promise<{ commandsInstalled: boolean }> {
// Sampled BEFORE the directory is created, and that order is the whole
// point. `ensureTasklessDirectory` mkdir -p's, so afterwards a pre-existing
// project is indistinguishable from a fresh one.
//
// This path is also `init --no-interactive`, whose documented job is
// refreshing an EXISTING project. Stamping there would mark a project that
// never walked the ledger as fully reconciled and skip every entry, which is
// the silent skip this feature exists to prevent.
const wasNewProject = !(await pathExists(join(cwd, TASKLESS_DIRECTORY)));
await ensureTasklessDirectory(cwd);
if (wasNewProject) {
// A project this CLI just created has no entries to walk: everything the
// ledger describes is already true of the scaffold it wrote.
await stampNewProjectRules(cwd);
}

const allSkills = getEmbeddedSkills();
const mandatoryNames = new Set(getMandatorySkillNames());
Expand Down
Loading
Loading