feat(cli): say on the envelope that the layout was migrated - #195
Conversation
`check`, `verify`, and `test` migrate `.taskless/` before they can do their
real work, which rewrites files in the caller's working tree. The only trace
was one line of prose on stderr: nothing on stdout, nothing in `--json`, and an
unchanged exit code, so a script reading `{"success":true}` could not learn its
checkout had changed underneath it. That silence cost three incidents in one
session - twice a migration staged into an unrelated commit, once a half
migrated layout failed six tests that had nothing to do with the change.
Those commands now carry an optional `migrated` field: the source and target
schema versions, the migrations applied, and the files added, modified, and
removed relative to the project root. It is absent when nothing ran, so
presence is the signal and no consumer reads empty arrays to decide.
The file list is observed rather than self-reported. The migrations write
through plain `fs` calls in five modules, so asking each to keep a list would
make the report only as honest as its bookkeeping; hashing `.taskless/` and the
root `.gitignore` before and after answers what actually changed on disk.
The migration stays automatic, because these commands need a known layout to
run at all and the alternative is a hard failure on every upgrade. The human
notice improves to match: it names both versions up front and prints the files
it touched on completion.
`verify` and `test` printed a hand built JSON object; their envelope is now
validated against a zod schema before printing, as the sibling payloads are.
Fixes #178
feb78e0 to
9cc2f64
Compare
|
@claude /review |
|
Claude finished @thecodedrift's task in 6m 0s —— View job Review completeReview mode: incremental — read 0 prior review thread(s) before reviewing.
AssessmentThe core mechanism — snapshotting Five inline findings, none blocking, roughly in severity order:
No correctness bugs found in the envelope contract, schema wiring, or manifest/report ordering itself. |
Three findings from review of #195, all verified before acting. The notice printed to stderr unconditionally, including under `--json`. That was true before this PR too, but it printed one line; now it prints a file-by-file summary capped at twenty paths. `check` already suppresses every other notice under that flag, for the reason that applies here exactly: the information is on the envelope's `migrated` field, and a machine consumer reading stderr gets prose it cannot parse. A CI script that logs or fails on stderr was handed a much noisier surprise than the one-liner it tolerated. Measured after the fix: a `--json` run writes zero bytes to stderr and still carries `migrated`, and a human run keeps its summary. `EnsureOptions.onNotice` still documented itself as called "once when a migration run is about to start". It is now called twice, on start and on completion. `migrate.ts` had its copy of that doc updated and this one was missed, which is observable rather than cosmetic: the wizard passes the callback and renders two blocks instead of one. The snapshot could not see symlinks. `Dirent` predicates describe the entry itself and do not follow links, so a symlink under `.taskless` was neither walked nor hashed, in `before` or `after`. A migration that created, removed or retargeted one reported nothing for it, which is the same quiet under-reporting this module exists to prevent, reached by a different route than the watched-paths limit already documented. A symlink is now recorded by its TARGET PATH rather than by following it. Following would risk a cycle and would double-count a target that is itself under a watched path; the link text catches creation, removal and retargeting, which are the three things a migration can do to one. The value is prefixed so it cannot collide with a sha256. `snapshot.ts` gains direct unit tests, which it had none of: it was exercised only through a CLI run, and its whole job is saying what actually changed, so a gap there is invisible in the way the report exists to prevent. Ten cases, including the three symlink shapes that were previously invisible, and the no-op rewrite that must NOT read as modified.
Hashing ran one file at a time through a sequential `for`/`await`, twice per migration. That is fine for this repository's own `.taskless/`, which holds a handful of rule files, and it is the wrong shape for a tree that is not: the work is IO-bound and there is no reason for each file to wait on the last. Traversal and hashing are now separate. `walk` collects entries without resolving them, since directory reads are cheap and inherently sequential, and the collected work is then resolved concurrently. **Bounded at 32, not a bare `Promise.all` over the whole tree.** Unbounded concurrency on a large repository opens every file at once and hits the process descriptor limit. That would surface as `EMFILE` inside `hashFile`, which swallows a read failure as "not part of the snapshot", so the report would come back silently incomplete rather than erroring. Quiet under-reporting is the exact failure this module exists to prevent, so it has to be designed out rather than caught. Covered by a test that crosses the batch boundary in both directions: 80 files in one run, all 80 accounted for and still sorted across batches, so a partition bug cannot pass as a smaller result. Verified end to end against a genuine version-2 layout: `from 2 to 5`, `applied [3, 4, 5]`, 5 added, 1 modified, 1 removed, unchanged from before the batching.
All six verified, all six real, all six addressed. Threads replied to and resolved individually. The Symlinks were the subtlest. The doc was observably wrong, not merely stale: the wizard passes that callback and was rendering two blocks against a comment promising one. Both low findings were taken as well. Batch hashing because repositories are larger than this one, bounded at 32 rather than unbounded, since Verification: — AI Coding Agent |
Three findings from review of #195, all verified before acting. The notice printed to stderr unconditionally, including under `--json`. That was true before this PR too, but it printed one line; now it prints a file-by-file summary capped at twenty paths. `check` already suppresses every other notice under that flag, for the reason that applies here exactly: the information is on the envelope's `migrated` field, and a machine consumer reading stderr gets prose it cannot parse. A CI script that logs or fails on stderr was handed a much noisier surprise than the one-liner it tolerated. Measured after the fix: a `--json` run writes zero bytes to stderr and still carries `migrated`, and a human run keeps its summary. `EnsureOptions.onNotice` still documented itself as called "once when a migration run is about to start". It is now called twice, on start and on completion. `migrate.ts` had its copy of that doc updated and this one was missed, which is observable rather than cosmetic: the wizard passes the callback and renders two blocks instead of one. The snapshot could not see symlinks. `Dirent` predicates describe the entry itself and do not follow links, so a symlink under `.taskless` was neither walked nor hashed, in `before` or `after`. A migration that created, removed or retargeted one reported nothing for it, which is the same quiet under-reporting this module exists to prevent, reached by a different route than the watched-paths limit already documented. A symlink is now recorded by its TARGET PATH rather than by following it. Following would risk a cycle and would double-count a target that is itself under a watched path; the link text catches creation, removal and retargeting, which are the three things a migration can do to one. The value is prefixed so it cannot collide with a sha256. `snapshot.ts` gains direct unit tests, which it had none of: it was exercised only through a CLI run, and its whole job is saying what actually changed, so a gap there is invisible in the way the report exists to prevent. Ten cases, including the three symlink shapes that were previously invisible, and the no-op rewrite that must NOT read as modified.
Makes the
.taskless/layout migration visible to the machine interface.The problem
Running
check,verifyortestagainst an older.taskless/tree migrates it: files move,taskless.jsonand.gitignoreare rewritten. On this repository's own tree that is six tracked files changed.This is not a request to stop migrating.
checkandverifyrequire a specific layout to function at all, so the alternative for anyone on@latestis a hard failure on every run, which is worse. Migration is a precondition, not a side effect.The problem is that nothing the caller can read says it happened:
Migrating to latest .taskless/ schema...--jsonstdoutA CI script reading
{"success":true,"results":[]}had no way to learn the working tree was rewritten underneath it, and a person looking at six changed tracked files had nothing connecting them to the command they ran. The notice named no file, no source version and no target version.What this adds
A
migratedfield on the--jsonenvelope, present only when a migration actually ran:Paths are project-root-relative, forward-slashed and sorted. The field is absent, not empty, when nothing migrated, so a caller distinguishes "nothing happened" from "something happened" without guessing.
Emitted by
check,verifyandtest: the three commands that both emit--jsonand callensureTasklessDirectoryat their own boundary.Human stderr improved too, since it named neither the files nor the versions:
Capped at 20 paths then
... and N more. Silent when nothing runs, as before.How the file list is produced
By hashing
.taskless/and the repo-root.gitignorebefore and after the run and diffing the two maps, rather than by per-migration bookkeeping. The five migrations write through plainfscalls in separate modules, so a self-reported list would only ever be as honest as its bookkeeping. A snapshot reports what actually changed.verifyandtestpreviously printed a hand-built JSON object with no schema at all. That payload is now validated like its siblings.Why this matters here specifically
The invisibility bit three times in one session:
Known limits
rule createandrule improvecan still migrate silently. Their migration happens insidewriteRuleFile, not at the command boundary, so their envelopes carry nomigrated. Fixing it means an explicitensureTasklessDirectoryat the top of each run plus network-mocked tests. Filed separately rather than grown into this PR.runMigrationsrethrows and no report is produced, so the error envelope carries nomigrated. No worse than before, but a partially applied migration is exactly the case the test-failure story above describes..taskless/and the root.gitignoreonly. Migration 0001 writes the root.gitignore, which is why it is included. A future migration writing elsewhere needsWATCHED_PATHSextended, or the report quietly under-reports.Verification
pnpm typecheck,pnpm lint,pnpm test(57 files, 937 tests),pnpm cli check(exit 0).8 new tests spawn the built CLI against a seeded version-3 scaffold: for each of
check/verify/test, a migration case assertingfrom/to/appliedand the specific added, modified and removed paths, plus a no-migration case on the second run asserting the field is absent. Two more cover the human stderr.Fixes #178