fix(cli): leave an already-partitioned rules tree alone - #113
fix(cli): leave an already-partitioned rules tree alone#113thecodedrift wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a migration-idempotency bug where a project already using the current .taskless/rules/<engine>/... layout but missing taskless.json was treated as version 0 and re-migrated, relocating rules into a non-scanned path and causing check to falsely report success.
Changes:
- Add a guard to migration
0004to detect an already engine-partitioned.taskless/rules/tree and skip the ambiguousrules/ → sg/rules/move. - Add regression tests covering “current layout + missing taskless.json” to ensure
checkstill finds rules andverifyreports the expected engine/rule IDs. - Add a changeset documenting the behavior change as a patch release.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/cli/src/filesystem/migrations/0004-vale-engine.ts | Adds an engine-partition detection guard to keep 0004 idempotent on current-layout trees missing a manifest. |
| packages/cli/test/migrate-round-trip.test.ts | Adds regression coverage for the missing-manifest/current-layout case; minor formatting tweaks to existing assertions/fixtures. |
| .changeset/idempotent-rules-partition.md | Documents the migration fix as a patch changeset. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
@claude /review |
|
Claude finished @thecodedrift's task in 4m 47s —— View job Claude finished @thecodedrift's task
Overall assessmentThe core fix is sound and well-reasoned: guarding One correctness concern worth resolving before merge (left inline on Secondary, lower-severity findings (also inline):
Conventions: no clear violations of the repo's CLAUDE.md / STYLEGUIDE-CODE.md found — imports are correctly grouped/direct, no barrel exports, and the new tests consume the CLI's structured JSON output rather than parsing generated artifacts. No security, performance, or clarity issues beyond what's noted above. The changeset accurately describes the fix and its scope. |
All four findings are addressed in 6072031, each answered inline on its thread: the — AI Coding Agent |
|
@claude /review |
|
Claude finished @thecodedrift's task in 3m 28s —— View job Review complete
Overall assessmentThe three false-negatives fixed in 6072031 ( The guard's own doc comment says a tree with both an engine directory and a stray loose Everything else found was minor and non-blocking, most already implicitly acknowledged by the PR's own design notes:
No security issues found. Test coverage is otherwise good for the cases it does cover (verified via the PR body's fail-then-pass check against unpatched source). |
The "genuinely mixed" correctness gap was real and is fixed in f6ca7fa: the guard is now Verified: lint clean, typecheck clean, 606 tests across 50 files pass, — AI Coding Agent |
A `.taskless/` with no `taskless.json` reads as version 0, so every migration runs — including 0004's `rules/` -> `sg/rules/` move, which predates the layout such a tree is usually already in. Applied there it buried every rule at `sg/rules/sg/<id>/`, 0005 scaffolded empty engine directories over the gap, and `check` exited 0 on a clean report for a project it had stopped scanning. 0004 now recognizes an engine-partitioned `rules/` and skips that one move. Recognition is strict — every entry a directory named for an engine, at least one present — so a genuinely pre-0004 tree of flat `rules/<id>.yml` files still migrates. Fixes #109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…y every entry Requiring *every* entry under `.taskless/rules/` to be an engine directory turned a stray `.DS_Store` into evidence of the pre-0004 layout, moving a live rules tree wholesale — the exact failure the guard exists to prevent. Key on the two real signals instead: at least one engine directory, and no loose `*.yml` at the root, which is the same signal `0005`'s `assertRootIsFree` reads from the other side. Also narrow the `readdir` catch to ENOENT/ENOTDIR so an unreadable `rules/` fails loudly rather than being relocated uninspected, and reuse the exported `isKnownEngine` in place of a cast over `ENGINES`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…irectory A `.taskless/rules/` holding both engine directories and a stray loose `<id>.yml` is genuinely mixed, and the guard already said that case "does migrate" — but the move it fell through to was directory-granular. Renaming `rules/` wholesale to collect one stray file carried every already-partitioned rule down with it, and `0005`'s `moveEngineRules` only relocates loose `*.yml` from `sg/rules/`: a directory entry is skipped, `pruneEmpty` finds the tree non-empty and leaves it, and the rules are stranded at `.taskless/sg/rules/sg/<id>/`. That is the same silent clean pass this branch set out to fix, reached from a merge-conflict leftover instead of a missing manifest. `rulesArePartitionedByEngine` becomes `inspectRulesLayout`, reporting `legacy` / `partitioned` / `mixed` plus the loose files it found. `mixed` now moves those files individually and leaves the engine directories where they are. A symlinked engine directory also counts as an engine directory, since `Dirent.isDirectory()` is false for a symlink and the cost of guessing wrong is asymmetric: over-recognising declines a move, under-recognising loses rules. The `rules/` move also moves out of `MOVES` into its own `RULES_MOVE`, so the loop no longer re-derives "is this the rules move" from the array's shape. Regression test added; it fails against the previous commit with `no-eval` missing from the check output entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
f6ca7fa to
5665642
Compare
What a user would have seen
A project already in the current rule-directory layout, but missing
taskless.json, reads as schema version 0 —readRawManifestreturns{version: 0}onENOENT, so every migration runs. Migration0004then applies itsrules/→sg/rules/move on top of the0005layout, burying every rule at.taskless/sg/rules/sg/<id>/<id>.yml.0005afterwards scaffolds fresh empty engine directories over the hole.Nothing errors.
checkscans a tree with no rules in it and prints{"success":true,"results":[]}with exit 0. The dangerous half is that this is indistinguishable from a project that passes: a manifest that was never committed, or was deleted, silently stops being checked.Why this is a bug, not a design choice
openspec/specs/cli-taskless-bootstrap/spec.mdalready requires both halves:If both held, running every migration against an already-current tree would be a no-op.
0004violates the second. This change makes the code conform to requirements that already exist, which is why no spec file changed.The guard
rulesArePartitionedByEngine()inpackages/cli/src/filesystem/migrations/0004-vale-engine.tsreads.taskless/rules/and returns true only when there is at least one entry and every entry is a directory named for an engine. It keys on theENGINESconstant — the same import0005already takes fromsrc/rules/engines.ts— so a fourth engine is recognized without editing this migration, which is the failure mode worth designing against: a hardcoded list would silently re-open this bug the day an engine is added.Recognition is strict on purpose. The two shapes are unambiguous — pre-
0004holds rule files (rules/<id>.yml), the current layout holds only engine directories — so a mixed or partial tree is not evidence of the new layout and still migrates. That keeps a genuinely old project moving forward at the cost of doing nothing clever with a tree nobody produces.Only the
rulesmove is ambiguousrulesis the one path inMOVESthat means two different things: it is simultaneously the pre-0004flat location and the root of the layout0005establishes. That is the same collision0005's ownassertRootIsFreecomment already describes from the other side.What I checked, and concluded needed no change:
rule-tests/0005puts tests atrules/<engine>/<id>/.tests/. Absent on a current tree, so the move is already a no-op.sgconfig.yml0005gitignores a dot-prefixed.sgconfig.ymland removes the committedsg/sgconfig.yml. The undotted name does not exist on a current tree.runtime-rules/,runtime-rule-tests/0004names only; nothing after0004writes them.0005itself is genuinely idempotent against a current tree and was left alone:sg/rulesand friends are either absent or the empty.gitkeepscaffolds0004writes, somoveEngineRules/moveEngineTestsmove nothing; the scaffoldedvale/.vale.inicarries no section, sosplitValeConfigfinds no blocks and yields no orphans;pruneEmptyplusscaffoldEngineDirectoriesconverge on the same shape. Only the one guard was added — no speculative rewriting of migrations that were not affected.Regression test
packages/cli/test/migrate-round-trip.test.ts, in a newdescribe("a current-layout project with no taskless.json"). Seedsrules/sg/no-eval/no-eval.ymlplus the engine.gitkeeps, notaskless.json, and anapp.tscontainingeval(raw), then runs the real built CLI. It assertscheck --jsonreportsno-eval:app.tsand exits 1, and thatverify --jsonstill lists exactlysg/no-eval.It sits deliberately outside the
withValegate — ast-grep alone is enough to see whether the rules survived, and this case must never regress silently on a machine without Vale.I verified it fails against unpatched source, not only that it passes: stashing the
0004change and rebuilding turns both cases red, and restoring it turns them green. A regression test that was never observed failing is only a test that the current code does what it does.Verification
pnpm lintpnpm typecheckpnpm testpnpm openspec validate --all --strictNothing was failing on
mainbeforehand.Residual, out of scope
There is still no "I scanned nothing" signal anywhere in the check pipeline. This guard closes the one path we know about, but any future tree that strands rules somewhere the scanner does not look would again report a clean pass with exit 0. That is the third option the issue floats, and it is a broader change than this fix — not addressed here.
Fixes #109