Skip to content

feat(init): name a package.json pin older than the running CLI - #443

Merged
theCodeDrift merged 3 commits into
mainfrom
worktree-taskless-upgrade-script-should
Oct 5, 2026
Merged

theCodeDrift merged 3 commits into
mainfrom
worktree-taskless-upgrade-script-should

Conversation

@theCodeDrift

@theCodeDrift theCodeDrift commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

An upgrade is usually run through a launcher (npx @taskless/cli@latest init), which leaves the project's own pins alone. Those pins, a devDependencies entry or a script spelling out @taskless/cli@0.10.2, are what CI, scripts and git hooks actually run. When the upgrade also migrated .taskless/, a pin that predates the new schema refuses the project with SCAFFOLD_VERSION_MISMATCH, so CI breaks on the push that carries the migrated files. Nothing about the upgrade itself fails, so nothing warned about it.

What changes

  • init (batch and wizard) reads package.json and names every pin of @taskless/cli / @taskless/cli-nightly that would run an older CLI than the one that just ran:
    • a dependency whose installed build (node_modules/<name>/package.json) is older. pnpm add -D writes ^0.11.0 and locks 0.11.0; the range admits 0.11.2, but CI runs 0.11.0;
    • a dependency or script spec that cannot reach the running version: an exact version, or a ^/~ range whose ceiling is below it (pre-1.0 caret holds the minor). latest, *, >=, workspace: and URLs are skipped rather than guessed at.
  • Semver precedence for exact and installed versions. A nightly is stamped with the release it anticipates (0.12.0-<stamp>x<sha>), so it sorts before that release, and two nightlies of one base sort by build time.
  • Each pin is named with its target, on the package that publishes that version: there is no @taskless/cli-nightly@0.11.2, so a nightly pin under a release CLI moves to @taskless/cli, and the reverse.
  • After migrating an existing scaffold the notice does not hedge. It names the schema move and SCAFFOLD_VERSION_MISMATCH, says CI will break, and puts the bump in the same commit as .taskless/. A fresh install (migration from schema 0) is not called an upgrade, and like a run with no migration says "likely fail".
  • It offers, it never edits package.json. A pin can be deliberate, and bumping it changes the lockfile.
  • The notice prints even when the re-install changed nothing. It is separate from the upgrade trailer, so "a no-op prints no upgrade trailer" still holds.
  • init --json carries pinnedCli: [{ location, name, spec, installed }], always present.
  • Recipes: init → topic v3 (envelope example and field list carry pinnedCli; "stop when changed is false" now also needs no stale pins; a bump step). update → topic v13 (a step offering the bump, and saying plainly after a migration that CI will fail without it).
  • compareVersions moved from reconcile-marker.ts to util/version-compare.ts unchanged, beside a new compareSemver.
package.json pins a Taskless CLI older than 0.11.2, the version that just ran here:
  - devDependencies: @taskless/cli ^0.11.0 (installed 0.11.0) -> @taskless/cli@0.11.2
  - devDependencies: @taskless/cli-nightly 0.11.2-20260901000000xaaaaaaa -> @taskless/cli@0.11.2, replacing @taskless/cli-nightly
This upgrade migrated .taskless/ from schema version 5 to 10, and a CLI that predates that schema refuses the project (SCAFFOLD_VERSION_MISMATCH). CI, scripts, and git hooks that run these pins will break on the push that carries the migrated files. Offer to update them as shown and reinstall dependencies, in the same commit as .taskless/.

Notes for review

  • GitHub Actions was down when this was opened, so it was reviewed locally (a bug review and a focus triage); the second commit carries those fixes.
  • One // ast-grep-ignore: no-unrouted-cli-invocation on PACKAGE_NAMES in pinned-cli.ts. That rule assumes every @taskless/cli literal in src/ is emitted text; these are lookup keys into someone else's package.json. Suppressed with the reason rather than splitting the literal to evade it.
  • "Will break" is exact when the pin predates the new schema. A pin older than the running CLI that already knows the schema would not break; there is no release-to-schema table to tell the two apart, and no released version can reach that case today.

Deliberately left for follow-up

  • Monorepos: only the root package.json is read. In a pnpm workspace the pin usually lives in a workspace package.
  • pinnedCli on info --json, so update can read the pins without re-running init.

Delivery shape

Single PR. OpenSpec change init-stale-cli-pins is archived here (one ADDED requirement on cli-init; 102 → 112 scenarios after rebasing onto main, nothing dropped). patch changeset.

Verification

pnpm typecheck and pnpm lint (including the house rules) clean; full CLI suite 1989 passed, including integration tests that run a real migration, a fresh install, and the wizard against a stale pin.

@theCodeDrift
theCodeDrift force-pushed the worktree-taskless-upgrade-script-should branch from f97d147 to 18272a8 Compare October 5, 2026 19:31
@theCodeDrift
theCodeDrift marked this pull request as draft October 5, 2026 21:54
@theCodeDrift
theCodeDrift marked this pull request as ready for review October 5, 2026 21:54
@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Claude finished @theCodeDrift's task in 3m 46s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no reviews, threads, or comments for this PR, so this is the first review of this PR; the whole diff was assessed fresh.

  • Fetch PR diff and metadata
  • Review pinned-cli.ts (new semver/pin logic)
  • Review version-compare.ts
  • Review init.ts / wizard/index.ts integration
  • Review recipe docs (init.md, update.md)
  • Review tests
  • Post final review

Summary

Well-scoped, well-tested change. The detector logic (dependency fields, script regex, installed-vs-range staleness, migration-certain-vs-likely wording) matches the spec and is backed by solid unit + integration coverage. package.json is never written, matching the stated contract. The OpenSpec delta is purely ADDED (no restated/modified requirement), so none of the archive-time deletion risk from the project's "spec delta REPLACES" guidance applies here.

Finding

packages/cli/src/util/version-compare.ts — compareSemver's prerelease comparison is lexicographic, not semver-precedence-correct, for multi-digit dot-separated prerelease identifiers.

compareSemver falls back to a plain string < comparison of the prerelease tail (version-compare.ts:913-922). True semver precedence compares dot-separated prerelease identifiers individually, with purely-numeric identifiers compared numerically. String comparison diverges from that whenever two identifiers of different digit-widths are compared:

compareSemver("0.11.0-rc.9", "0.11.0-rc.10")
  • numeric core is equal (0.11.0 == 0.11.0)
  • prerelease("0.11.0-rc.9") = "rc.9", prerelease("0.11.0-rc.10") = "rc.10"
  • "rc.9" < "rc.10" is false in JS ('9' > '1' byte-wise), so the function returns 1 (pin rc.9 sorts after rc.10)
  • isStale therefore reports this pin as not stale, even though real semver precedence says rc.10 > rc.9, i.e. the pin genuinely predates the running version.

This is a real, silent false negative — exactly the failure mode this PR exists to eliminate ("nothing warned about it"). It happens to be masked for the CLI's own nightly stamps because those are fixed-width 14-digit timestamps (20260901000000x... vs 20261005000000x...), where lexicographic and numeric order coincide — which is why none of the added tests (nightly ordering in pinned-cli.test.ts:1207-1232) catch it. But isStale's exact-version branch and the cli-init spec text ("An exact version ... SHALL be compared with semver precedence") are written generally, covering any hand-authored exact pin like 0.11.0-rc.9, not just nightly stamps, so the gap is reachable from a real pin a user could write.

Low severity in practice today (Taskless doesn't currently ship rc-style release versions, only X.Y.Z releases and nightly stamps), but worth a one-line fix (compare dot-separated identifiers, numeric-vs-numeric) or at least a code comment scoping the guarantee to the nightly stamp format specifically, since the current doc comment and spec prose both claim general "semver precedence."

@theCodeDrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "compareSemver's prerelease comparison is lexicographic, not semver-precedence-correct, for multi-digit dot-separated prerelease identifiers"
#443 (comment)

Fixed in 3a113b5: prerelease tails are now compared identifier by identifier per semver (numeric by value and below alphanumeric, shorter prefix first), with rc.9 vs rc.10, rc vs rc.1, and 1 vs alpha added to the ordering table.

— AI Coding Agent

An upgrade through a launcher leaves the project's own pins alone, and
those pins are what CI, scripts and git hooks run. After a migration, a
pin that predates the new schema refuses the project with
SCAFFOLD_VERSION_MISMATCH, so CI breaks on the push carrying the
migrated files.

init (batch and wizard) now names every bounded pin of @taskless/cli or
@taskless/cli-nightly that cannot reach the running version, in the
dependency fields or spelled out in a script, and offers the bump
without editing package.json. After a migration the notice states the
breakage as certain and ties the bump to the same commit as .taskless/.
init --json carries the pins as pinnedCli, and the update recipe (topic
v11) tells an agent to offer the bump.
Review fixes for the stale-pin notice:

- read the version installed under node_modules as well as the range;
  ^0.11.0 admits 0.11.2 but CI runs the locked 0.11.0
- compare exact and installed versions with semver precedence, so an
  older nightly of the same base is stale
- name each pin's target on the package that publishes that version
- a migration from schema 0 is a fresh install, not an upgrade
- init recipe topic v3: pinnedCli in the envelope, the stop rule, and a
  bump step; update recipe points at re-running init --json
- script regex: left boundary, punctuation-terminated versions, one
  report per repeated pin
- tests: ordering guard, wizard, fresh install, nightly ordering
compareSemver compared the prerelease tail as one string, which orders
rc.9 after rc.10. Compare dot-separated identifiers instead: numeric
ones by value and below alphanumeric ones, a shorter prefix list first.
Nightly stamps were unaffected, since their timestamp is fixed-width.
@theCodeDrift
theCodeDrift force-pushed the worktree-taskless-upgrade-script-should branch from 3a113b5 to 1d41534 Compare October 5, 2026 22:57
@theCodeDrift
theCodeDrift merged commit f0c7a5b into main Oct 5, 2026
4 checks passed
@theCodeDrift
theCodeDrift deleted the worktree-taskless-upgrade-script-should branch October 5, 2026 23:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant