Uh oh!
There was an error while loading. Please reload this page.
fix(lint): guard walkPageComponents against component cycles - #13236
fix(lint): guard walkPageComponents against component cycles#13236os-elon wants to merge 1 commit into
walkPageComponents against component cycles#13236Conversation
📓 Docs Drift Check1 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 — 5 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 ab5c66105a36c7834d2ae57d54c58362d2eea8e2 && git checkout ab5c66105a36c7834d2ae57d54c58362d2eea8e2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a9b7a0b104b0434ec04a763aab739b717add76c c79006ebbe38c10a7047c642ca83e558f1b95e29 && git checkout -B drift-repro 5a9b7a0b104b0434ec04a763aab739b717add76c && git merge --no-ff c79006ebbe38c10a7047c642ca83e558f1b95e29
node scripts/docs-audit/affected-docs.mjs --json 5a9b7a0b104b0434ec04a763aab739b717add76c |
Uh oh!
There was an error while loading. Please reload this page.
os-elon
commented
Aug 29, 2026
PR #13289 (#13235, the It also ran an ablation this PR's owner may want to see. Ablation C narrowed its ancestor set to records only — the shape this PR uses — and the array-only ring still died with That is not a defect here:
No action needed on the sequencing: the two PRs have no code dependency in either direction and should land independently. Generated by Claude Code |
Fixes#13217
walkPageComponents— the one shared page-component traversal under every page-shaped lint rule and the CLI's i18n object-sections pass — descended the untyped composition slots insidepropertieswith no cycle guard. Every one of those slots isz.array(z.unknown())authored data, so a component whoseproperties.childrencontains itself is legal input. Feeding one in recursed until the stack died, and because the walk is shared rather than copied, that crash was not scoped to one rule.The card's landing path was wrong, and so was triage's correction
The card body names
packages/lint/src/page-walk.ts. The triage grading comment corrected that topackages/lint/src/page-envelope-audit.ts. Re-anchored from the tree: the card body was right and the correction is not.page-envelope-audit.ts:102only imports it (from './page-walk.js'), andindex.ts:718re-exports it from the same module. There is exactly one definition site. The card's own stack trace (visit … page-walk.ts:104) also lands on theproperties.childrenrecursion at that line, in this tree, today.Premise, measured before any edit
RangeError: Maximum call stack size exceededatvisitA -> B -> ARangeError: Maximum call stack size exceededatvisitWhat shipped: a cycle guard, NOT a depth cap
These are different instruments and the card asked for the choice to be stated. This ships the cycle guard only.
A cap bounds a legal-but-absurd document. On a resolver that leaves copy untranslated; on a lint walk it would drop real components from the walk output and every rule would go quiet about them — a silent truncation that reads exactly like a clean page. With the cycle guard the descent is already bounded by the document's own finite nesting, so a cap could only ever fire on acyclic input, which is precisely the input it must not truncate. The
depth 40row above is pinned as a test, and the suite pins depth 64 — past the sibling resolver's cap of 32.An ancestor set, not a visited set
The guard adds the node before descending and removes it on the way out, so only a node that is its own ancestor stops the descent. This is the predicate the sibling
translatePage(packages/spec/src/system/i18n-resolver.ts:1048) already settled on, and its reason applies here with more force: a component object placed twice as a sibling, or reached down two different branches, is legitimate re-use at two distinct config paths, and every rule built on this walk must see both placements. A visited set would yield the first and silently drop the rest — trading a loud crash for missing lint coverage. Both shapes are pinned as tests.Reverse verification
Both ablations were run from the committed fix, each proved on disk before the run (anchor grep counts plus
git hash-objectagainst the HEAD blob), each restored by anEXIT INT TERMtrap that re-verified the restored blob equals the HEAD blob. The test imports./page-walk.js— a relative, same-package specifier that resolves tosrc/, notdist/— so no rebuild is interposed, which the ablations demonstrate by picking up each mutation with no build step.RangeErrorRangeErrorThe A-to-B delta is the point: under the direct-only half-fix,
terminates on a DIRECT self-referencepasses whileterminates on an INDIRECT cycle (A -> B -> A)and the longer chain still die. The pin is on the indirect case, which is the one a half-fix survives.No new reject behaviour
Measured, because this was the one conditional the card reserved: on a cyclic-but-otherwise-valid page, all six rules that route through the walk report exactly what they report for the equivalent acyclic document.
The guard is silent by construction: a cycle stops the descent and yields nothing extra. Deciding that a self-referential page is itself an authoring error would be new reject behaviour on authored input, which is a contract call and not this walk's to make.
Blast radius, measured from the tree
The card named six rules plus the CLI pass. The real consumer set is larger: ten lint modules call the walk, not six — the card did not name
validate-component-types,validate-page-field-bindingsorvalidate-translation-references, and all three route through it. So the p1 argument holds and then some.One member of the named six behaves differently, and it is not fixed by this PR.
page-envelope-auditrunscollectBareat Door 1 (line 264) before it reacheswalkPageComponents(line 276), andcollectBareis a separate lockstep raw/parsed value walker with its own unguarded recursion. On a cyclic page it dies at line 264 and never reaches the walk at all. Its membership in the blast-radius list is real; the cause is not the missing walk guard. Filed separately as #13235 with the measurement — a third walk, out of scope both here and for #13218, and its guard shape is a genuine design call rather than a mechanical copy. #13235 is not addressed here.No authored page in this repo carries such a cycle: swept 57 page-shaped objects across 34 modules with a live positive control, zero hits. This fixes a reachable crash, not an active incident.
Gates
All run at the final commit
c79006ebbe, exit codes captured before any pipe.Derived by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(27 matched families + 6 convention-triggered by the added test file). All green, includingcheck:cross-package-test-inputs,check:test-source-alias,check:page-declaration-shape,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:changeset-gate-self-tests.Two needed the built closure, so the closure was built (
turbo run build, 70/70 successful) and both then measured green:check:type-check-coverage --re-measure: OK — 30 ledger entr(ies) re-measured in 242.9s, 1560 raw tsc error(s) total, none above its recorded number.check:dual-build-cjs-loads — 102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse.Tests:
pnpm --filter @objectstack/lint test— 82 files, 2334 tests passedpnpm --filter @objectstack/cli exec vitest run test/i18n-extract.test.ts test/platform-page-i18n-parity.test.ts test/i18n-extract-emitted-files.test.ts— 3 files, 22 tests passed (the named cross-package consumer)pnpm --filter @objectstack/lint typecheck— exit 0Two readings recorded as NOT MEASURED rather than green:
node scripts/pm/check-half-states.mjsrun bare exits 3 with its ownPREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential … it is no reading at all. CI's actual invocation ispnpm check:pm-half-states(--self-test), which is exit 0 here.node scripts/check-test-completeness.mjsrun bare answers with a usage line (it grades a savedturbo run testlog). Known — [finding]check-test-completeness.mjsanswers a standalone run with exit 1 + a usage line, so the derived gate family reads one false red — three independent devs paid for it in one session #13110, PR fix(scripts): refuse a logless check-test-completeness run instead of answering exit 1 #13152. CI always passes the log path, so that branch is unreachable there.packages/lint/tsconfig.jsonexcludes**/*.test.ts, andtsc --listFilesconfirms 1 hit forpage-walk.tsand 0 forpage-walk.test.ts. That is this package's accounted-forTEST_DEBTposture (check:type-check-coverageis green), not a regression from this PR — recording it so "typecheck is clean" is not read as covering the new tests. The tests are of course executed.ESLint was narrowed rather than swept, and the narrowing is measured: 2 files linted per
--format json, 0 errors, 0 warnings, with the population taken from eslint's own config. The repo runs oneeslint.config.mjsthat never enables type-aware linting for any file (documented ateslint.config.mjs:327-329), so this diff cannot move the verdict on any file it does not touch. The repo-widepnpm lintis CI's run.⛔
packages/lint/src/validate-jsx-pages.tsis untouched (held by #13154). ⛔ The two walks are not unified — that is #13218's scope and needs a prior decision about which walk gets exported.Generated by Claude Code
Generated by Claude Code