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

Report the `.taskless/` layout migration on the `--json` envelope.

`check`, `verify`, and `test` migrate the scaffold before they can do their real
work, and that rewrites files in the working tree: rules move into per-rule
directories, configs are deleted, `taskless.json` and `.gitignore` are rewritten.
Until now the only trace was one line of prose on stderr, so a CI script reading
`{"success":true}` had no way to learn that its checkout had changed underneath
it. The migration would then land in an unrelated commit, or run mid-suite and
fail tests that had nothing to do with the change being made.

Those commands now carry a `migrated` field when, and only when, a migration
ran:

```json
{
"success": true,
"results": [],
"migrated": {
"from": 3,
"to": 5,
"applied": [4, 5],
"files": {
"added": [".taskless/rules/sg/no-eval/no-eval.yml"],
"modified": [".taskless/taskless.json"],
"removed": [".taskless/sgconfig.yml"]
}
}
}
```

The field is absent when nothing happened, so presence is the signal and no
consumer has to read empty arrays to decide. Paths are relative to the project
root and sorted.

The migration keeps happening automatically, because these commands need a known
layout to run at all and the alternative is a hard failure on every upgrade. The
human notice improved to match the new field: it names the source and target
versions up front, and prints the files it touched on completion.
30 changes: 26 additions & 4 deletions packages/cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,26 @@ export const checkCommand = defineCommand({
// creates the scaffold, and `check` is a read-only command — running it in
// a project that has none should report that, not write one (and not fail
// on a read-only filesystem).
if (await pathExists(join(cwd, ".taskless"))) {
await ensureTasklessDirectory(cwd);
}
//
// The report is carried into the `--json` envelope below: migrating
// rewrites files in the caller's working tree, and a consumer reading
// `{"success":true}` would otherwise have nothing to attribute that diff
// to.
const migrated = (await pathExists(join(cwd, ".taskless")))
? await ensureTasklessDirectory(cwd, {
// Suppressed under `--json` for the same reason every other notice
// in this command is: the information is on the envelope's
// `migrated` field, and a machine consumer reading stderr gets
// prose it cannot parse. This one grew from a single line to a
// file-by-file summary, so leaving it ungated would hand a CI
// script that logs or fails on stderr a much noisier surprise than
// the one-liner it tolerated before.
onNotice: (message: string) => {
if (!args.json) console.error(message);
},
})
: undefined;
const migratedField = migrated === undefined ? {} : { migrated };
const dispatch = await planEngineDispatch(cwd);

// Static rules (trusted ast-grep YAML) always run; runtime rules
Expand Down Expand Up @@ -335,7 +352,11 @@ export const checkCommand = defineCommand({
if (args.json) {
console.log(
JSON.stringify(
checkOutputSchema.parse({ success: true, results: [] })
checkOutputSchema.parse({
success: true,
results: [],
...migratedField,
})
)
);
} else {
Expand Down Expand Up @@ -397,6 +418,7 @@ export const checkCommand = defineCommand({
const output = checkOutputSchema.parse({
success: exitCode === 0,
results,
...migratedField,
...(plan.skipped.length > 0 ? { skipped: plan.skipped } : {}),
...(dispatched.failures.length > 0
? { failures: dispatched.failures }
Expand Down
37 changes: 34 additions & 3 deletions packages/cli/src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
resolveRulePath,
RuleNotFoundError,
} from "../rules/resolve-path";
import { outputSchema as verifyTestOutputSchema } from "../schemas/verify-test";
import { makeErrorEnvelope } from "../types/errors";

/**
Expand All @@ -37,7 +38,21 @@ async function runOverPath(options: {
}): Promise<void> {
const { cwd, target, json, label, run } = options;

await ensureTasklessDirectory(cwd);
// Both commands need a current layout before a path means anything, so this
// migrates as a precondition. The report is what lets the run say it did:
// carried into the `--json` envelope below, and printed for a person.
const migrated = await ensureTasklessDirectory(cwd, {
// Suppressed under `--json` for the same reason every other notice
// in this command is: the information is on the envelope's
// `migrated` field, and a machine consumer reading stderr gets
// prose it cannot parse. This one grew from a single line to a
// file-by-file summary, so leaving it ungated would hand a CI
// script that logs or fails on stderr a much noisier surprise than
// the one-liner it tolerated before.
onNotice: (message: string) => {
if (!json) console.error(message);
},
});

let rules;
try {
Expand Down Expand Up @@ -66,7 +81,15 @@ async function runOverPath(options: {
// has not written a rule yet, and failing here would make `verify` unusable
// in CI on a fresh install.
if (json) {
console.log(JSON.stringify({ ok: true, rules: [] }));
console.log(
JSON.stringify(
verifyTestOutputSchema.parse({
ok: true,
rules: [],
...(migrated === undefined ? {} : { migrated }),
})
)
);
} else {
console.log(`No rules found under ${target}.`);
}
Expand All @@ -81,7 +104,15 @@ async function runOverPath(options: {
const failed = results.filter((result) => !result.ok);

if (json) {
console.log(JSON.stringify({ ok: failed.length === 0, rules: results }));
console.log(
JSON.stringify(
verifyTestOutputSchema.parse({
ok: failed.length === 0,
rules: results,
...(migrated === undefined ? {} : { migrated }),
})
)
);
} else {
for (const result of results) {
const mark = result.ok ? "✓" : "✗";
Expand Down
23 changes: 16 additions & 7 deletions packages/cli/src/filesystem/directory.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";

import { runMigrations } from "./migrate";
import { runMigrations, type MigrationReport } from "./migrate";

export interface EnsureOptions {
/**
* Called once when a migration run is about to start. Callers that render
* their own UI (e.g., the interactive wizard using clack) can use this to
* keep the message inside their visual tree. When omitted, the migration
* runner falls back to its default `console.error` message.
* Called TWICE when migrations run: once as the run starts, and once on
* completion with the file-by-file summary. Callers that render their own
* UI (e.g., the interactive wizard using clack) can use this to keep both
* messages inside their visual tree. When omitted, the migration runner
* falls back to writing them with `console.error`.
*
* Callers that emit `--json` should pass a callback that suppresses output
* under that flag: the same information is on the envelope's `migrated`
* field, and a machine consumer reading stderr gets prose it cannot parse.
*/
onNotice?: (message: string) => void;
/**
Expand All @@ -22,14 +27,18 @@ export interface EnsureOptions {
* Ensure the .taskless/ directory exists and is up-to-date by running
* any pending migrations. Safe to call repeatedly — returns immediately
* if already current.
*
* Returns the {@link MigrationReport} when migrations ran, so the command that
* triggered them can say so in its own output, and `undefined` when the
* scaffold was already current.
*/
export async function ensureTasklessDirectory(
cwd: string,
options: EnsureOptions = {}
): Promise<void> {
): Promise<MigrationReport | undefined> {
Comment thread
thecodedrift marked this conversation as resolved.
const tasklessDirectory = join(cwd, ".taskless");
await mkdir(tasklessDirectory, { recursive: true });
await runMigrations(tasklessDirectory, {
return runMigrations(tasklessDirectory, {
onNotice: options.onNotice,
allowVersionMismatches: options.allowVersionMismatches,
});
Expand Down
95 changes: 87 additions & 8 deletions packages/cli/src/filesystem/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join } from "node:path";

import { CLIError } from "../util/cli-error";
import type { Migrations } from "./types";
import { diffSnapshots, snapshotPaths, type TreeChanges } from "./snapshot";
import init from "./migrations/0001-init";
import installMigration from "./migrations/0002-install";
import dropInstalledAt from "./migrations/0003-drop-installed-at";
Expand Down Expand Up @@ -158,11 +159,62 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* What one migration run changed, in a form a caller can print or hand to a
* machine consumer.
*
* The migration is a precondition of `check` and `verify` rather than a side
* effect, so it will keep happening automatically. What this makes possible is
* for the run that triggered it to *say so*: without a report, a caller reading
* `{"success":true}` has no way to learn that the working tree was rewritten
* underneath it, and an unexplained diff is left looking like someone else's.
*/
export interface MigrationReport {
/** Scaffold version found on disk before anything ran. */
from: number;
/** Scaffold version written on completion. */
to: number;
/** Every migration applied, in the order they ran. */
applied: number[];
/** Files the run added, rewrote, or deleted, relative to the project root. */
files: TreeChanges;
}

/** Paths a migration can write, relative to the project root. */
const WATCHED_PATHS = [".taskless", ".gitignore"];

/** Cap on how many paths the human notice lists before summarizing. */
const NOTICE_PATH_LIMIT = 20;

/** Render {@link MigrationReport} as the completion notice a person reads. */
export function formatMigrationNotice(report: MigrationReport): string {
const { added, modified, removed } = report.files;
const lines = [
...added.map((path) => ` + ${path}`),
...modified.map((path) => ` ~ ${path}`),
...removed.map((path) => ` - ${path}`),
];
const total = lines.length;
const shown =
total > NOTICE_PATH_LIMIT
? [
...lines.slice(0, NOTICE_PATH_LIMIT),
` ... and ${String(total - NOTICE_PATH_LIMIT)} more`,
]
: lines;
const headline =
`Migrated .taskless/ from schema version ${String(report.from)} to ` +
`${String(report.to)}: ${String(added.length)} added, ` +
`${String(modified.length)} modified, ${String(removed.length)} removed.`;
return total === 0 ? headline : [headline, ...shown].join("\n");
}

export interface RunMigrationsOptions {
/**
* Called once before the first pending migration runs. Defaults to a
* bare `console.error` notice. Callers that own their own UI can pass a
* custom handler to route the notice through their logger.
* Called once before the first pending migration runs and once after the
* run completes, the second time with the file-by-file summary. Defaults to
* a bare `console.error` notice. Callers that own their own UI can pass a
* custom handler to route the notices through their logger.
*/
onNotice?: (message: string) => void;
/**
Expand All @@ -181,20 +233,24 @@ export interface RunMigrationsOptions {
* Throws when the manifest's version is *newer* than the highest migration
* this CLI knows: an older CLI cannot safely read a layout written by a newer
* one, so it fails loudly rather than half-reading it.
*
* Returns a {@link MigrationReport} when something ran, and `undefined` when
* nothing did. The distinction is the point: a caller must be able to tell
* "the tree was rewritten" from "nothing happened" without guessing.
*/
export async function runMigrations(
tasklessDirectory: string,
options: RunMigrationsOptions = {}
): Promise<void> {
): Promise<MigrationReport | undefined> {
const sorted = sortedMigrations(migrations);
if (sorted.length === 0) return;
if (sorted.length === 0) return undefined;

const maxVersion = sorted.at(-1)![0];
const { version } = await readRawManifest(tasklessDirectory);

if (version > maxVersion) {
if (options.allowVersionMismatches ?? hasVersionMismatchOverride()) {
return;
return undefined;
}
throw new CLIError(
`This project's .taskless/ scaffold is version ${String(version)}, but this CLI only understands version ${String(maxVersion)}. ` +
Expand All @@ -204,12 +260,23 @@ export async function runMigrations(
}

if (version === maxVersion) {
return;
return undefined;
}

const pending = sorted.filter(([v]) => v > version);
const notice = options.onNotice ?? ((message) => console.error(message));
notice("Migrating to latest .taskless/ schema...");
notice(
`Migrating .taskless/ from schema version ${String(version)} to ${String(maxVersion)}...`
);
Comment thread
thecodedrift marked this conversation as resolved.

// Observed rather than self-reported: the migrations write through plain
// `fs` calls in five separate modules, and asking each to keep a list of
// what it touched would leave the report only as honest as its bookkeeping.
// Hashing the tree before and after answers the question the caller is
// actually asking, which is what changed on disk.
const projectRoot = join(tasklessDirectory, "..");
const before = await snapshotPaths(projectRoot, WATCHED_PATHS);

for (const [v, migrate] of pending) {
try {
await migrate(tasklessDirectory);
Expand Down Expand Up @@ -238,4 +305,16 @@ export async function runMigrations(
...latestRaw,
version: maxVersion,
});

const report: MigrationReport = {
from: version,
to: maxVersion,
applied: pending.map(([v]) => v),
files: diffSnapshots(
before,
await snapshotPaths(projectRoot, WATCHED_PATHS)
),
};
notice(formatMigrationNotice(report));
return report;
}
Loading
Loading