fix(cli): reach screen nodes inside ADR-0031 regions in os i18n extract - #17644
Conversation
`walkScreenFlows` iterated `flow.nodes` flat, so a `type: 'screen'` node nested in a region (`loop.config.body`, `parallel.config.branches[].nodes`, `try_catch.config.try`/`.catch`) was never reached: no `flows.NAME.screens.NODE_ID.*` skeleton entry and no coverage row. A silent zero — the gap was invisible to the mechanism built to report gaps. Route the node universe through a region-aware descent that reads the one shared table, `FLOW_REGION_SLOTS_BY_TYPE` from `@objectstack/spec/automation`, exactly as `packages/lint`'s `walkFlowNodes` does. Depth stays out of the key: `lookupFlowScreenCopy` is keyed by node id alone, so a repeated id collapses onto its single bundle slot via the existing `dedupeByPath`. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📓 Docs Drift Check3 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 — 23 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 52fda8428805f9184be451ea17e6e01c01ba663d && git checkout 52fda8428805f9184be451ea17e6e01c01ba663d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 155b875d0f563c18d7c085545c2a9d68250215a7 faf7b1b834cc203dc8913a9dc69388f0883e3074 && git checkout -B drift-repro 155b875d0f563c18d7c085545c2a9d68250215a7 && git merge --no-ff faf7b1b834cc203dc8913a9dc69388f0883e3074
node scripts/docs-audit/affected-docs.mjs --json 155b875d0f563c18d7c085545c2a9d68250215a7 |
Fixes #17511
Clause-②: no
Declared by the dispatching
domain:cliPM seat (sessionsession_01TSf4DV7ziu4V5j73e46b7c), matching the claim comment on #17511. Verified against the DIFF, not the card: the extractor now reaches screen nodes it was always meant to reach, emitting keys under the already-declaredflows.NAME.screens.NODE_ID.*shape — no new key shape, no new error code (0 added upper-case code literals), and no change to what INPUT is accepted (a nested screen was always legal to author; only the walker failed to see it).check-i18n-coverageconfirms the ratchet does not move.walkScreenFlows(packages/cli/src/utils/i18n-extract.ts) iteratedflow.nodesflat, so atype: 'screen'node nested inside an ADR-0031 region —loop.config.body,parallel.config.branches[].nodes,try_catch.config.try/.catch, nesting arbitrarily — was never reached. It emitted noflows.NAME.screens.NODE_ID.title/.fields.*skeleton entry and no coverage row.That pairing is the defect, not the missing translation: a translator was never shown the key AND the coverage gate had no row to demand, so the hole was invisible to the mechanism built to report holes. A silent zero.
Premise check — both the card and its triage comment are stale, so neither is quoted
translateFlowwalksflow.nodesflat — a screen node inside an ADR-0031 region is never overlaid, in the fourth pass of a class three earlier cards each fixed once #11745 ispm:dispatched. It isclosed/completed, landed as PR fix(spec): overlay screen nodes inside ADR-0031 regions intranslateFlow#17521. Verified myself rather than cited from the comment. The region walk this card reuses therefore already exists onmain; there was no moving target and no sibling pin to register.translateFlowwalksflow.nodesflat — a screen node inside an ADR-0031 region is never overlaid, in the fourth pass of a class three earlier cards each fixed once #11745" still holds — different package, different unit, and it is closed.Everything else in the card re-measured true at
7d350a46, with fresh lit controls (see below).The dispatch assumption that turned out FALSE, and what it changed
The dispatch flagged one assumption as unmeasured: "
mapFlowNodeListis importable frompackages/cliwithout a new dependency edge." It is not, and this is the one finding worth reading before the diff:packages/spec/src/conversions/index.tsre-exportstypes/registry/apply/storedonly — notwalk.js.walk.ts's own docblock states the boundary deliberately: "Deliberately NOT re-exported fromconversions/index.js…walk.tsreaches noexportssubpath of the package (packages/spec/api-surface/*.jsonlists none of its symbols), and that is the boundary that keeps this a repair rather than a widening of the package's public surface."mapFlowNodeListappears in no file underpackages/spec/api-surface/.packages/lint'swalkFlowNodes(src/flow-walk.ts) is also not exported from that package's entry (src/index.ts/src/runtime.ts).So neither landed descent is reachable across a package boundary, and making one reachable means editing
packages/specorpackages/lint— outside this card's file surface, andpackages/specis the spec seat's unconditionally. Deep-importing past the boundary was ruled out by the dispatch and is not done here.The route taken, and why it is not a second region table
The card's own "Shape of the fix" authorises exactly this: "either through the spec-side slot table or by whichever walker
packages/clialready depends on." The slot table is reachable —FLOW_REGION_SLOTS_BY_TYPEis exported from@objectstack/spec/automationand recorded atpackages/spec/api-surface/automation.json:116— so the node universe now comes from a local descent that reads that one table.This is the architecture
automation/region-slots.tsdeclares, not a deviation from it:packages/lint/src/flow-walk.ts:51reaches the table the same way, from outsidepackages/spec.walk.ts:175's "one implementation" is scoped to the per-flow descent insidepackages/spec, which is whywalk.tsrefuses to publish itself. No copy of the slot list is introduced — the thing this card is an instance of one package over.The lookup goes through the
Map, never an object literal:node.typeis author-controlled and an open namespace (ADR-0018), so an object lookup would resolve'constructor'through the prototype chain. AMAX_REGION_DEPTHof 32 mirrors both spec-side walks, because adefineStacktree is hand-built objects and a self-containing region is reachable.Depth does not enter the key — the key-path question the dispatch asked
Entries stay
flows.NAME.screens.NODE_ID.*at every depth, because that is what the resolver reads:lookupFlowScreenCopy(bundle, flowName, nodeId)is keyed by node id alone, andtranslateFlow's docblock says the bundle schema "is keyed by node id and knows nothing about depth". A region segment in the key would offer a key nothing resolves — the exact producer/consumer drift the imported key face exists to prevent.A node id repeated at two depths therefore addresses one bundle slot, and the existing
dedupeByPathcollapses it to a single entry, first emission wins; the walk is outer-before-inner so which one wins is deterministic. That is not a loss: one slot can serve only one string, and the resolver overlays that string onto both nodes. Pinned.Seeding is identical at every depth, not narrower —
titlefalls back to the nodelabel(whatScreenSpec.titledraws) and a fieldlabelfalls back to itsnameas a derived seed, so the skeleton stays usable while the gate demands no translation of a string nobody authored. Both pinned, because getting the second wrong is how a region-aware walk starts failing the gate on machine identifiers.Acceptance — the named criterion, with the lit control in the same fixture
nestedOnboardingcarries a screen in every slot the table declares, at four depths, and a top-level screen as the control:welcomepick_regionloop.config.bodyaccept_termsparallel.config.branches[0]card_detailstry_catch.config.trypayment_failedtry_catch.config.catchPins assert the exact key face (eight of the ten keys were absent before), that the coverage gate gains a row per nested screen attributed to the
flowbucket, that the skeleton still parses understrictObject, and the negative half — anhttpnode'sconfig.bodyis not walked, sincehttpowns no slot, which is what consulting the table buys over walking everybody.Reverse verification
Reverting the one line to the flat walk, with the mutation proven on disk (anchor counts 1→0 and 0→1, blob hash differing from HEAD) and restored by
git checkout HEAD -- ...verified against the HEAD blob hash and an emptygit diff HEAD:Predicted direction was "goes red" and that is what it did. The 17 that stayed green include the pre-existing #11485 suite and the pins measuring collision handling, cycle termination and table consultation — properties the descent does not decide. The subject resolves through a relative import into
src/, so nodistrebuild is involved in the ablation.Verification
Closure build
turbo build --filter '@objectstack/cli^...'—Tasks: 56 successful, 56 total, exit 0. Fullpnpm buildafterwards:73 successful, 73 cached.Typecheck
pnpm --filter @objectstack/cli typecheck—tsc --noEmitpluscheck:test-typecheck: OK, exit 0.Unit tier
vitest run --project unit—Test Files 195 passed (195),Tests 2708 passed (2708). Theintegrationtier is declared to CI: the diff touches no integration-layer file and no spawn entry point.Gate floor re-derived on the final 3-path diff with
scripts/pm/dispatch-gates.mjs, every command redirected with$?captured before any pipe:62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN— a derived zero, all 62 records carrying an exit code and none of them 3.PREREQUISITE NOT MET, "NOTHING was measured" (check:i18n,check:i18n-coverage,check:i18n-walk-parity,check:dual-build-cjs-loads): they read built output that did not exist yet. Recorded as unmeasured, the prerequisite closures each gate names were built, and all four re-run green:check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).check-i18n-coverage: OK (13 config(s), 621 baselined untranslated string(s), none new).check-i18n-walk-parity: 11 declared group(s), 8 walked, 3 exempted — every declared group has an extractor face.check:dual-build-cjs-loads — 104 published require entry point(s) across 67 package(s) load(floors held).check-undeclared-dep-imports— the gate the dispatch flagged as most likely to bite the import — exit 0.@objectstack/clialready declares@objectstack/spec, and@objectstack/spec/automationis a published subpath, so no new dependency edge.pnpm lint(the standingdispatch-gatesblind spot) delivered as a declared narrowing, with all three readings:isPathIgnoredover every tracked lintable file): 6612 in scope, 0 ignored — not a guess;--format jsonoutput: 2, 0 errors / 0 warnings, exit 0;eslint.config.mjsstates in its own header that this repo "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file, test or not" — measured there with a positive control, and confirmed independently (everyparserOptionsoccurrence carries onlyecmaVersion/sourceType). Each file is linted in isolation, so this diff cannot move the verdict on any of the 6610 untouched files.The whole-repo run stays CI's, as the repo-scan rule assigns it.
A
patchchangeset is included:@objectstack/clipublishesdist, and the extractor's emitted output changes.Acceptance notes (out of scope, filed nowhere)
packages/lint'swalkFlowNodesis not exported from@objectstack/lint.i18n-extract.tsalready importswalkPageComponentsfrom that package, so the CLI consuming a lint walker is established practice, and publishing this one would let two consumers share a descent rather than a table. Noted, not filed: it is a surface-widening proposal for the lint seat, not a defect — the runtime rejects nothing and no declared contract is broken. Successor: whoever next needs a region-aware node walk outsidepackages/spec.packages/cli/src/for the tests, but every sibling i18n-extract pin lives inpackages/cli/test/(eight files). The new pins were added topackages/cli/test/i18n-flow-screen-coverage.test.ts— this card's own surface — rather than stranded insrc/away from their siblings; that file also already carries thevi.mockof the liveness ledger that makes aflowscoverage row observable at all, which the acceptance criterion requires. Declared as a deviation in the report.Generated by Claude Code
Generated by Claude Code