Repository navigation
fix(cli): a renamed destructuring key off ctx is a key, not a free identifier - #22489
objectstack-fleet[bot] merged 2 commits into
Conversation
…entifier
The hook-body free-identifier scan walked a BindingElement's propertyName
as a value reference, so `const { previous: prev } = ctx` reported
`previous` as free: `objectstack lint` refused a lowerable hook with advice
describing what it already did, and `objectstack build` bundled it instead
of lowering it. The reference walk now treats a non-computed destructuring
key the way it already treats a `{ key: value }` key, and still walks a
computed key's expression and the element's default.
Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
… fix Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b9d2c2525f71f1f66f759625c0f8c93cbf7fec3f && git checkout b9d2c2525f71f1f66f759625c0f8c93cbf7fec3f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8b713fad783e23d4313fa7d291b88de0bde6efd7 38fa4d0857ce2f88f9b2547ce6e012432bb0971e && git checkout -B drift-repro 8b713fad783e23d4313fa7d291b88de0bde6efd7 && git merge --no-ff 38fa4d0857ce2f88f9b2547ce6e012432bb0971e
node scripts/docs-audit/affected-docs.mjs --json 8b713fad783e23d4313fa7d291b88de0bde6efd7 |
Fixes #22459
Clause-②: no
The rule's published text negates this refusal: its own remedy line reads "Inline the value(s) into the handler, or reach them through
ctx." (packages/cli/src/lint/hook-body-lowering.ts:163), andconst { previous: prev } = ctxreaches the value throughctx. Removing a misrejection that the rule's text already denies isno.What changed
The free-identifier scan behind
objectstack lint'shook-body/not-lowerablerule andobjectstack build's hook lowering (packages/cli/src/utils/detect-free-identifiers.ts) counted a renamed destructuring key as a value reference. For a hook that destructures a renamed key offctx, such asconst { event, input, previous: prev } = ctx, it reportedpreviousas free. The binding walk (collectBindings) already bound the aliasprev. The reference walk (collectReferences) then met the element'spropertyNameas a plain identifier. So lint refused a lowerable hook with advice describing what it already did, and the build bundled it instead of lowering it.collectReferencesnow handles aBindingElementthe way it already handles aPropertyAssignment. A non-computedpropertyNameis a key, not a reference. The walk still visits a computed key's expression, the binding name (an alias is subtracted bybindings; a nested pattern recurses), and the default, because{ [k]: v = d }readskandd. It is one branch in the existing walker, with no second walker.extract-hook-body.ts, the rule's message text andpackages/specare untouched.Changeset:
.changeset/22459-hook-lowering-destructuring-alias.md,@objectstack/clipatch. The remedy is: none needed, because a hook written this way now lowers.Reproduction, red then green
collectReferences(basef66c440de):detect-free-identifiers.test.tshas 8 failed and 37 passed (45). The failures readexpected [ 'previous' ] to deeply equal [](the card's A shape, an object pattern inside an array pattern, a nested callback parameter, and the compiled.toString()shape),[ 'a', 'b' ](nested),[ 'key' ](defaulted alias),[ 'message' ](catch clause), and[ 'FALLBACK', 'key' ]where the control expects[ 'FALLBACK' ].38fa4d085:detect-free-identifiers.test.tsandhook-body-lowering.test.tsgive 63 passed (63).scripts/ablation-replace.mjs: anchor hit 1 time and went from 1 to 0, and the blob wentb768875ce125todcd006617c08. Both suites then gave 9 failed and 54 passed (63). The ninth failure is the lint/build pin:expected [ { severity: 'error', …(3) } ] to deeply equal []. On restore, the blob matched HEAD (b768875ce125) andgit diff HEADwas empty.Pins, as triage directed:
{ event, input, previous: prev } = ctx) lowers;{ a: { b: c } } = ctx) and defaulted ({ key: alias = d },din scope) shapes lower, and so does a computed key with an in-scope default;FALLBACK(also the shorthand{ previous = FALLBACK }), and a free computed key still reportsKEY;previous(noctxsource) is still reported, at the helper and at lint/build level.H2 and H3 (measured)
const [first, second] = ctx.items): no false positive before the fix. It was green on base, because an array element has no key. Parameter destructuring: the handler's own top-level parameters never had it, because the reference walk does not visit top-level parameter patterns (the existing({ a: { b } }) => b + 1pin was green on base). The same element shape inside the body DID have it:ctx.items.map(({ previous: prev }) => prev.n),const [{ previous: prev }] = ctx.items, andcatch ({ message: msg })were all red on base. The same rule covers them, and each is pinned.hook-body-lowering.test.ts): for a hook that destructures a renamed key offctx,checkHookBodyLoweringreturns[].lowerCallablesrecords no extraction warning,bodyExtractedis 1, and the loweredbody.sourcecarriesprevious: prev. The control hook reads a module-scopeprevious. It is still anerrorunderhook-body/not-lowerableathooks[0].handler, and the build records['free-identifiers', ['previous']]and lowers nothing. Under the ablation the positive pin went red; the control stayed green both ways.Verification, at
38fa4d085pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 272 files passed. 2 files failed on a prerequisite, not a verdict ("packages/cli is not built (./dist/index.js is absent)"):published-subpath-hook-body.pinandpublished-subpath-console.pin. Afterpnpm --filter @objectstack/cli build, those two gave 29 passed (29). Totals: 274 of 274 files, 4023 + 29 tests passed, 29 skipped. Theintegrationlayer is declared to CI, because the diff touches no integration-layer file and no spawn entry.pnpm --filter @objectstack/cli typecheck: exit 0.tsc -p tsconfig.json --listFilesOnlyincludes both edited test files (count 2).pnpm lint. (1) Population, read fromeslint.config.mjsthrough the ESLint API: none of the 3 changed TS files is ignored. The changeset.mdis outside everyfilesglob. (2) Count, from--format json: 3 files, 0 errors, 0 warnings. (3) Invariance: the config enables no type-aware linting (each file's resolvedparserOptionsis{ ecmaVersion, sourceType }only, with noproject), and the diff touches no ESLint config, plugin or baseline. So no untouched file's verdict can move.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) derives 64 commands; all 64 exit 0. Four of them (check:dual-build-cjs-loads,check:i18n,check:i18n-coverage,check:i18n-walk-parity) first exited 3, PREREQUISITE NOT MET, and exited 0 after the build.--ranreconciles: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.Acceptance notes
(async (ctx) => { … })(ctx), and the scan treats the handler's top-level parameters as bound. Measured at38fa4d085, a pre-existing path this PR does not touch:extractHookBody(({ input }) => { input.x = 1; })lowers to the sourceinput.x=1, and(c) => { c.input.x = 1; }lowers toc.input.x=1.checkHookBodyLoweringreports 0 issues for both. Relatedly, the reference walk never visits a top-level parameter pattern's default:detectFreeIdentifiers('({ x = FREE }) => x')gives[], while the in-body spelling gives['FREE']. NOT MEASURED: whether such a body throws when the runtime evaluates it. Carrier: none.Generated by Claude Code