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/ast-grep-0-51-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@taskless/cli": patch
---

Update the bundled ast-grep to 0.51.0 (from 0.45.3).

What a rule author sees:

- **Haskell rules on `kind: type_synomym` are renamed to `kind: type_synonym`.** ast-grep's Haskell grammar fixed the typo in that kind name, and a rule still using the old spelling fails to load. Because `taskless check` loads every ast-grep rule together, `check` would report the failure and no ast-grep findings at all. `taskless update --rules` now applies the rename itself when it records a reconciliation, naming each file it rewrote. No other Haskell kind or field changed.
- `taskless update --rules` applies mechanical rule rewrites for the engine versions a project's rules cross, keyed on the engine versions it last recorded rather than on the `.taskless/` layout version, so they never force a scaffold bump. `taskless update --json` lists them in advance. It now refuses with `SCAFFOLD_MIGRATION_REQUIRED` when the layout is behind; run `taskless init` first.
- `sg` rules can target `Zig`.
- A rule file ending in a stray `---` no longer stops `taskless check` from reporting any ast-grep findings for the project. ast-grep used to read the empty document after it as a broken rule and abort the whole scan. `taskless validate` still flags the file as multi-document YAML.
- The `.taskless/` layout does not change, so a pinned CLI keeps reading the project.
16 changes: 8 additions & 8 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"tsx": "^4.21.0"
},
"devDependencies": {
"@ast-grep/cli": "0.45.3",
"@ast-grep/cli": "0.51.0",
"@clack/prompts": "^1.2.0",
"@jedmao/ini-parser": "0.2.4",
"@types/sprintf-js": "^1.1.4",
Expand All @@ -85,13 +85,13 @@
"taskless": "./dist/index.js"
},
"optionalDependencies": {
"@ast-grep/cli-darwin-arm64": "0.45.3",
"@ast-grep/cli-darwin-x64": "0.45.3",
"@ast-grep/cli-linux-arm64-gnu": "0.45.3",
"@ast-grep/cli-linux-x64-gnu": "0.45.3",
"@ast-grep/cli-win32-arm64-msvc": "0.45.3",
"@ast-grep/cli-win32-ia32-msvc": "0.45.3",
"@ast-grep/cli-win32-x64-msvc": "0.45.3",
"@ast-grep/cli-darwin-arm64": "0.51.0",
"@ast-grep/cli-darwin-x64": "0.51.0",
"@ast-grep/cli-linux-arm64-gnu": "0.51.0",
"@ast-grep/cli-linux-x64-gnu": "0.51.0",
"@ast-grep/cli-win32-arm64-msvc": "0.51.0",
"@ast-grep/cli-win32-ia32-msvc": "0.51.0",
"@ast-grep/cli-win32-x64-msvc": "0.51.0",
"@taskless/vale-darwin-arm64": "3.23.0-20260930045834",
"@taskless/vale-darwin-x64": "3.23.0-20260930045834",
"@taskless/vale-linux-arm64": "3.23.0-20260930045834",
Expand Down
50 changes: 47 additions & 3 deletions packages/cli/src/agent/update.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: update (CLI v%(CLI_VERSION)s / topic v17)
# Topic: update (CLI v%(CLI_VERSION)s / topic v18)

%(ONE_COMMAND_PER_CALL)s

Expand All @@ -8,8 +8,10 @@ ALREADY IN THIS PROJECT, and what to do about them.

It is not about installing. Running `%(TASKLESS_CLI)s` migrates the
`.taskless/` layout and refreshes the installed skills on its own. That
handles the DIRECTORY. This recipe handles the RULES, which no migration
can rewrite for you: a rewriter that now needs a `fix`, a rule whose
handles the DIRECTORY. This recipe handles the RULES. A purely
mechanical rewrite, such as a kind an engine renamed, is applied by
`update --rules` when you record the walk. The rest needs judgment no
migration can supply: a rewriter that now needs a `fix`, a rule whose
matching semantics shifted under a new engine, a rule that could now be
expressed better with a newly supported language.

Expand Down Expand Up @@ -103,6 +105,16 @@ that you finished.
This also records the engine versions the rules are now valid
against, which is what lets a later upgrade say what moved.

Before it records them, it applies every mechanical rewrite between
the engine versions the rules were last reconciled against and the
bundled ones, and names each file it changed (`migrated` with
`--json`). `ruleMigrations` in `update --json` lists them in advance.
A section below that says `update --rules` does something needs
nothing from you beyond this step. Read the rewritten files in the
diff, run `%(TASKLESS_CLI)s validate`, and commit them with the
marker. If the project's `.taskless/` layout is behind, this refuses
with `SCAFFOLD_MIGRATION_REQUIRED`; run `%(TASKLESS_CLI)s init` first.

## The ledger

### Migrating to 0.11.x
Expand Down Expand Up @@ -483,13 +495,45 @@ until now and starts reporting.
by `check`, and `validate` rejected it before this release while Vale
loaded it without complaint.

### Migrating to 0.13.3
Comment thread
theCodeDrift marked this conversation as resolved.

The vendored ast-grep moves from 0.45.3 to 0.51.0. Nothing here needs
work by hand.

**A Haskell type synonym is `type_synonym` now, not `type_synomym`, and
`update --rules` renames it for you.** ast-grep's Haskell grammar moved
from tree-sitter-haskell 0.23.1 to 0.24.1, which fixed the typo in that
kind's name. Diffed across both grammars, it is the only kind or field
that changed. A rule still naming the old kind is a HARD CONFIG ERROR:
exit 8, ``Kind `type_synomym` is invalid``, and since `check` loads
every sg rule into one config it reports no sg findings at all until
the rule is fixed.

Do not rename it by hand. Step 5 rewrites every `kind: type_synomym` in
a rule whose `language` is Haskell, leaves everything else in the file
as it was, and names each file it changed. No rule Taskless issued uses
the old kind, so the rewrite never touches a signed rule.

**A rule file ending in a stray `---` no longer breaks `check`.** On
0.45.3 ast-grep read the empty document after it as a second rule with
no `language`, exited 8, and `check` reported no sg findings at all.
It now loads. If `check` was failing on that, findings come back and
are real. `validate` still rejects the file as multi-document YAML, so
delete the trailing `---` when you next touch the rule.

**`Zig` is a language.** `language: Zig` parses, so a rule over `.zig`
files that was routed to the runtime tier, or not written because
ast-grep could not parse Zig, can now be an sg rule. No existing rule
changes.

## Errors

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

| code | meaning | fix |
|-----------------|------------------------------------------------|--------------------------------------------|
| `INVALID_INPUT` | no `.taskless/`, or an older CLI would rewind the marker | set the project up, or upgrade first |
| `SCAFFOLD_MIGRATION_REQUIRED` | the `.taskless/` layout is behind this CLI, so rule rewrites have nowhere safe to write | run `%(TASKLESS_CLI)s init`, then `update --rules` again |

## See Also

Expand Down
31 changes: 30 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ import {
stampNewProjectRules,
} from "../rules/reconcile-marker";
import { readManifest } from "../filesystem/manifest";
import type { MigrationReport } from "../filesystem/migrate";
import {
requireCurrentSchema,
type MigrationReport,
} from "../filesystem/migrate";
import { pendingRuleMigrations } from "../rules/rule-migrate";
import { TASKLESS_DIRECTORY } from "../rules/vale/formats";
import { CLIError } from "../util/cli-error";
import { buildInvocation } from "../util/invocation";
Expand Down Expand Up @@ -291,6 +295,19 @@ export const updateCommand = defineCommand({
// never recorded a reconciliation, which is not the same as being
// behind, or it is already current.
walk: walk ?? null,
// The rewrites `update --rules` will apply before it stamps, so
// the walk can tell its reader what it does not need to do by
// hand. Empty without a project, which has nothing to rewrite.
ruleMigrations:
manifest === undefined
? []
: pendingRuleMigrations(manifest.rules?.engines).map(
({ engine, engineVersion, summary }) => ({
engine,
engineVersion,
summary,
})
),
recipe,
})
);
Expand All @@ -303,6 +320,10 @@ export const updateCommand = defineCommand({

const telemetry = await getTelemetry(cwd);
try {
// Rule migrations rewrite files where the CURRENT layout puts them, so
// a scaffold that is behind has to be migrated first. Checked here and
// not in `recordReconciliation`, because `migrate` imports the marker.
await requireCurrentSchema(cwd);
const result = await recordReconciliation(cwd);
if (args.json) {
console.log(JSON.stringify({ ok: true, ...result }));
Expand All @@ -312,6 +333,14 @@ export const updateCommand = defineCommand({
? `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}).`
);
for (const migration of result.migrated) {
console.log(
`${migration.summary} (${migration.engine} ${migration.engineVersion}): ` +
(migration.files.length === 0
? "no rule needed it."
: `rewrote ${migration.files.join(", ")}.`)
);
}
}
telemetry.capture("cli_rules_reconciled");
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/generated/ast-grep-rule-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -743,5 +743,5 @@
"additionalProperties": true
}
},
"$comment": "Generated by fetch-ast-grep-schema.ts from ast-grep v0.45.3 (https://raw.githubusercontent.com/ast-grep/ast-grep/0.45.3/schemas/rule.json)"
"$comment": "Generated by fetch-ast-grep-schema.ts from ast-grep v0.51.0 (https://raw.githubusercontent.com/ast-grep/ast-grep/0.51.0/schemas/rule.json)"
}
3 changes: 2 additions & 1 deletion packages/cli/src/rules/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
* Pinned against the binary by `test/ast-grep-vendor-contract.test.ts`
* ("engine capabilities" → "reports the pinned version").
*/
export const AST_GREP_VERSION = "0.45.3";
export const AST_GREP_VERSION = "0.51.0";

/**
* Every language ast-grep can parse, verbatim from
Expand Down Expand Up @@ -91,6 +91,7 @@ export const AST_GREP_LANGUAGES = [
"Tsx",
"TypeScript",
"Yaml",
"Zig",
] as const;

/** One of the spellings {@link AST_GREP_LANGUAGES} lists, canonically cased. */
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/rules/reconcile-marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { access } from "node:fs/promises";
import { join } from "node:path";

import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities";
import { applyRuleMigrations, type AppliedRuleMigration } from "./rule-migrate";
import { readManifest, writeManifest } from "../filesystem/manifest";
import { TASKLESS_DIRECTORY } from "./vale/formats";
import { CLIError } from "../util/cli-error";
Expand All @@ -27,6 +28,8 @@ export interface ReconcileResult {
engines: { sg: string; vale: string };
/** The value replaced, or `undefined` on a project that had never recorded one. */
previous: string | undefined;
/** Rule rewrites applied for the engine versions this stamp crosses. */
migrated: AppliedRuleMigration[];
}

/**
Expand Down Expand Up @@ -98,6 +101,15 @@ export async function recordReconciliation(
);
}

// Before the stamp, never after: the stamp records these engines, and a
// marker naming an engine whose rewrites did not run would excuse the next
// walk from them. A failure part-way leaves the marker where it was, and
// every migration is idempotent, so the rerun finishes the job.
const migrated = await applyRuleMigrations(
tasklessDirectory,
manifest.rules?.engines
);

const rules = {
...manifest.rules,
reconciledTo,
Expand All @@ -108,7 +120,7 @@ export async function recordReconciliation(

await writeManifest(tasklessDirectory, { ...manifest, rules }, raw);

return { reconciledTo, engines: rules.engines, previous };
return { reconciledTo, engines: rules.engines, previous, migrated };
}

/**
Expand Down
124 changes: 124 additions & 0 deletions packages/cli/src/rules/rule-migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { join } from "node:path";

import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities";
import haskellTypeSynonym from "./rule-migrations/sg-0-50-0-haskell-type-synonym";
import { compareVersions } from "../util/version-compare";

/**
* Mechanical rewrites of rule CONTENT, applied when a project's rules cross an
* engine version.
*
* Not scaffold migrations, and deliberately keyed on a different version.
* The scaffold `version` records the LAYOUT, and every bump of it makes a
* pinned older CLI refuse the project with `SCAFFOLD_VERSION_MISMATCH`. An
* engine upgrade that renames a kind or retires a key touches the few projects
* with a rule in that shape, so spending a scaffold bump on it would break
* every pinned CI for a change almost none of them need. These are keyed on
* `rules.engines` instead: the engine versions the rules were last reconciled
* against, recorded by `update --rules` and nowhere else.
*
* `update --rules` applies them, after the ledger walk and before it stamps
* the marker, so the marker never records an engine whose rewrites did not
* run. Each migration runs when the recorded engine version is below its
* `engineVersion` and the bundled engine is at or above it.
*
* A migration MUST:
*
* - be idempotent, and leave a file it has nothing to do in untouched, so a
* rerun after a partial failure (nothing is stamped then) changes nothing
* already done and `git status` stays clean on a project it does not apply
* to;
* - edit bytes in place rather than parse and re-serialize, so every comment
* and blank line stays where the author put it;
* - rewrite only shapes the Taskless service has not issued, or has already
* migrated in its own corpus. An issued rule is checked against the bytes
* the service recorded, and accepting any other bytes client-side would be a
* signature bypass. A rewritten issued rule reads as "edited since Taskless
* issued it", and `rule restore` then writes the old shape straight back.
*/
export type RuleEngine = "sg" | "vale";

export interface RuleMigration {
engine: RuleEngine;
/** The first engine version on which the rewritten shape is required. */
engineVersion: string;
/** One line saying what changes, as `update --rules` reports it. */
summary: string;
/**
* Rewrite the rules under `engineDirectory` (`.taskless/rules/<engine>`),
* returning each file written, relative to that directory.
*/
apply: (engineDirectory: string) => Promise<string[]>;
}

/** What one applied migration reports: its identity and the files it wrote. */
export interface AppliedRuleMigration {
engine: RuleEngine;
engineVersion: string;
summary: string;
/** Written files, relative to the project root. */
files: string[];
}

const RULE_MIGRATIONS: readonly RuleMigration[] = [haskellTypeSynonym];

const BUNDLED: Record<RuleEngine, string> = {
sg: AST_GREP_VERSION,
vale: VALE_VERSION,
};

/**
* A rules block that never recorded an engine predates the marker, so it has
* had none of these applied. The same reading `BASELINE_VERSION` gives an
* absent `reconciledTo`; restated rather than imported, because
* `reconcile-marker` imports this module.
*/
const UNRECORDED = "0.0.0";

/** The migrations a project with `recorded` engines still needs, in order. */
export function pendingRuleMigrations(
recorded: Partial<Record<RuleEngine, string>> | undefined,
migrations: readonly RuleMigration[] = RULE_MIGRATIONS
): RuleMigration[] {
return migrations
.filter(
(migration) =>
compareVersions(
recorded?.[migration.engine] ?? UNRECORDED,
migration.engineVersion
) < 0 &&
compareVersions(migration.engineVersion, BUNDLED[migration.engine]) <= 0
)
.toSorted(
(a, b) =>
a.engine.localeCompare(b.engine) ||
compareVersions(a.engineVersion, b.engineVersion)
);
}

/**
* Apply every pending migration under `tasklessDirectory`, in order.
*
* Every migration is reported, including one that wrote nothing, so the
* output says what was checked and not only what changed.
*/
export async function applyRuleMigrations(
tasklessDirectory: string,
recorded: Partial<Record<RuleEngine, string>> | undefined,
migrations: readonly RuleMigration[] = RULE_MIGRATIONS
): Promise<AppliedRuleMigration[]> {
const applied: AppliedRuleMigration[] = [];
for (const migration of pendingRuleMigrations(recorded, migrations)) {
const engineDirectory = join(tasklessDirectory, "rules", migration.engine);
const written = await migration.apply(engineDirectory);
applied.push({
engine: migration.engine,
engineVersion: migration.engineVersion,
summary: migration.summary,
files: written.map((file) =>
join(".taskless", "rules", migration.engine, file)
),
});
}
return applied;
}
Loading
Loading