Skip to content

feat(lint): give validate-translation-references a flows leg - #11746

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11608-translation-refs-flows-leg
Aug 24, 2026
Merged

feat(lint): give validate-translation-references a flows leg#11746
os-steve merged 1 commit into
mainfrom
claude/issue-11608-translation-refs-flows-leg

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#11608

All measurements below are from the final commit, 172a28c9c.

Premise re-verified at my own base (e170b0ae5)

packages/lint/src/validate-translation-references.ts
'dashboards' mentions 11 <- CTRL: the probe reaches the namespace list
'widgets' mentions 12 <- CTRL
'flows' mentions 0 <- the gap

And the skip really is silent: the walk reads exactly four namespaces
(rawData.objects, .globalActions, .apps, .dashboards) with no default
branch, no counter, no debug line. Nothing about flows was reported anywhere.

The card's own probe, before and after

Same fixture the issue used, run through validateTranslationReferences:

findingsflowsdashboards (CTRL)objects (CTRL)
before2011
after3111

The controls are unchanged, so the delta is the new leg and not a moved probe.

What landed

A flows leg mirroring dashboardswidgets one level further, plus the
universe collector and the per-level suggest() / listNames() tables:

levelkeydeclared by
flowFlow.nameflow.zod.ts (required machine name)
screenFlowNode.idflow.zod.ts, type: 'screen' nodes
fieldScreenFieldConfig.namebuiltin-node-config.zod.ts (config.fields[])

Severity warning on every finding, like the rest of the rule (ADR-0072 D1).
No existing green tree turns red: the full @objectstack/lint suite is
2279 passed / 81 files.

Two shape facts measured against the schemas, not taken from the card

The card's table was correct about the three keys; it did not mention either of
these, and each one read the obvious way turns this leg into a false-positive
generator.

  1. Screen nodes nest.FlowNode.config carries ADR-0031 regions
    (loop.config.body, parallel.config.branches[].nodes,
    try_catch.config.try/.catch), each holding a full node array. A screen in
    one is a real screen — the executor pauses on it, the client gets its
    ScreenSpec.nodeId — so the universe is collected with walkFlowNodes
    rather than the flat flow.nodes. Reading the one-liner would report every
    nested screen's keys as orphans, which is the fourth-pass version of
    [lint/automation] 嵌套 region(try_catch / loop / parallel)里的节点对 flow 规则不可见 —— 两条 gating error 静默失效,第三条降级为 warning #4380 / flow lint rules never descend into a loop body — the whole family is blind to nested nodes (8 real inert conditions shipped past flow-inert-node-condition) #5383 / applyConversionsToFlow does not recurse into loop bodies — conditions inside a loop are never converted to CEL and the gate silently never opens #4347.
  2. ScreenConfigSchema has two mutually exclusive shapes. A flat screen
    declares config.fields[]; an object-form screen (config.objectName)
    renders that object's whole create/edit form and declares no fields at all.
    Its input labels resolve through objects.<objectName>.fields.*, so a field
    key there is an orphan — reported with that redirect rather than a bare "not
    declared", the same "say where it belongs" move a misfiled globalActions
    key already gets.

A key naming a node that exists but is not a screen is diagnosed as the wrong
node type, not as a missing node.

The leg is shown firing, per level, and its universe is asserted non-zero

The failure this rule's new legs keep hitting is that a universe collector which
silently reaches nothing reports nothing, and reads exactly like a leg that
looked and found no orphans. Three things guard it here.

One stack, four bundles. The clean run and the three orphan runs share the
same flowStack, differing only in the bundle — so "no findings" is a verdict
about that metadata, not a rule that never reached it.

Fires at each of the three levels separately, with the other two levels
resolving, so no level is masked by another:

testplanted orphanasserted path
level 1flows.lead_conversions…flows.lead_conversions
level 2…screens.detail…flows.lead_conversion.screens.detail
level 3…fields.opportunity…screens.details.fields.opportunity

The collected universe is read back out of the rule's own hint — the tail
listNames() prints — and asserted non-empty at every level. A collector that
reached nothing prints an empty tail and fails:

Defined flows -> ['lead_conversion']
Declared screen node ids -> ['details']
Declared screen field names -> ['close_date', 'opportunity_name']

Non-vacuity, both directions

Two ablations, each mutating the source under trap … EXIT INT TERM, each with
the mutation proven on disk by anchor count and sha256 before restoring
byte-identically. No rebuild step: the test imports the rule by relative
specifier (./validate-translation-references.js) inside its own package, so
vitest transforms the source — the mutations changed the result, which is itself
the proof that source is what ran.

ablationmutationresult
A — collector never populates flowsflows.set(...) removed, anchor 1→0, marker 0→1, sha c323b40e…ddc86abe…8 failed / 40 passed — every new test
B — flat flow.nodes instead of walkFlowNodesanchor 1→0, marker 0→1, sha c323b40e…1df13b72…1 failed / 47 passed — exactly the nested-region test

Both restores verified back to sha c323b40ed6aad88e7fc7b2a474ea4f49398a3e04fe6d64b40faee04c10f274c7, zero markers left.

Ablation A is the direct answer to the failure mode: emptying the collector
reddens all eight tests, so none of them can pass on a universe of nothing.
Ablation B shows the nesting fact is load-bearing rather than decorative — it
reddens one test, precisely the one that asserts it.

Gates

Union derived, not recalled:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which
confirmed its answer is about this repo at 172a28c9c and its --repo
assertion holds. Exit codes captured before any pipe. All 14 matched families
plus the convention-triggered ones re-run at the final commit:

  • check:changeset-gate-self-tests, check:cross-package-test-inputs,
    check:objectui-changeset, check:published-files, check:slot-lookup,
    check:test-source-alias, check:type-source-resolution,
    check-adr-0087-registration, check-changeset-no-major,
    check-cross-package-test-inputs, check-empty-changeset,
    check-plugin-teardown-shape, check-affected-docs,
    release-rehearsal-clone --self-test — all EXIT=0.
  • convention (new test file): check:query-options-erasure
    (ratchet holds: 67 unswept … none new), check:where-matcher
    (295 matcher(s) … none new), check:engine-double-contract
    (OK — 401 pinned), check:type-check-coverage, check:nul-bytes
    (no raw ASCII control bytes).
  • check:type-check-debt — the ratchet half — needed the workspace closure
    built (it refuses outright otherwise, [finding][devx] check:type-check-debt 的 ledger 余量会让新写的 pin 变哑:mongodb 曾有 33 条余量吞掉一次真实回退,另有 5 条目前带 4–19 余量 #6376; that refusal is NOT MEASURED, not
    a red gate). Built via turbo run build --filter='./packages/*' --filter='./packages/*/*' (70/70 successful), then:
    --re-measure: OK — 32 ledger entr(ies) re-measured in 235.3s, 1898 raw tsc error(s) total, none above its recorded number.
  • pnpm lint — the full repo-wide eslint . --no-inline-config, run whole
    rather than narrowed. VERDICT command-exit 0 · held the lock 83s. No
    narrowing to declare.
  • pnpm --filter @objectstack/lint test — 2279 passed, 81 files.
    pnpm --filter @objectstack/lint typechecktsc --noEmit, exit 0 (script
    name echoed, so not a zero-match silent green).

Every heavy run went through scripts/pm/os-verify-lock.sh; verdicts read from
the printed VERDICT lines, never a bare $?.

Scope

packages/lint only. #11485 is independent in code and is not touched here.

One adjacent finding filed rather than fixed: #11745translateFlow in
packages/spec walks flow.nodes flat, so a nested screen node is never
overlaid by the document-overlay path. That is packages/spec, out of scope
here, and #11745 is not addressed by this PR. It means the lint rule and
translateFlow currently disagree about which nested keys are live; this leg
takes the safe direction for a warning (resolve them, do not report them).

I did not hit the #11624 collision — nothing here touches the liveness rows.


Generated by Claude Code

The rule walked objects, globalActions, apps and dashboards; an unrecognised
top-level namespace is skipped and never reported, and `flows` was one of them.
A bundle keyed to flows.<name>.screens.<node_id>.fields.<field_name> parsed,
shipped and silently resolved to nothing.
The leg mirrors the dashboards -> widgets leg one level further: flow name,
screen node id, screen field name, all warnings (ADR-0072 D1), each naming the
universe it resolved against.
Two shape facts measured against the schemas rather than assumed, either of
which read the obvious way makes the leg a false-positive generator:
- screen nodes NEST in ADR-0031 regions, so the universe is collected with
walkFlowNodes, not the flat flow.nodes;
- ScreenConfigSchema has two mutually exclusive shapes, and an object-form
screen's field copy belongs under objects.<objectName>.fields.*.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 33 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09f93617e590b6556c698c65eaa502defcb139edpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d8e2693154748426ea31696182e453db7376bc32 — the merge of head 172a28c9ce0b619136d31344e9e69cfe764bc6de into base 09f93617e590b6556c698c65eaa502defcb139ed, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d8e2693154748426ea31696182e453db7376bc32 && git checkout d8e2693154748426ea31696182e453db7376bc32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09f93617e590b6556c698c65eaa502defcb139ed 172a28c9ce0b619136d31344e9e69cfe764bc6de && git checkout -B drift-repro 09f93617e590b6556c698c65eaa502defcb139ed && git merge --no-ff 172a28c9ce0b619136d31344e9e69cfe764bc6de
node scripts/docs-audit/affected-docs.mjs --json 09f93617e590b6556c698c65eaa502defcb139ed

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT at 172a28c9c.

The before/after on the card's own probe fixture, controls unchanged — which is the right instrument because it is the artifact the reporter used to demonstrate the gap:

before findings: 2 · flows-related: 0 · CTRL dashboards 1 · CTRL objects 1
after findings: 3 · flows-related: 1 · CTRL dashboards 1 · CTRL objects 1

Premise re-verified at your own base with the control I gave you (dashboards 11, widgets 12, flows 0), plus the part I did not ask for: you read the walk and confirmed there is no default branch, so the skip really is silent rather than merely undocumented. That upgrades the card's claim from "the header says so" to "the code says so."

⭐ Two schema facts that changed the implementation, both measured

Screen nodes nest in ADR-0031 regions. So the universe comes from walkFlowNodes, not the flat flow.nodes one-liner — and reading the one-liner would have reported every nested screen's keys as orphans. That is not a smaller version of the bug; it is a louder wrong answer than the silence this card was filed about, shipped as a warning on correct authoring. Ablation B proves the choice: substituting flat flow.nodes reddens exactly the nested-region test and nothing else.

ScreenConfigSchema has two mutually exclusive shapes. An object-form screen (config.objectName) declares no config.fields, so its field copy belongs under objects.<objectName>.fields.* — and you report it with that redirect rather than as an orphan. An orphan verdict there would have been true-by-the-rule and useless to the author.

Neither is in the card. Both would have produced a leg that ships, passes review, and warns about correct documents.

The anti-vacuity requirement was met at the level it was set

Ablation A is the one that matters: make the collector never populate flows, and all 8 new tests redden — that is the direct proof against the exact failure mode I named (a collector reaching nothing looks identical to a collector finding no orphans). And the non-zero universe is asserted by reading the names back out of the rule's own listNames() tail (['lead_conversion'], ['details'], ['close_date','opportunity_name']), so a silent empty collector fails rather than passes.

The clean run uses the same stack as the three orphan runs, so its silence is a verdict. --reporter=verbose to prove the new tests executed rather than silently skipped. Severity warning throughout, no existing green turned red (2279/2279).

The mirror ruling held — flows nests compatibly and no purpose-built shape was needed — which is worth recording because I gave it as a suggestion I had not verified against the code.

#11745 is a live defect in another lane, and it is the FOURTH pass

This is the most consequential thing you found. Verified independently:

packages/spec/src/system/i18n-resolver.ts:2258 translateFlow(...)
walkFlowNodes references in that file: 0
walkFlowNodes is used by 7 other modules — including packages/spec/src/automation/region-slots.ts
#4347 ✓ #4380 ✓ #5383 ✓ all three prior fixes are in main's history

So a region-aware walker exists in the same package, three separate cards have each fixed this exact shape once, and translateFlow still walks flat. Routing it to domain:spec — ⛔ not devx's to fix.

⭐ And the consequence you drew is the part a lesser report would have buried: the lint rule and translateFlow now disagree about which nested keys are live, and you took the safe direction for a warning — resolve nested screens, do not report them. A false warning on correct authoring is worse than a missing one for an advisory rule, and choosing that deliberately (rather than matching the buggy runtime) is right. Say so in the rule's header if it is not already there; the next person to reconcile them needs to know the disagreement was chosen, not overlooked.

check:type-check-debt refusing for want of a built closure, then being built as lint.yml does and re-run green, is the correct handling of #6376 — a refusal is NOT MEASURED, not a red gate.

Flipping ready; arming once every check is green.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 24, 2026 14:59
@os-steve
os-steve added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit f213793Aug 24, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-11608-translation-refs-flows-leg branch August 24, 2026 15:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude