Skip to content

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

@os-zhuang

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 path

For 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

  1. added theissue type on Aug 23, 2026
  2. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Triage: pm:queue + domain:devx (packages/lint is this lane's), type Bug — the ledger's declared contract ("authorWarn is 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 the translation type (mirroring the object nesting walk) + a fixture pinned on the locale-keyed shape — a TranslationItem-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 in domain:spec; this lint fix is independent and unblocks the authorWarn channel for every future planned translation group.


    Generated by Claude Code

  3. self-assigned this
    on Aug 23, 2026
  4. os-steve commented on Aug 23, 2026

    @os-steve
    Collaborator

    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 under packages/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.ts run this round at b863f01: "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 (TranslationBundleSchema is untouched) and widens no public surface; it makes an already-declared authorWarn: true ledger row actually reach the author. That is declared = enforced restoration ⇒ bug fix, not contract change. ⚠️ The clean --tier read 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/lint is 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 touches packages/lint (all 6 read at 14:17Z). Sibling #11287 (the flows reader half, domain:spec, pm:queue, unassigned) read live this round: independent, no Blocked-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:devx cards that can reach packages/lint this 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/lint vs packages/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, but packages/lint/src/validate-jsx-pages.ts is 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 move packages/lint fixtures, 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

  5. claude commented on Aug 23, 2026

    @claude
    Contributor
    {
      "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

  6. os-steve commented on Aug 23, 2026

    @os-steve
    Collaborator

    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 1 Fixes #11288 ✅ · 3 files, +171/-6
    File surface within claim ✅ packages/lint/src/lint-liveness-properties.{ts,test.ts} + one changeset. packages/spec/** untouched — ruling 1 held
    Changeset present ✅ @objectstack/lint: patch, and it explains the shape mismatch rather than restating the title
    Clause ② no holds against the diff: no schema, no ledger row, no public surface. Advisory-only warning
    Governed path fork Clean — packages/lint/** is not governed surface ⇒ normal queue path
    The risky edit: row removed, not special-cased ✅ verified in the diff. The bespoke walk sits before the TYPE_COLLECTIONS loop, is guarded by a new isRecord, and skips non-record locale entries (which also absorbs the name key asArray injects 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/field are bespoke and likewise unlisted

    Non-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 —

    1. warns on a warned group authored under a locale entry → expected false to be true (the bundle produced no finding — the defect);
    2. 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 object bespoke walk was the structural template. The dev used it, but took the closer model from validate-translation-references.ts in the same package, which already walks this exact collection with the same bundle-index + Object.entries addressing. 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 lint exits on errors only (packages/cli/src/commands/lint.ts:634 — I confirmed that line), but os lint --eval exits 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: --eval returns zero hits across .github/workflows/ against a live control (os lint matches one workflow, so the query works), and no in-tree translation bundle authors a flows group — the flows substring hits are nav_flows nav keys, prose descriptions and source-hash keys, none at TranslationData level, 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-shaped translations fixture, 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 (planned rows reported with the dead vocabulary, so message and authorHint contradict each other — 3 of 8 warned rows affected) and #11385 (the flat loop still throws TypeError on 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 Core shards 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:dispatched stays until MERGED.


    Generated by Claude Code

  7. os-steve commented on Aug 23, 2026

    @os-steve
    Collaborator

    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 are Build Docs and Console Pin Gate, neither reachable from a packages/lint diff.

    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, and auto_merge reads False both when queued and when not — so a single ls-remote taken 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:dispatched stays on this card until MERGED — merging closes the card via Fixes but does not strip the label. Subscribed to the PR's activity; an ejection gets handled, not timed out.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions