feat(verify): refuse a rule id held by more than one engine - #388
Conversation
|
@claude /review |
|
Claude finished @thecodedrift's task in 6m 21s —— View job Claude's reviewReview mode: incremental — read 0 prior review thread(s) before reviewing. Todo list
SummaryThis is a well-scoped, thoroughly-documented change. I read the full diff plus the surrounding files it touches or interacts with ( [New] Findings (all cleanup-level; no correctness issues found), posted inline:
No blocking issues. Nice attention to the import-cycle regression test (entering the graph at the migration module first) and to proving the rename's safety claims rather than asserting them. |
|
@claude /review |
|
Claude finished @thecodedrift's task in 2m 34s —— View job Claude's reviewReview mode: incremental — read 3 prior review thread(s) before reviewing. All three are unresolved and none carry an Todo list
What's new since the last reviewCommit
No correctness issues found in this new code. It matches the standing spec text added in I also re-read the manifest/migrate split ( [Unchanged since last review] — 3 open findings, still present verbatim
None of these are blocking — all are pre-existing cleanup-level observations, not correctness bugs, and the new runtime carve-out commit doesn't touch any of the three locations. SummaryNothing NEW and correctness-affecting since the last review. The one new commit ( |
2a4cc5c to
1d7097d
Compare
All three findings handled; branch rebased onto
This also confirms the import question the second review raised implicitly: the module-edge set of — AI Coding Agent |
A circular import leaves one module in the cycle holding `undefined`, and which module loses depends on where the graph is entered. That makes it invisible to the built CLI and to most tests — the migrate/reconcile-marker cycle surfaced in exactly two tests, by luck, as `TypeError: migrate is not a function`. Nothing in the repo looked for it. Adds eslint-plugin-import-x and turns on `import-x/no-cycle` over the TypeScript sources. Only that rule; no other rules from the plugin. Two settings are load-bearing and easy to get wrong: - `import-x/extensions` must list the TS extensions. Its default is `['.js', '.mjs', '.cjs']`, and a file outside that list is dropped by `ExportMap.get` before its imports are read. Without it the rule resolves our files, walks into them, finds nothing, and passes on a tree that provably contains a cycle. - `import-x/resolver-next` needs the same list for a different reason: the built-in resolver defaults to `['.mjs', '.cjs', '.js', '.json', '.node']` and our sources import extensionlessly. Type-only edges are ignored, which is the rule's own non-configurable behavior and the behavior we want: an `import type` edge is erased before the module runs, so it cannot produce the `undefined` binding this rule exists to catch. `verbatimModuleSyntax` is what makes that safe to lean on. The only cycle on main is `filesystem/migrate` <-> `rules/reconcile-marker`, which PR #388 already breaks by splitting out `filesystem/manifest.ts`. Exempted at both ends with a comment rather than fixed here, to avoid conflicting with #388 in the same file; the disables become unused-disable warnings once it lands. The guard test asks ESLint whether the rule is on and whether it reports a real cycle written into packages/cli/src. It does not re-implement cycle detection. It exists because the failure mode is silence: a green lint run looks identical whether the rule works or is inert.
A circular import leaves one module in the cycle holding `undefined`, and which module loses depends on where the graph is entered. That makes it invisible to the built CLI and to most tests — the migrate/reconcile-marker cycle surfaced in exactly two tests, by luck, as `TypeError: migrate is not a function`. Nothing in the repo looked for it. Adds eslint-plugin-import-x and turns on `import-x/no-cycle` over the TypeScript sources. Only that rule; no other rules from the plugin. Two settings are load-bearing and easy to get wrong: - `import-x/extensions` must list the TS extensions. Its default is `['.js', '.mjs', '.cjs']`, and a file outside that list is dropped by `ExportMap.get` before its imports are read. Without it the rule resolves our files, walks into them, finds nothing, and passes on a tree that provably contains a cycle. - `import-x/resolver-next` needs the same list for a different reason: the built-in resolver defaults to `['.mjs', '.cjs', '.js', '.json', '.node']` and our sources import extensionlessly. Type-only edges are ignored, which is the rule's own non-configurable behavior and the behavior we want: an `import type` edge is erased before the module runs, so it cannot produce the `undefined` binding this rule exists to catch. `verbatimModuleSyntax` is what makes that safe to lean on. The only cycle on main is `filesystem/migrate` <-> `rules/reconcile-marker`, which PR #388 already breaks by splitting out `filesystem/manifest.ts`. Exempted at both ends with a comment rather than fixed here, to avoid conflicting with #388 in the same file; the disables become unused-disable warnings once it lands. The guard test asks ESLint whether the rule is on and whether it reports a real cycle written into packages/cli/src. It does not re-implement cycle detection. It exists because the failure mode is silence: a green lint run looks identical whether the rule works or is inert.
A circular import leaves one module in the cycle holding `undefined`, and which module loses depends on where the graph is entered. That makes it invisible to the built CLI and to most tests — the migrate/reconcile-marker cycle surfaced in exactly two tests, by luck, as `TypeError: migrate is not a function`. Nothing in the repo looked for it. Adds eslint-plugin-import-x and turns on `import-x/no-cycle` over the TypeScript sources. Only that rule; no other rules from the plugin. Two settings are load-bearing and easy to get wrong: - `import-x/extensions` must list the TS extensions. Its default is `['.js', '.mjs', '.cjs']`, and a file outside that list is dropped by `ExportMap.get` before its imports are read. Without it the rule resolves our files, walks into them, finds nothing, and passes on a tree that provably contains a cycle. - `import-x/resolver-next` needs the same list for a different reason: the built-in resolver defaults to `['.mjs', '.cjs', '.js', '.json', '.node']` and our sources import extensionlessly. Type-only edges are ignored, which is the rule's own non-configurable behavior and the behavior we want: an `import type` edge is erased before the module runs, so it cannot produce the `undefined` binding this rule exists to catch. `verbatimModuleSyntax` is what makes that safe to lean on. The only cycle on main is `filesystem/migrate` <-> `rules/reconcile-marker`, which PR #388 already breaks by splitting out `filesystem/manifest.ts`. Exempted at both ends with a comment rather than fixed here, to avoid conflicting with #388 in the same file; the disables become unused-disable warnings once it lands. The guard test asks ESLint whether the rule is on and whether it reports a real cycle written into packages/cli/src. It does not re-implement cycle detection. It exists because the failure mode is silence: a green lint run looks identical whether the rule works or is inert.
1d7097d to
866b0c9
Compare
`.taskless/rules/sg/no-eval/` and `.taskless/rules/vale/no-eval/` could both exist, and the collision was silent. The two share one `.taskless/rule-metadata/no-eval.yml`, because the sidecar is keyed by id alone, so the second `rule create` overwrites the first's metadata and deleting either takes the shared file with it. `verify` now fails such a rule, per rule rather than only project-wide, since verifying the rule an author just wrote is when the collision is cheapest to fix. `writeRuleFile` only warns, because `check`'s repair path calls it. Migration 9 detects and refuses on upgrade, never renaming: nothing can tell which rule should keep the id.
Migration 9 refused a project holding one id under two engines. Refusing walls `init`, which is the command `SCAFFOLD_MIGRATION_REQUIRED` sends a stale scaffold to, so the CLI's own instruction became the thing that failed and a multi-file hand edit was the only way out. It now renames every colliding copy to `<id>-<engine>`, symmetrically, so no engine keeps the bare id and nobody has to work out which of their two rules kept the name. A taken target takes the next free `-N`. The rename carries the rule file, its `id:`, sg fixtures and their `id:`, and the Vale breadcrumb and both `<id>.<id>` segments — all inside the rule's own directory. Every rename is printed. Safe to automate because the metadata sidecar is never written: the service does not return the `meta` block it comes from, and `rule meta` reports RULE_META_UNAVAILABLE saying so. It is left in place, unowned. The `verify` refusal stays: it is the guard for a collision created after the migration runs, by hand or by a merge.
`migrate.ts` did two unrelated jobs: the taskless.json manifest, and the migration registry and runner. Because they shared a module, importing "read the manifest" also loaded every migration — so a migration that reached for anything reading the manifest closed a loop through the runner. That is what left `migrations["9"]` holding `undefined`, and the local `pathExists` in 0009 treated the symptom rather than the cause. The manifest moves to `filesystem/manifest.ts`, which imports nothing from `migrate.ts`. All six importers point at it directly; nothing is re-exported for compatibility, per the styleguide. `readRawManifest` and `writeRawManifest` become exported because the runner reads and stamps the raw version. 0009 now imports the shared `pathExists` from `rules/reconcile-marker`, which is the proof the loop is gone. No behavior change. Nothing published exposes either module.
Migration 9 renamed every colliding copy. It now moves only the sg and vale copies; a runtime rule holding a colliding id keeps the bare id. Runtime is the signed and blessed tier, and leaving it untouched keeps this migration clear of that machinery rather than reasoning about it. It costs nothing: within one engine the filesystem already guarantees one directory per id, so moving the other copies resolves the collision either way. Measured, a rename would have been safe anyway -- signRuleFile hashes the content of check.ts and never its path -- so this is a precaution, not a correctness fix. The report names the runtime copy that kept its id, so a reader of a three-engine collision is not left wondering why one of the three did not move.
…yout
`occupiedRuleIds` re-read the engine directories itself, with `.taskless`
and `rules` written out as string literals, and `renameRuleFile` rebuilt
the rule file name as `${id}.yml`. Both facts already live somewhere:
`listRuleIds` in `rules/engines.ts` derives the path from
`TASKLESS_DIRECTORY`/`RULES_DIRECTORY`, and `ENGINE_LAYOUTS[engine].ruleFile`
is the table that decides what a rule file is called.
`ruleFilePath` is deliberately NOT used for the second one: it resolves
from a `cwd` and a rule id into the rule's own directory, and by the time
`renameRuleFile` runs that directory has already moved. Only the file
inside it still carries the old name.
No new module edges: the migration already imported `ruleDirectory` from
`rules/engines` and `ENGINES` from `rules/layout`, so the import graph the
manifest split repaired is untouched.
`check --rule <id>` selects every engine holding the id, and #385's test proved it by seeding `vale/no-eval` beside the fixture's `sg/no-eval`. Migration 9 now renames exactly that state, and `runCli` migrates on every invocation through `migrateFixture`, so the collision was renamed to `no-eval-sg`/`no-eval-vale` before `check` ever saw it: `--rule no-eval` exited `RULE_NOT_FOUND` and the test died reading `.map` of an undefined `results`. The migration invalidated the setup, not the behaviour. An id held by two engines still selects both, and a project can still reach that state — by hand, or by a merge landing a same-id rule under another engine — which is the case the new per-rule check in `verify` exists to catch. So the fixture is migrated first and the second engine's copy seeded after, with a comment naming migration 9 so the setup is not "simplified" back. Also names the consequence in the changeset: an id passed to `--rule` yesterday may not exist today, and that failure is `RULE_NOT_FOUND` rather than a quiet zero findings.
866b0c9 to
47a4077
Compare
A circular import leaves one module in the cycle holding `undefined`, and which module loses depends on where the graph is entered. That makes it invisible to the built CLI and to most tests — the migrate/reconcile-marker cycle surfaced in exactly two tests, by luck, as `TypeError: migrate is not a function`. Nothing in the repo looked for it. Adds eslint-plugin-import-x and turns on `import-x/no-cycle` over the TypeScript sources. Only that rule; no other rules from the plugin. Two settings are load-bearing and easy to get wrong: - `import-x/extensions` must list the TS extensions. Its default is `['.js', '.mjs', '.cjs']`, and a file outside that list is dropped by `ExportMap.get` before its imports are read. Without it the rule resolves our files, walks into them, finds nothing, and passes on a tree that provably contains a cycle. - `import-x/resolver-next` needs the same list for a different reason: the built-in resolver defaults to `['.mjs', '.cjs', '.js', '.json', '.node']` and our sources import extensionlessly. Type-only edges are ignored, which is the rule's own non-configurable behavior and the behavior we want: an `import type` edge is erased before the module runs, so it cannot produce the `undefined` binding this rule exists to catch. `verbatimModuleSyntax` is what makes that safe to lean on. The only cycle on main is `filesystem/migrate` <-> `rules/reconcile-marker`, which PR #388 already breaks by splitting out `filesystem/manifest.ts`. Exempted at both ends with a comment rather than fixed here, to avoid conflicting with #388 in the same file; the disables become unused-disable warnings once it lands. The guard test asks ESLint whether the rule is on and whether it reports a real cycle written into packages/cli/src. It does not re-implement cycle detection. It exists because the failure mode is silence: a green lint run looks identical whether the rule works or is inert.
A circular import leaves one module in the cycle holding `undefined`, and which module loses depends on where the graph is entered. That makes it invisible to the built CLI and to most tests — the migrate/reconcile-marker cycle surfaced in exactly two tests, by luck, as `TypeError: migrate is not a function`. Nothing in the repo looked for it. Adds eslint-plugin-import-x and turns on `import-x/no-cycle` over the TypeScript sources. Only that rule; no other rules from the plugin. Two settings are load-bearing and easy to get wrong: - `import-x/extensions` must list the TS extensions. Its default is `['.js', '.mjs', '.cjs']`, and a file outside that list is dropped by `ExportMap.get` before its imports are read. Without it the rule resolves our files, walks into them, finds nothing, and passes on a tree that provably contains a cycle. - `import-x/resolver-next` needs the same list for a different reason: the built-in resolver defaults to `['.mjs', '.cjs', '.js', '.json', '.node']` and our sources import extensionlessly. Type-only edges are ignored, which is the rule's own non-configurable behavior and the behavior we want: an `import type` edge is erased before the module runs, so it cannot produce the `undefined` binding this rule exists to catch. `verbatimModuleSyntax` is what makes that safe to lean on. The only cycle on main is `filesystem/migrate` <-> `rules/reconcile-marker`, which PR #388 already breaks by splitting out `filesystem/manifest.ts`. Exempted at both ends with a comment rather than fixed here, to avoid conflicting with #388 in the same file; the disables become unused-disable warnings once it lands. The guard test asks ESLint whether the rule is on and whether it reports a real cycle written into packages/cli/src. It does not re-implement cycle detection. It exists because the failure mode is silence: a green lint run looks identical whether the rule works or is inert.
What
Nothing kept
.taskless/rules/sg/no-eval/and.taskless/rules/vale/no-eval/from both existing.isValidRuleIdis/^[a-z0-9][a-z0-9-]*$/, with no engine component and no cross-engine check; the write path resolves one engine andmkdirs inside it; the read path askslistRuleIdsper engine and never diffs the answers.check's human output printsseverity[ruleId]with no engine, so a collision shows two identicalerror[no-eval]lines.verifyfails a rule whose id is held by more than one engine, naming every holding directory. The check is per rule, not only project-wide: verifying the rule an author just wrote is the moment the collision is cheapest to fix.testinherits it, reached through the same helper rather than a secondverifySgRulecall.9renames the collidingsgandvalecopies to<id>-<engine>on upgrade —sg/no-eval→sg/no-eval-sg,vale/no-eval→vale/no-eval-vale. Aruntimecopy is never renamed and keeps the bare id.writeRuleFilewarns and still writes.check's repair path calls it, so a refusal would brick repair for both colliding rules.LATEST_SCHEMA_VERSIONbecomes 9; this repo's own.taskless/taskless.jsonis bumped with it.Why the migration renames rather than refuses
The first cut of this refused. Refusing walls
init, andinitis the command every other refusal points at:checkandverifysend a stale scaffold there withSCAFFOLD_MIGRATION_REQUIRED. That leaves the CLI's own instruction as the thing that fails, with a multi-file hand edit as the only way out.Renaming is safe to automate here, and both halves of that were verified rather than assumed:
The metadata sidecar is never written. It comes from the
metablock of a rule status response; the service does not populate it. Both call sites incommands/rules.tscarry a comment saying so ("Dead in practice, and kept deliberately"),rule meta's own description reads "no sidecar is written by this version" and it reportsRULE_META_UNAVAILABLErather thanRULE_NOT_FOUNDprecisely because no id produces one, and.taskless/rule-metadata/does not exist in this repository. The clobber #387 leads with is latent, so there is no metadata for a rename to destroy.Every reference to a rule id lives inside the rule's own directory.
sg<id>.yml, itsid:field,.tests/<id>-*-test.yml, each fixture's ownid:vale<id>.yml, and in.vale.inithetskl) rulebreadcrumb and both segments of<id>.<id>runtimeBoth Vale segments move because
StylesPath = .taskless/rules/vale, so the rule directory is the style and<id>.ymlis the check inside it. Nothing outside the rule directory names a rule id —taskless.jsonrecords versions only.Worth flagging:
.tests/is a reference the obvious enumeration misses, and missing it fails quietly in both directions.discoverRuleTestFilesclaims a fixture by the<id>-filename prefix, so a file left behind stops being found and the rule failssg-test-file-required; what ast-grep actually runs is keyed on theid:inside the file, so a renamed file still carrying the old id is discovered, silently not counted, and the rule reads as having shipped no cases. Both are handled.Policy
Runtime rules are never renamed. A
runtimecopy keeps the bare id, and onlysgandvalemove. Runtime is the signed and blessed tier, and leaving it untouched keeps this migration clear of that machinery rather than reasoning about it. The result is collision-free either way, because within one engine the filesystem already guarantees one directory per id:sg+runtimeleavessg/no-eval-sgbesideruntime/no-eval; all three leavessg/no-eval-sg,vale/no-eval-valeandruntime/no-eval.This is a precaution, not a correctness fix, and the PR is explicit about that so nobody later "restores symmetry" believing it was one. Measured:
signRuleFilereadscheck.tsand hashes its CONTENT (rule-hash.ts:116), never its path, and the reconcile join is by signature (run-set.ts:73). A runtime rule's directory name feeds only the reportedruleIdon findings and the sandbox copy target — no signature, no capture identifier. Confirmed end to end: thecheck.tssignature is byte-identical before and after a three-engine migration.Symmetric between the engines that do move. Where
sgandvaleboth hold an id, neither keeps it. Any precedence rule between them would be arbitrary, and a symmetric rename means no user has to work out which of their two rules kept the name.Never clobbers. A taken
<id>-<engine>takes the first free<id>-<engine>-Ncounting from 2, and free means held by no engine, so clearing one collision cannot create another. Every chosen name is asserted againstisValidRuleId.Sidecar left in place. A symmetric rename gives
rule-metadata/<id>.ymlno owner to follow, so moving it to either side would be a guess and deleting it would destroy something the migration cannot recreate. It is left, reported, and orphaned — vacuous in practice, per above.Reports everything. A migration that silently renames a user's rules is worse than one that refuses.
End-to-end, on a real v8 scaffold (all three engines colliding)
check.tssignature before and after:30a3054f680e14aa…both times. A secondinitis a no-op. The<id>-<engine>target being taken was verified separately: withsg/no-eval,sg/no-eval-sgandvale/no-evalpresent, the sg copy went tono-eval-sg-2and the existingno-eval-sgwas untouched.The
verifycheck staysIt is the guard for a collision created after the migration runs — by hand, or by a
git mergethat lands two rules of the same name under different engines. It is per rule, so it fires when an author verifies the one they just wrote.It is not a
RULE_CONSTRAINTSentry: every constraint declares oneengineand is published per engine, because a constraint says what this CLI requires of a rule for that engine beyond what the engine requires. A collision requires nothing of the rule — the file is valid, and what is wrong is that a sibling tree holds the same directory name. It is reported inerrorswith emptyviolations, which the existing "an unattributable failure carries no id" scenario already covers.Tests
packages/cli/test/rule-id-uniqueness.test.ts, 21 cases.verify: colliding pair fails from either side naming both paths; a single rule verified alone catches it; a clean tree passes; no constraint attribution. Migration: symmetric rename; sg file/id:/fixtures follow; Vale style file and both config segments follow; runtime is never renamed — forsg+runtime(asserting the runtime directory is byte-identical afterwards), forvale+runtime, and for all three at once; a taken target takes the next free suffix without touching the existing rule; sidecar left in place; renamed Vale rule still verifies clean; no collision left behind; two runs write nothing on a clean project and nothing after a rename; missing rules tree is a no-op. Plus unit cases forretargetValeConfig.writeRuleFile: still writes and warns; no warning without a collision.pnpm typecheck,pnpm lint,pnpm --filter @taskless/cli test(100 files, 1668 tests) all pass.The import cycle, and why the fix is a module split
The migration first imported
pathExistsfromrules/reconcile-marker, which read the manifest fromfilesystem/migrate.ts— the module holding the migration registry. The cycle leftmigrations["9"]holdingundefined, surfacing asTypeError: migrate is not a functionthrown from the middle of a rule write.A local
pathExistsin the migration would have made that symptom go away. The cause is thatmigrate.tsdid two unrelated jobs in one module, so importing "read the manifest" also loaded every migration, and the next migration needing any manifest reader would hit the same wall.packages/cli/src/filesystem/manifest.ts(new) now holdsTasklessInstallTarget,TasklessInstallManifest,TasklessRulesManifest,TasklessManifest,readManifest,writeManifest, plusunreadableManifest,readRawManifest,writeRawManifest,isPlainObjectandMANIFEST_FILE. It imports nothing frommigrate.ts, and its docblock says that is the property it exists to hold.migrate.tskeeps the machinery: the registry,LATEST_SCHEMA_VERSION,MigrationReport,formatMigrationNotice,pendingMigration,requireCurrentSchema,runMigrations,sortedMigrations.ALLOW_VERSION_MISMATCHES_FLAGandhasVersionMismatchOverridestayed with it, decided by usage rather than by name: nothing outsidemigrate.tsreferences either, and their only callers arerequireCurrentSchemaandrunMigrations. 564 lines became 225 + 365.readRawManifest/writeRawManifestwere private and are now exported, because the runner reads the raw manifest and stamps the version onto it. That is the one visibility change the split required.All six importers point at the new module directly —
install/state.ts,rules/reconcile-marker.ts,commands/info.ts,commands/init.ts,commands/onboard.ts,test/migrate-install.test.ts. Nothing is re-exported frommigrate.tsfor compatibility, per the styleguide's direct-imports rule.commands/init.tslegitimately imports from both:readManifestfrom the manifest,MigrationReportfrom the machinery.Migration
0009now imports the sharedpathExistsfromrules/reconcile-marker, which is itself the proof the loop is gone. The "migrations keep their imports narrow" note it replaced has been deleted rather than softened — the constraint no longer holds, and the narrower one that does (the manifest must not import the runner) is stated inmanifest.tswhere it can be acted on.Proving the cycle is gone
The repo has no cycle detection. No
eslint-plugin-import, noimport/no-cycle;eslint.config.jsruns@eslint/js,typescript-eslintandeslint-plugin-unicornonly. Nothing was catching this and nothing would have. No devDependency was added to change that, per the styleguide.So it is proven by reintroducing the cycle and measuring. Two earlier forms of the regression test passed against a deliberately broken tree, which is the only reason the final one is trusted:
vi.resetModules()+ dynamicimport()across four entry pointsrules/files.tsmigrate.tsis reached before any migration module, and it builds the record from fully evaluated imports.filesystem/migrations/0009-unique-rule-idsfirstTypeError: migrate is not a function— the original symptom.The third is what shipped. Entering at a migration module is what breaks it: reached before
migrate.ts, its own default export is still unassigned when the runner behind it builds the record.rule-id-uniqueness.test.tsimports0009on its first line for its unit cases, which is the only reason the cycle was ever observed at all.test/migration-registry.test.tsasserts every registered version applies, proven by running the registry rather than inspecting it:runMigrationsreports each version it applied, and an entry bound toundefinedthrows on call rather than reachingapplied. That keeps the registry unexported. Verified failing with the cycle present and passing with it removed.Not in scope
Per #387, deliberately deferred: whether the metadata sidecar should gain an engine segment, and whether
check --rule <id>should stop selecting both engines.OpenSpec
openspec/changes/archive/2026-09-22-rule-id-uniqueness/, declared Single PR and archived here. The archived delta and the standing specs were both rewritten for the rename; the archive dry-run was re-run from the restored pre-archive state and the#### Scenariodiff is purely additive on both specs (+5cli-rule-validation, +11cli-taskless-bootstrap, 0 removed).pnpm openspec validate --specs --strictpasses, 29 items.Changeset
One
.changeset/rule-id-uniqueness.md,patch(pre-1.0, perCLAUDE.md). It says the migration does the rename, names the<id>-<engine>shape, and tells the reader to expect their rule ids to change incheckoutput and to update any CI config or suppression that names an old id.Not grown for the manifest extraction. A release note describes what a consumer crosses, and no consumer can observe this one: the published export paths are
.,./prompts,./layout,./schemas,./node/runtimesand./reference.json, and neitherfilesystem/migrate.tsnorfilesystem/manifest.tsis reachable through any of them. It is internal structure with no behavior change.Fixes #387
Refs #379