Uh oh!
There was an error while loading. Please reload this page.
fix(lint): guard collectBare's recursion so a self-referential page terminates instead of killing the stack - #13289
fix(lint): guard collectBare's recursion so a self-referential page terminates instead of killing the stack#13289os-elon wants to merge 2 commits into
collectBare's recursion so a self-referential page terminates instead of killing the stack#13289Conversation
collectBare is a lockstep raw/parsed walker separate from walkPageComponents and carried no cycle guard, so a page whose component tree contains itself -- input PageSchema accepts, since properties is z.record(z.unknown()) -- killed the stack at door 1 before any other door ran. The guard is an ancestor set on the authored side: added on entry, removed on exit, so a node is skipped only when it is its own ancestor. That makes it report-neutral on acyclic input by construction, where a visited-set would have silently dropped findings from merely shared subtrees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw
The previous fixture put a record back on the ring, so a record-only ancestor set would have caught it too. An array that contains ITSELF has no record on the cycle, which is the shape that actually discriminates: walkPageComponents only recurses on records, while collectBare descends into arrays as values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw
📓 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 09804ee2ba1c5472d4419e4da90afbc6f47eb2e9 && git checkout 09804ee2ba1c5472d4419e4da90afbc6f47eb2e9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 74049254d47bd0edd2a2fcd732dcc01c91504f10 040433a84819340c6a3e59ac6a2812b8c547d72a && git checkout -B drift-repro 74049254d47bd0edd2a2fcd732dcc01c91504f10 && git merge --no-ff 040433a84819340c6a3e59ac6a2812b8c547d72a
node scripts/docs-audit/affected-docs.mjs --json 74049254d47bd0edd2a2fcd732dcc01c91504f10 |
Fixes#13235
What was wrong
collectBareinpackages/lint/src/page-envelope-audit.tsis a lockstep raw/parsed valuewalker, separate from the shared
walkPageComponentstraversal, and it carried no cycleguard.
A page whose component tree contains itself is input the schema admits:
propertiesisz.record(z.unknown())andproperties.childrenisz.array(z.unknown()), soPageSchema.safeParsesucceeds and the walk then recursed until the stack died. Measured atthe base commit
74049254d4, all four layers on oneA -> B -> Apage:Door 1 runs
collectBareover the whole page (line 264) before the shared walk (line 276),so this is the first thing that dies.
The fix
An ancestor set on the authored side: objects are added on entry to the descent and
removed on exit, so a node is skipped only when it is its own ancestor. Three properties are
load-bearing, and each is pinned by a test whose necessity was measured by ablation rather
than argued:
raw, notparsedraw; boundingraw's simple-path depth bounds the recursion whatever shapeparsedhascollectBaredescends into arrays as values, so an array that contains itself is a cycle carrier with no record on the ringAnyRecset) still dies withRangeErroron the array-only ringThat third row is the reason this is not a reuse of the shared walk's guard. The guard
armed on the
walkPageComponentscard (PR #13236, not merged at this branch point) reachesthe same ancestor-set conclusion independently, but its set holds records only (
AnyRec) — correct there,because that walk only ever recurses on records. Here a record-only set is measurably
insufficient. So this carries a local guard by necessity, not by duplication, and there is
no dependency on unmerged code.
Verification
Everything below was run at the final commit
040433a848.pnpm --filter @objectstack/lint test— 82 files, 2331 passed.platform-objects20,mcp16,cloud-connection11 — 47 passed. All three alias@objectstack/lintto source, so they ran against the patched walker. This is thereport-neutrality measurement on real pages, not on fixtures.
pnpm lint(eslint . --no-inline-config, whole repo) — clean, 49s. No narrowing claimed.pnpm --filter @objectstack/lint typecheck— exit 0.**/*.test.ts, so it says nothing about the test edits; re-run with tests included, thecount is 19, exactly the figure ledgered for this package in
scripts/check-type-check-coverage.mjs, and 0 of the 19 are in either edited file.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack,28 matched + convention-triggered): all green.
check:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:where-matcher,check:engine-double-contract,check:query-options-erasure,check:type-check-coverage,check:published-files,check:page-declaration-shape,check:slot-lookup,check:type-source-resolution, and the changeset family.pnpm --filter @objectstack/lint build— dts emitted 2/2, built CJS loads.Three gates are NOT MEASURED locally, by their own verdict lines, and are left to CI:
check:test-completenessandcheck:dual-build-cjs-loads(both exit 3, PREREQUISITE NOTMET — they need a saved turbo log and a full workspace build respectively) and
check:pm-half-states(exit 3, no usable GitHub credential in this container). None of thethree is a red.
Ablations restored by byte proof each time: restored blob hash compared against the HEAD
blob and
git diff HEADempty, under an EXIT/INT/TERM trap with absolute paths.Scope, and what this does not do
The card's fence is
collectBare, and that is fully discharged: door 1 terminates and stillreports every finding on a simple path.
auditPageExpressionEnvelopesend-to-end still stack-dies on the same input at thisbranch point, because
walkPageComponentscarries its own separate unguarded recursion —a different function and a different card (#13217, whose PR #13236 was still unmerged when
this branched). The regression tests here therefore drive door 1 in isolation, which is what
collectBareis exported for; asserting end-to-end termination here would be assertingsomeone else's change, and would go red or green for reasons unrelated to this diff.
⛔ Nothing here reopens or supersedes that card, and this one is not covered by it.
Declarations
anything different. The guard cannot fire on acyclic input, because no node is ever its
own ancestor there — that is by construction, and it is the property the report-neutrality
test pins. On a cyclic page nothing distinct is lost either: every node of a finite graph
is reachable by a simple path, so each authored position is still visited, and what is
dropped is only the infinite tail of re-reports at ever-longer paths.
collectBareis package-internal and not re-exportedfrom the barrel; confirmed mechanically against the built artifact — loading
packages/lint/dist/index.jsgives'collectBare' in exports === false. The addedparameter is optional and trailing, so every existing call site is untouched.
cyclic pages, so this stays a reachable crash on legal input rather than a live incident.
Open question for review, deliberately not decided here
The truncation is silent — a cyclic page reports its findings with no signal that a
descent stopped. That is defensible (unlike a door that could not open, this one read
everything there was to read, and every distinct position is still visited), and it is why
this ships as report-neutral. But this module's own doctrine is that preconditions are
reported and never assumed, and surfacing truncation would mean adding a channel to
PageEnvelopeAudit, which consumers are required to assert. That is a reporting-shapechoice on a published interface, so it is left to a contract call rather than taken in this
PR.
Generated by Claude Code
Generated by Claude Code