Repository navigation
lintLivenessProperties walks stack.translations as if each item were a TranslationItem — it is a locale-keyed bundle, so the whole translation ledger warns nobody #11288
Description
Activity
Triage:
pm:queue+domain:devx(packages/lintis this lane's), type Bug — the ledger's declared contract ("authorWarnis what tells the author") is enforced for zero file-authored bundles, with a guarded A/B ablation proving zero delta. Fix shape per the card: a bespoke locale-keyed walk for thetranslationtype (mirroring theobjectnesting walk) + a fixture pinned on the locale-keyed shape — aTranslationItem-shaped fixture passes on the broken walk, so the pin must be the bundle shape. The "are runtime-authored translation items reached at all by this lint" side-question is the dev's to answer in the PR, not a blocker. Sibling context: #11287 (the flows-reader half) is queued separately indomain:spec; this lint fix is independent and unblocks theauthorWarnchannel for every futureplannedtranslation group.
Generated by Claude Code
Claim: PM loop round R2
Session:session_015ahemw8RcTgqtxrj15PEZx
Branch:claude/issue-11288-translation-liveness-walk
Worktree:objectstack-issue-11288
Domain:domain:devx
File surface:packages/lint/src/lint-liveness-properties.ts+ its tests/fixtures underpackages/lint/; ⛔packages/spec/**is out of surface — it is the spec seat's, and the ledger rows themselves are not this card's to flip (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(node scripts/pm/dispatch-gates.mjs --tier packages/lint/src/lint-liveness-properties.tsrun this round atb863f01: "no path-derived mandate"; judgement tier because the walk's shape and the runtime-vs-stack-door question are design calls, not mechanical edits)
Clause-②: no — judged from content, not from the clean path read. The fix changes no schema accept set (TranslationBundleSchemais untouched) and widens no public surface; it makes an already-declaredauthorWarn: trueledger row actually reach the author. That isdeclared = enforcedrestoration ⇒ bug fix, not contract change.⚠️ The clean--tierread covers only the path limb and is not a clearance for this limb; this line is the seat's own content judgement and is auditable as such.
Serial constraints cleared:packages/lintis fenced to this card for the round. #11148 (packages/sdui-parser.trim()) is held serial behind this card, not dispatched — see below. No open PR touchespackages/lint(all 6 read at 14:17Z). Sibling #11287 (theflowsreader half,domain:spec,pm:queue, unassigned) read live this round: independent, noBlocked-by:either way — it needs a reader to exist, this card needs the walk to reach the bundle. Neither gates the other.
fold-or-serial — answered, verdict SERIAL
#11288 and #11148 are the two queued
domain:devxcards that can reachpackages/lintthis round. They do not fold; gate ① fails outright:Gate #11288 vs #11148 ① same defect shape, same fix ❌ a locale-keyed walk bug vs an HTML-whitespace .trim()— unrelated defects, unrelated fixes. Fails here; the rest is recorded for completeness only② same package/area ❌ fix lands in packages/lintvspackages/sdui-parser③ every member already graded ✅ both pm:queue, both type Bug④ each independently checkable ✅ ⑤ exclusion list named n/a — no family ⇒ Serial, not folded. #11148's fix lands in
packages/sdui-parser/src/parse.ts, butpackages/lint/src/validate-jsx-pages.tsis its only measured in-tree consumer (git grep -l sdui-parser origin/main -- packages/lint/→validate-jsx-pages.ts,package.json,CHANGELOG.md). A parser-whitespace change can movepackages/lintfixtures, so running both at once risks two devs editing one package's tests. Known pothole recorded on #11148 at the moment of deferral, per the "延后不是搁置" rule.
Generated by Claude Code
{ "issue": 11288, "status": "done", "branch": "claude/issue-11288-translation-liveness-walk", "pr": "https://github.com/objectstack-ai/objectstack/pull/11383", "premise_still_valid": true, "head": "dd0e782", "base": "b863f01", "files_changed": [ "packages/lint/src/lint-liveness-properties.ts (+54/-4)", "packages/lint/src/lint-liveness-properties.test.ts (+105/-1)", ".changeset/lint-liveness-translation-bundle-walk.md (+15, new)" ], "line_budget": "n/a — 0 files under skills/** (git diff --name-only b863f01 dd0e782 -- 'skills/**' = 0). Whole diff: 3 files, +171/-6.", "summary": "translation now walks bespoke (bundle -> locale entry -> TranslationData) instead of flat; the TYPE_COLLECTIONS row is replaced by a tombstone comment. Zone 2 line numbers re-derived at b863f01 and all three hold (TYPE_COLLECTIONS :221, translation row :247, walk loop :294). Fence intact: no evidence of another agent in packages/lint (the one sibling worktree in the container is issue-10907, packages/cli i18n-coverage).", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (script read the change set itself from the merge base; no hand-built diff passed). 9 path-matched + 6 convention-triggered (test-file kind) + 5 changeset-triggered.", "run_against": "dd0e782 — the FINAL commit; the first pass ran on a tree that then took a comment reflow, so the whole union was re-run after committing.", "green": [ "check:cross-package-test-inputs", "check:published-files", "check:slot-lookup", "check:test-source-alias", "check:type-source-resolution", "check:query-options-erasure", "check:type-check-coverage", "check:engine-double-contract", "check:where-matcher", "check:changeset-gate-self-tests", "check:objectui-changeset", "check:nul-bytes", "check:type-check-debt", "scripts/check-ci-filter-parity.mjs", "scripts/check-cross-package-test-inputs.mjs", "scripts/check-plugin-teardown-shape.mjs", "scripts/docs-audit/check-affected-docs.mjs", "scripts/check-adr-0087-registration.mjs", "scripts/check-changeset-no-major.mjs", "scripts/check-empty-changeset.mjs" ], "red": [], "notes": "check:type-check-debt --re-measure needed the built closure, so `turbo run build --filter=./packages/* --filter=./packages/*/* --concurrency=2` was run first (70/70 tasks, 6m10s, under the verify lock). It reports no drift for @objectstack/lint: the new test code adds zero raw tsc errors to the TEST_DEBT program that hides packages/lint's tests. Its three informational lowerable entries (plugin-approvals -1, runtime -1, plugin-auth -12) and one graduation candidate (trigger-record-change) are pre-existing on other packages and untouched by this diff. Repo-wide `pnpm lint` (eslint . --no-inline-config) was run WHOLE, not narrowed: clean, 85s.", "verdicts_quoted_not_inferred": "every line above is the gate's own printed verdict; each gate was run as its own command with its exit code captured directly, never through a pipe." }, "tests": { "prefix_failure": "REQUIRED NON-VACUITY EVIDENCE (ruling 3). The 6 new cases were written and run BEFORE any source edit, on an otherwise untouched tree. `pnpm --filter @objectstack/lint test` printed: `Test Files 1 failed | 79 passed (80)` / `Tests 4 failed | 2248 passed | 5 skipped (2257)`. The four failures, in two opposite directions: (1) 'warns on a warned group authored under a locale entry' -> AssertionError: expected false to be true (the locale-keyed bundle produced NO finding on the broken walk); (2) 'does not treat a runtime `TranslationItem` shape as a bundle' -> AssertionError: expected [ { …(4) } ] to deeply equal [], received finding message 'sets `flows` but this translation property has no runtime effect (liveness: dead)' (the item-shaped fixture DID warn on the broken walk — the direct proof of ruling 2); (3) 'reaches every locale of every bundle, not just the first of each' (authored on locale 2 of bundle 2); (4) 'never throws on a malformed bundle' -> TypeError: Cannot read properties of null (reading 'name').", "postfix": "Same command after the fix: `Test Files 80 passed (80)` / `Tests 2257 passed (2257)`. Per-case confirmation via `vitest run src/lint-liveness-properties.test.ts --reporter=verbose`: all 6 new cases named with a check mark, 49/49 in that file.", "typecheck": "pnpm --filter @objectstack/lint typecheck — exit 0, no TS diagnostics.", "build_direction": "dependency closure built FIRST in the fresh worktree (`pnpm --filter '@objectstack/lint^...' build`, 162s) before any test was believed; the full workspace build later served check:type-check-debt.", "end_to_end_on_built_dist": "Post-fix built dist, not source: `lintLivenessProperties({ translations: [null, { en: { flows: { x: {} } } }] })` returns one finding, where = \"translation bundle #1 · locale 'en'\". Same call shape on a flat collection still throws (`{ agents: [null] }` -> TypeError), which is filed as #11385 rather than fixed.", "ablation": "n/a — no mutation/ablation leg was used. The pre-fix/post-fix pair above is the direction evidence, and it needed no mutation: the pre-fix run is literally the unmodified source.", "blast_radius": "Assumption 4 VERIFIED, with one caveat worth PM's attention. `os lint` exits 1 only on errors.length > 0 (packages/cli/src/commands/lint.ts:634); warnings never move the exit code. BUT `os lint --eval` does exit 1 on a scored corpus bar that counts warnings — it is invoked by no workflow in .github/workflows, so it cannot redden CI. Blast radius is bounded to `translation.flows` (the only warned row in the translation ledger, measured), and no file in the tree authors a `flows` group inside a translation bundle, so this change produces zero new findings in-tree today." }, "deviations": [ "SUBJECT SHAPE: the finding subject is \"translation bundle #0 · locale 'zh-CN'\" — the card/triage-endorsed shape PLUS the bundle index. Reason: bundles are an unnamed array and one locale can appear in several, so without the index the finding does not say which bundle to open; it is the addressing the sibling rule validate-translation-references already reports (translations[0][\"zh-CN\"]).", "STRUCTURAL TEMPLATE: PM's assumed template was the `object` bespoke walk. It is a fine template for the two-level shape and was followed, but the closer model taken for the bundle iteration itself is validate-translation-references.ts in the same package, which already walks this exact collection (bundle index + Object.entries(bundle) locale entries) and whose docblock already states the shape.", "FIXTURE TRIAGE (unrequested, in-file): the pre-existing #4667 silence pin authored a TranslationItem-shaped `translations` fixture. It asserts silence, so it was green both before and after and pinned nothing about this collection; it is now bundle-shaped. No assertion changed.", "TYPE_COLLECTIONS row REMOVED rather than special-cased, replaced by a tombstone comment. The file's own convention is that a bespoke-walked type is not listed (object/field are not), and the list's standing comment warns about an unregistered type warning nobody — the tombstone states the other half of that contract explicitly so the row is not restored as a 'fix'.", "NO packages/spec CHANGE. Ruling 1 held: no ledger row flipped, no schema touched. The `flows` row stays `planned` with `authorWarn: true` (ruling 4)." ], "open_questions": [], "out_of_scope_findings": [ "filed as #11384: `lintLivenessProperties` reports `planned` ledger rows with the `dead` vocabulary (describe() branches only on `experimental`), so the message and the authorHint on the same finding contradict each other — 3 of the 8 warned rows across all shipped ledgers are `planned`. No label (concrete defect, PM triage). Pre-existing and independent of this card: field.relatedListFilter and object.externalSharingModel already hit it.", "filed as #11385 (label: finding): the flat TYPE_COLLECTIONS loop throws `TypeError: Cannot read properties of null` on a null collection item, against the function's own 'never throws' contract. Latent — input: 'parsed' means Zod has already rejected such a stack. This PR's bespoke walk guards its own two levels; the flat loop is untouched." ] }
Generated by Claude Code
ACCEPT — PR #11383 · reviewer of record:
devx@objectstack(session_015ahemw8RcTgqtxrj15PEZx)Checklist passed. Every reading below was re-taken against GitHub or
origin/main; nothing is accepted on the report's say-so.Spot-checks I re-took independently
Claim My reading PR shape draft ✅ · base main✅ · body line 1Fixes #11288✅ · 3 files,+171/-6File surface within claim ✅ packages/lint/src/lint-liveness-properties.{ts,test.ts}+ one changeset.packages/spec/**untouched — ruling 1 heldChangeset present ✅ @objectstack/lint: patch, and it explains the shape mismatch rather than restating the titleClause ② noholds against the diff: no schema, no ledger row, no public surface. Advisory-only warningGoverned path fork Clean — packages/lint/**is not governed surface ⇒ normal queue pathThe risky edit: row removed, not special-cased ✅ verified in the diff. The bespoke walk sits before the TYPE_COLLECTIONSloop, is guarded by a newisRecord, and skips non-record locale entries (which also absorbs thenamekeyasArrayinjects for map-shaped input). The removed row is replaced by a tombstone comment stating why re-adding it is the bug. That matches the file's own convention —object/fieldare bespoke and likewise unlistedNon-vacuity — the strongest part of this run
Ruling 3 asked for a pre-fix failure. What arrived is better: 6 cases written and run before any source edit, on an otherwise untouched tree, failing in two opposite directions —
warns on a warned group authored under a locale entry→expected false to be true(the bundle produced no finding — the defect);does not treat a runtime TranslationItem shape as a bundle→ the item-shaped fixture did warn.
⭐ (2) is the direct proof of ruling 2, not a restatement of it. It demonstrates on the running code that a
TranslationItem-shaped fixture is green on the broken walk — i.e. such a test would have been green from the day the bug shipped and pinned nothing. That is the difference between asserting a fixture-shape rule and measuring it.Post-fix:
Test Files 80 passed (80)/Tests 2257 passed (2257), plus an end-to-end call against the built dist rather than source.My own Zone-2 assumptions, both falsified — correctly
- I assumed the
objectbespoke walk was the structural template. The dev used it, but took the closer model fromvalidate-translation-references.tsin the same package, which already walks this exact collection with the same bundle-index +Object.entriesaddressing. Better answer than mine, and it makes the two rules report the same coordinates. - I asked it to verify the blast radius rather than trust my reading. It did, and returned a caveat I did not anticipate:
os lintexits onerrorsonly (packages/cli/src/commands/lint.ts:634— I confirmed that line), butos lint --evalexits 1 on a scored bar that counts warnings. It then established that no workflow invokes--eval, so CI cannot redden. That is the shape a verification answer should have: the risk named, then bounded.
I re-measured both limbs myself:
--evalreturns zero hits across.github/workflows/against a live control (os lintmatches one workflow, so the query works), and no in-tree translation bundle authors aflowsgroup — theflowssubstring hits arenav_flowsnav keys, prose descriptions and source-hash keys, none atTranslationDatalevel, against a control of 93 translation files in the tree. ⇒ zero new findings in-tree today.Deviations — five, all declared, none disqualifying
The subject gained a bundle index (
translation bundle #0 · locale 'zh-CN') beyond the shape triage endorsed — justified: bundles are an unnamed array and one locale can appear in several, so without it the finding cannot say which bundle to open. Accepted as an improvement.⭐ The unrequested fixture triage is worth naming: the pre-existing #4667 silence pin authored a
TranslationItem-shapedtranslationsfixture, so it was green before and after and pinned nothing about this collection. It is now bundle-shaped with no assertion changed. That is a second latent vacuous test found and closed while passing through — exactly the class this card is about.Out-of-scope findings — both filed, both correctly not fixed here
#11384 (
plannedrows reported with thedeadvocabulary, so message andauthorHintcontradict each other — 3 of 8 warned rows affected) and #11385 (the flat loop still throwsTypeErroron a null collection item, against the function's own "never throws" contract). Verified both exist, open, unassigned. The second is notable for honesty: the dev's own end-to-end probe showed{ agents: [null] }still throws, and it filed that rather than quietly widening its guard across a surface it was not dispatched for.⛔ Landing HELD — CI still converging
Head at review time: 10 success · 2 skipped · 16 pending · 0 red, with all six
Test Coreshards still running. Entry eligibility is every check green, not the required subset. I will re-read the full set and flip ready → queue only on all-green;pm:dispatchedstays until MERGED.
Generated by Claude Code
Landing hold released — PR #11383 is in the merge queue.
Final gate read at head
dd0e782: 29 success · 2 skipped · 0 red · 0 pending — all-green on the full check set, not the required subset. The two skipped areBuild DocsandConsole Pin Gate, neither reachable from apackages/lintdiff.Path fork taken from the PR's actual file list, not the report:
.changeset/…,packages/lint/src/lint-liveness-properties.ts,…test.ts. No governed surface (docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md) ⇒ normal queue path, no human-merge hold.Ready-flip then arm, in that order. Queue membership verified against the ref:
15:21:0x refs: [pr-11378] <- absent, enqueue still in flight 15:21:44 refs: [pr-11378, pr-11383] <- present⚠️ Worth knowing for the next seat: enqueue is asynchronous, andauto_mergereadsFalseboth when queued and when not — so a singlels-remotetaken right after arming can show a false "not queued", and the tempting response (re-arm) is the one action most likely to disturb a PR that was about to enter. Poll the ref; don't sample it once. Correction recorded on the seat post.pm:dispatchedstays on this card until MERGED — merging closes the card viaFixesbut does not strip the label. Subscribed to the PR's activity; an ejection gets handled, not timed out.
Generated by Claude Code
Summary
lintLivenessPropertiesregisters{ type: 'translation', key: 'translations' }and then walksstack.translationsitems as if each were aTranslationItem— readingitem['flows'],item['objects'], … off the top level. Butstack.translationsisz.array(TranslationBundleSchema)(packages/spec/src/stack.zod.ts:275): each item is a locale-keyed bundle, so its top-level keys areen/zh-CN/ja-JP/ …, and every warned property lookup misses.Net effect: the whole
translationliveness ledger is silent for file-authored bundles — the only way apps author translations today. It is the exact failure the surrounding code comment names:Here the collection is registered; the shape underneath it is the mismatch.
Measurement (HotCRM @
a0bc5e8d,@objectstack/*17.1.0)Guarded ablation on a real app.
flowsis the ledger's only currently-warnedtranslationproperty (status: planned,authorWarn: true, with anauthorHintthat says no shipped runner reads the group). Injected aflows:section intosrc/translations/zh-CN.ts, ranobjectstack lint --json, restored viatrap:Baseline on the unmutated tree, same command: 91 total issues as well. The mutation is completely invisible — zero delta, and specifically zero liveness advisories.
Expected direction was "one new advisory". Measured direction was "no change at all", which is what turned this from a corroboration into a finding.
Why it misses
packages/lint/src/lint-liveness-properties.ts:For
flows(a top-level, dotless path)checkItemtakes the[item[path]]branch, i.e.bundle['flows']— and aTranslationBundlehas noflowskey at any depth reachable that way; the groups live one level down, under each locale code.The two doors #3778 bridged share the group vocabulary but not the container: the runtime
translationmetadata item haslocale+ groups at the top level (which is the shape this walk assumes), while the file-authored stack collection isRecord<locale, TranslationData>.Consequence
An app author who reaches for a
plannedtranslation group gets silence — no warning, no error, a greenos lint— and ships translations that render nowhere. That is strictly worse than the ledger being absent, because the ledger's stated contract is thatauthorWarnis what tells the author. Measured today this affectsflows(the only warned row); it will affect every futureplannedtranslation group the same way.Suggested fix
Give the
translationtype a bespoke walk, the wayobjectalready has one for its field nesting: for each bundle instack.translations, iterate its locale entries andcheckItemtheTranslationDataunder each, with a subject naming the locale (e.g.translation bundle · locale 'zh-CN'). A test should pin it against a locale-keyed fixture, since a fixture shaped like aTranslationItempasses on the broken walk.Worth deciding at the same time whether runtime-authored
translationitems are reached at all by this lint, or only ever the stack collection — the ledger's rows cite both doors, and only one of them is walked here.Back-link
Found while measuring objectstack-ai/hotcrm#1210 (HotCRM's screen-flow localization card), whose platform half is filed as #11287. Not a blocker for either — this is the reason the
flowsrow'sauthorWarnnever reached the app author who would have benefited from it.No labels applied — routing is triage's call.