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/reconcile-after-engine-bump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

`taskless init` now says when the project's rules need `update --rules`, on every run rather than only when the CLI version moves, with the same answer as `reconcile` (`{ required, engines, ruleMigrations }`) on `init --json` and `update --json`, and as `rules.reconcile` on `info --json`. Previously, moving between two nightlies of one release that bumped ast-grep or Vale reported nothing to walk, while `check` and `verify` refused with `RULES_ENGINE_MISMATCH` until `update --rules` ran.
13 changes: 12 additions & 1 deletion 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 v21)
# Topic: update (CLI v%(CLI_VERSION)s / topic v22)

%(ONE_COMMAND_PER_CALL)s

Expand Down Expand Up @@ -49,6 +49,17 @@ that you finished.
`null` when there is not. Prefer it to re-deriving the boundary
yourself, so the offer and the enforcement cannot disagree.

**A `null` walk does not mean you are done.** Read
`rules.reconcile.required` too. It is `true` when there is a walk,
and also when `rules.reconcile.engines` lists an engine whose rules
are recorded behind the one this CLI bundles. That second case has no
ledger section: every nightly of one release reads as that release,
so an ast-grep or Vale bump between two nightlies leaves `walk` null
while `check` and `verify` refuse with `RULES_ENGINE_MISMATCH`. With
`required` true and `walk` null, skip steps 2 and 3 and go on to step
4. With `required` false, the rules need nothing and step 5 has
nothing to record.

2. **Walk forward, in order.** Start at the section after
`reconciledTo` and continue to the installed version. Sections are
cumulative, never replaceable: a later one can depend on an earlier
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { outputSchema as infoOutputSchema } from "../schemas/info";
import { makeErrorEnvelope } from "../types/errors";
import { resolveRepositoryContext } from "../util/git-remote";
import { readManifest } from "../filesystem/manifest";
import { reconciliationStart } from "../rules/reconcile-marker";
import { reconcileReport, reconcileStatus } from "../rules/reconcile-status";
import { TASKLESS_DIRECTORY } from "../rules/vale/formats";

export const infoCommand = defineCommand({
Expand Down Expand Up @@ -76,6 +76,8 @@ export const infoCommand = defineCommand({
}
}

const reconcile = await reconcileStatus(cwd, manifest?.rules);

const result = {
success: true as const,
version: __VERSION__,
Expand Down Expand Up @@ -111,7 +113,11 @@ export const infoCommand = defineCommand({
// 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,
walk: reconcile.walk,
// Whether `update --rules` is due, which `walk` alone cannot say: an
// engine bump between two nightlies of one release owes no walk and
// still trips the engine gate.
reconcile: reconcileReport(reconcile),
},
pinnedCli,
};
Expand Down
63 changes: 48 additions & 15 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,19 @@ import {
import {
pathExists,
recordReconciliation,
reconciliationStart,
stampNewProjectRules,
} from "../rules/reconcile-marker";
import { readManifest } from "../filesystem/manifest";
import {
requireCurrentSchema,
type MigrationReport,
} from "../filesystem/migrate";
import { pendingRuleMigrations } from "../rules/rule-migrate";
import {
getReconcileNotice,
reconcileReport,
reconcileStatus,
type ReconcileStatus,
} from "../rules/reconcile-status";
import { TASKLESS_DIRECTORY } from "../rules/vale/formats";
import { CLIError } from "../util/cli-error";
import { buildInvocation } from "../util/invocation";
Expand Down Expand Up @@ -172,6 +176,10 @@ export const initCommand = defineCommand({
// Always present, so the agent can tell the user telemetry is on
// and how to turn it off; the human path prints the notice instead.
telemetry: { enabled: telemetryEnabled },
// Whether the rules need `update --rules`, on every run rather
// than only on a version move. The shape `info --json` reports as
// `rules.reconcile` and `update --json` as `reconcile`.
reconcile: reconcileReport(result.rules),
// Absent when nothing ran, so a caller distinguishes "the tree was
// rewritten" from "nothing happened" by presence, never by reading
// empty arrays out of it.
Expand All @@ -196,10 +204,21 @@ export const initCommand = defineCommand({
// version the project now records. A build with no launcher names
// the invocation the person used.
invocation: buildLauncherInvocation() ?? getCliPrefix(),
reconcileDue: result.rules.required,
});
if (upgradeTrailer !== undefined) {
console.log(upgradeTrailer);
}
// Not gated on the run having changed anything, like the pin notice
// below. Between two nightlies, or on a re-run after an upgrade nobody
// reconciled, nothing moves and `check` still refuses.
const reconcileNotice = getReconcileNotice(
result.rules,
buildLauncherInvocation() ?? getCliPrefix()
);
if (reconcileNotice !== undefined) {
console.log(reconcileNotice);
}
// Not gated on the run having changed anything. A re-install that wrote
// nothing still leaves the pin and the project disagreeing, and this is
// the one place that looks.
Expand Down Expand Up @@ -306,7 +325,7 @@ export const updateCommand = defineCommand({
const { manifest } = await readManifest(
join(cwd, TASKLESS_DIRECTORY)
).catch(() => ({ manifest: undefined }));
const walk = reconciliationStart(manifest?.rules?.reconciledTo);
const status = await reconcileStatus(cwd, manifest?.rules);
console.log(
JSON.stringify({
ok: true,
Expand All @@ -316,20 +335,26 @@ export const updateCommand = defineCommand({
// `null` when there is nothing to walk: either the project has
// never recorded a reconciliation, which is not the same as being
// behind, or it is already current.
walk: walk ?? null,
walk: status.walk,
// Whether to finish with `update --rules`, which a null `walk`
// does not settle: an engine bump between two nightlies of one
// release has no ledger section and still trips the engine gate.
// Without a project nothing is due and nothing would be rewritten.
reconcile: reconcileReport(
manifest === undefined
? {
...status,
required: false,
engines: [],
ruleMigrations: [],
}
: status
),
// 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,
})
),
// hand. Also on `reconcile`; kept here for callers that read it
// from before `reconcile` existed.
ruleMigrations: manifest === undefined ? [] : status.ruleMigrations,
recipe,
})
);
Expand Down Expand Up @@ -421,6 +446,8 @@ async function runNonInteractive(
permissions: PermissionEdit[];
/** User-level trust entries, read and never written. */
globalTrust: GlobalTrustOutcome[];
/** Whether the rules need `update --rules`, read after any stamp. */
rules: ReconcileStatus;
}> {
// Under `--json`, stdout carries only the envelope printed by the caller.
// This per-target summary is not on that envelope (it is finer-grained than
Expand Down Expand Up @@ -572,6 +599,11 @@ async function runNonInteractive(
log(warning);
}

// Read after `stampNewProjectRules`, so a project this run created reports
// nothing due.
const { manifest } = await readManifest(join(cwd, TASKLESS_DIRECTORY));
const rules = await reconcileStatus(cwd, manifest.rules);

return {
reloadNotice,
// Read after every write above, so it lists what this run left behind.
Expand All @@ -598,6 +630,7 @@ async function runNonInteractive(
launcherVersionMoved(previousState, result.state) ||
versionMoved({ previousCliVersion, cliVersion }),
pinnedCli: await findCliPins(cwd, cliVersion, { migrated }),
rules,
};
}

Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/install/upgrade-trailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export interface UpgradeTrailerInput {
cliVersion: string;
/** The invocation to print in front of `update`, e.g. `./.taskless/bin/cli.mjs`. */
invocation: string;
/**
* Whether `init` prints the reconcile notice after this trailer. That notice
* names `update` and `update --rules` with the reason, so this one states
* only the move rather than sending the reader to `update` twice.
*/
reconcileDue?: boolean;
}

/**
Expand Down Expand Up @@ -48,9 +54,11 @@ export function getUpgradeTrailer(
];

if (versionMoved(input)) {
const moved = `The CLI moved from ${input.previousCliVersion ?? ""} to ${input.cliVersion}.`;
lines.push(
`The CLI moved from ${input.previousCliVersion ?? ""} to ${input.cliVersion}. ` +
`Run \`${input.invocation} update\` to learn what that changes for the rules already in this project.`
input.reconcileDue === true
? moved
: `${moved} Run \`${input.invocation} update\` to learn what that changes for the rules already in this project.`
);
}

Expand Down
27 changes: 18 additions & 9 deletions packages/cli/src/rules/engine-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ import { compareVersions } from "../util/version-compare";
* must not be refused by the gate it exists to clear.
*/

const ENGINE_LABELS = { sg: "ast-grep", vale: "Vale" } as const;
export const ENGINE_LABELS = { sg: "ast-grep", vale: "Vale" } as const;

type GatedEngine = keyof typeof ENGINE_LABELS;
export type GatedEngine = keyof typeof ENGINE_LABELS;

const BUNDLED: Record<GatedEngine, string> = {
sg: AST_GREP_VERSION,
Expand Down Expand Up @@ -68,6 +68,18 @@ export function findEngineMismatches(
return mismatches;
}

/** The gated engines that have at least one rule in this project. */
export async function findEnginesWithRules(
cwd: string
): Promise<Set<GatedEngine>> {
const engines = new Set<GatedEngine>();
for (const engine of Object.keys(ENGINE_LABELS) as GatedEngine[]) {
const ids = await listRuleIds(cwd, engine);
if (ids.length > 0) engines.add(engine);
}
return engines;
}

/** The refusal's text: what differs, and the remedy for each direction. */
export function engineMismatchMessage(
mismatches: EngineMismatch[],
Expand Down Expand Up @@ -118,13 +130,10 @@ export async function requireReconciledEngines(cwd: string): Promise<void> {
}
if (recorded === undefined) return;

const enginesWithRules = new Set<GatedEngine>();
for (const engine of Object.keys(ENGINE_LABELS) as GatedEngine[]) {
const ids = await listRuleIds(cwd, engine);
if (ids.length > 0) enginesWithRules.add(engine);
}

const mismatches = findEngineMismatches(recorded, enginesWithRules);
const mismatches = findEngineMismatches(
recorded,
await findEnginesWithRules(cwd)
);
if (mismatches.length === 0) return;
throw new CLIError(
engineMismatchMessage(mismatches, getCliPrefix()),
Expand Down
124 changes: 124 additions & 0 deletions packages/cli/src/rules/reconcile-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
ENGINE_LABELS,
findEngineMismatches,
findEnginesWithRules,
type EngineMismatch,
} from "./engine-gate";
import { reconciliationStart } from "./reconcile-marker";
import { pendingRuleMigrations } from "./rule-migrate";
import type { TasklessRulesManifest } from "../filesystem/manifest";
import { compareVersions } from "../util/version-compare";

/**
* Whether a project's rules still need `update --rules`, decided once.
*
* Two things can make a reconciliation due, and they move independently. The
* ledger walk is keyed on the CLI version, compared on its numeric core, so
* every nightly of one release reads as that release and owes no walk. The
* engine gate is keyed on the exact engine versions, and an ast-grep or Vale
* bump lands between two nightlies of one release as easily as between two
* releases. Asking only the first question is how a project on nightlies was
* told "nothing to walk" by `info` and `update` while `check` refused it with
* `RULES_ENGINE_MISMATCH`, so the advice and the enforcement disagreed and
* only the refusal said what to run.
*
* `walk` keeps its narrower meaning, the ledger sections to read. `required`
* is the answer a caller acts on: run the `update` recipe through to its last
* step, which applies `ruleMigrations` and records the engines.
*/
export interface ReconcileStatus {
/**
* Whether the `update` recipe has work to do, ending in `update --rules`.
* While `engines` is non-empty, `check` and `verify` also refuse until then.
*/
required: boolean;
/** The ledger sections to read, or `null` when there are none. */
walk: { from: string; to: string } | null;
/**
* Engines with rules recorded BEHIND the bundled version: what `check`
* refuses on and `update --rules` clears. An engine recorded ahead is not
* listed, since reconciling with an older CLI does not clear it; the
* refusal itself names the remedy for that.
*/
engines: EngineMismatch[];
/** The mechanical rewrites `update --rules` applies before it stamps. */
ruleMigrations: Array<{
engine: string;
engineVersion: string;
summary: string;
}>;
}

export async function reconcileStatus(
cwd: string,
rules: TasklessRulesManifest | undefined
): Promise<ReconcileStatus> {
const walk = reconciliationStart(rules?.reconciledTo) ?? null;
// The same comparison the gate makes, so what this reports and what `check`
// enforces cannot drift apart.
const engines = findEngineMismatches(
rules?.engines,
await findEnginesWithRules(cwd)
).filter(({ recorded, bundled }) => compareVersions(recorded, bundled) < 0);
const ruleMigrations = pendingRuleMigrations(rules?.engines).map(
({ engine, engineVersion, summary }) => ({ engine, engineVersion, summary })
);
return {
required: walk !== null || engines.length > 0,
walk,
engines,
ruleMigrations,
};
}

/**
* The `reconcile` object every `--json` payload carries: `info` under
* `rules`, `init` and `update` at the top level. One shape, built here, so an
* agent reads the same path whichever command it ran. `walk` is left out
* because each payload already reports it where it always has.
*/
export function reconcileReport(
status: ReconcileStatus
): Omit<ReconcileStatus, "walk"> {
return {
required: status.required,
engines: status.engines,
ruleMigrations: status.ruleMigrations,
};
}

/**
* The line `init` prints when a reconciliation is due, or `undefined` when it
* is not. Printed on every run that finds one due, not only on a version move:
* an install between two nightlies, or a re-run after an upgrade nobody
* reconciled, moves nothing and still leaves `check` refusing.
*/
export function getReconcileNotice(
status: ReconcileStatus,
invocation: string
): string | undefined {
if (!status.required) return undefined;
const reasons: string[] = [];
if (status.walk !== null) {
reasons.push(
`the ledger has entries from ${status.walk.from} to ${status.walk.to}`
);
}
for (const { engine, recorded, bundled } of status.engines) {
reasons.push(
`${ENGINE_LABELS[engine]} moved from ${recorded} to ${bundled}`
);
}
const rewrites =
status.ruleMigrations.length === 0
? ""
: ` It applies ${String(status.ruleMigrations.length)} mechanical rule rewrite(s) for you.`;
const refusal =
status.engines.length === 0
? ""
: ", so `check` and `verify` will refuse to run them until it is recorded";
return (
`This project's rules are not reconciled to this CLI (${reasons.join("; ")})${refusal}. ` +
`Run \`${invocation} update\` and follow it to its last step, \`${invocation} update --rules\`.${rewrites}`
);
}
Loading
Loading