docs(state): release notes and the known limitation for recursive paths (stacked on #259) - #260
Merged
Merged
Conversation
…y sit on (Phase A)
Phase A of the recursive-path work measures whether the engine can carry a
recursive path family at all. Seven probes plus adversarial verification turned
up one blocker for the plan's stated goal ("works on a tree that is not
rendered") and four smaller defects. This lands the measurements as two
permanent suites so the later fixes have something to flip.
integration.recursionPrerequisites.test.ts (37) pins what already works and what
the feature will lean on: cold-start asymmetry ($getAll/$setAll build the
ListIndex ledger themselves, only $resolve throws), manually unrolled aggregate
getters following every structural change when one `for` exists, lazy accessor
materialisation via defineTreeAccessor when it happens before the first read,
and the effective depth ceiling of 128 evaluation frames.
integration.recursionKnownDefects.test.ts (50) characterises what is wrong
today, each `it` naming the value it should return:
- a structural write to a for-less root list splits the ListIndex ledger
generation, leaving aggregates permanently stale or permanently throwing
(the diff baseline is only ever written from the render path)
- sharing one array instance between two parents aliases the ledger to the
first parent seen; cycles are the same defect
- depth 129 reports "Possible circular dependency" on a strictly linear tree
- $129 resolves to undefined with no diagnostic
- $setAll / $resolve(path, indexes, value) bypass the readonly guard
The design note and the implementation plan record the corrections, the new
D12 gate (reject shared array instances, not "DAGs"), and a Phase A' that lands
E1-E3 before the feature work starts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… split depth from cycle (Phase A') Phase A measured three defects the recursive-path work sits on. All three are pre-existing and none is specific to recursion. E1. walkDependency read its list diff baseline from a store only ever written by the render path. A list with no `for` binding therefore had an empty baseline forever, so createListDiff took its "old list was empty" branch and minted fresh ListIndexes for the new array. The child ledgers kept pointing at the old parent ListIndexes, which left aggregates permanently stale or permanently throwing "ListIndexes not found" — exactly the headless tree the feature targets. The render baseline answers "what did I last paint"; merging the walk into it would starve applyChangeToFor of its diff. So this adds a second, state-side baseline shared by reads, rendering and the dependency walk. The reads have to share it too: it is the ledger a read builds that the first structural write re-mints. Observations are committed at the end of the update batch rather than the end of each walk. Committing per walk lets a second structural write in the same batch diff against an intermediate value that is never painted; when that value drops rows, their ListIndexes are re-minted and the child ledgers break again. Batch scope makes every walk in a batch diff against the value the renderer diffs against. E2. Depth 129 reported "Possible circular dependency" on a strictly linear tree. The check now looks for a repeated address across the whole stack rather than a repeated path string in the last 8 frames. Path strings cannot separate the three shapes: a getter cycle longer than the window was reported as mere depth (and the old wording asserted "no repeated path" while printing the cycle underneath), while a legitimate recursion that reads the same path on another row has one path string all the way down. IStateAddress is interned on (pathInfo, listIndex), so only a real cycle revisits an address. E3. $129 resolved to undefined with no diagnostic while $128 returned 0. The lookup miss now raises wcs/index-param-range, gated on a '$' charCode so ordinary property reads never reach the regex. The eight known-defect tests for E1 and the two for E2/E3 are flipped to assert the corrected behaviour, keeping the old value in a comment. Four tests are added for the holes an adversarial review found: same-batch double writes (two shapes), a period-9 getter cycle, and a linear chain as its control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d getters (Phase B)
A tree has a dynamic depth, but a wcstack path bakes its depth into the string.
Phase A established that the engine already handles the shape — the reduction
edge from a deeper path to a shallower one has always worked — so what is
missing is notation, not machinery. This adds it.
$recursion = { "nodes.*": "children.*" };
get "nodes.**.total"() {
return this["nodes.**.value"] +
this.$getAll("nodes.**.children.*.total").reduce((a, b) => a + b, 0);
}
"**" is an authoring-layer symbol for an infinite family of ordinary paths. The
engine only ever sees one member of that family, expanded on demand: reading
nodes.*.children.*.total materialises the depth-1 accessor just before the read,
registers the list paths along the way, and from then on it is an ordinary
quoted-path getter. Depth is recovered from the address the engine already
pushes for getter paths, so "**" inside a getter body binds to the depth being
evaluated rather than to a count of repetitions in the string.
The invariant that "**" must never reach PathInfo is enforced by PathInfo
itself, on the intern miss: anything that hands a "**" path to a consumer which
does not interpret it fails loudly instead of producing a path whose wildcard
count is undefined.
Expansion is lazy because it has to be. A concrete path read before its accessor
exists caches undefined as clean and never recovers (isCacheable keys on
wildcardCount alone), so the hook sits before the cache lookup and the registry
tracks what this generation materialised — not what getterPaths happens to hold,
which after a re-set still contains the previous generation's accessors.
An adversarial review found nineteen holes; all are fixed here. The three that
mattered: bindings on a recursive concrete path warned that updates would be
silently dropped while rendering and updating correctly (path existence is now
checked against the declared "**" getters); two "**" getters whose expansions
overlap silently let the first-declared win (collisions are decidable from the
declarations alone and are rejected at construction); and a re-set lost the
listPaths registration. Two design calls were reversed: { "nodes.*": "nodes.*" }
is accepted — it is the natural spelling of a self-similar tree, and relative vs
absolute cannot be decided from the name — and the two diagnostics that pointed
at each other now say plainly that the all-depths union is not implemented yet.
$getAll with "**" binds to the current depth; the "[]" union form is Phase C and
says so. $setAll and $resolve reject "**" through the PathInfo invariant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
$getAll("nodes.**.value", []) walks every depth. The new walk descends only in
the depth dimension and hands each depth's concrete path to the existing
fixed-arity machinery, so what the engine sees is still an ordinary path with a
known wildcard count. Order is depth-first, pre-order, index ascending — the
same order $setAll will write in.
This also lands E6 (design gate D12): a recursion needs a tree, and a tree is
what the walk checks for. Phase A had measured that sharing one array instance
between two parents aliases the ledger to the first parent seen and returns
plausible wrong values with no diagnostic; cycles are the same defect, reported
until now as "wcs/wildcard-rank — wrap it in that many for templates".
The first predicate for that guard was wrong in a way worth recording. Asking
whether the ledger's parent is the parent we descended from conflates "shared"
with "re-minted": an ordinary immutable update (nodes.map(n => ({...n})), where
the spread carries children by reference) re-mints the row and permanently
rejected a legitimate tree, telling the author to give each node its own
children array — which they already had. Sharing is a property of the walk, not
of the ledger, so the walk now carries the set of arrays it has visited and the
set of arrays on the current branch. Reaching an array twice is sharing;
reaching one that is on the branch is a cycle. Empty arrays hold no rows and
cannot alias, so reusing [] stays legal.
Four more findings from the same review are fixed: the ancestor scan never
compared null, so a cycle back to the root list reported as sharing; the walk
probed one level past every leaf, which made the effective depth limit 127
rather than the documented 128; per-depth path strings were rebuilt per node;
and the suffix branch re-walked from level 0 for every node instead of
expanding from the row it already held. The duplicated dependency registration
is gone — getByAddress already registers the edge, and does it better.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
$setAll("nodes.**.selected", [], false) writes every node at every depth, using
the same enumeration the read side uses, in the same order. Only the broadcast
form is accepted: a mapper cannot receive an index tuple whose length changes
with depth, spread would hand a flat array to a tree and require the author to
know the walk order, and an omitted index list would give the write API the
implicit context $setAll deliberately does not have. Writes into the recursion
structure itself and into recursive getters are rejected by name.
Every form check runs before the enumeration, so a rejected call writes nothing.
Two findings from the adversarial review are fixed. The write's enumeration now
commits the shared list baseline. Passing commitDiffBaseline: false mirrored the
fixed-arity $setAll, but that flag meant "do not disturb the read's private
baseline" under a model E1 replaced: the baseline is now the state-side record
that reads, rendering and the dependency walk share. Without committing it, a
cold $setAll mints ListIndex generations for every depth and leaves no record of
them, so the next structural change diffs against nothing, re-mints the rows,
and orphans the surviving grandchildren's ledgers — after which reading a
recursive getter throws for good. The union of values keeps working, so nothing
looks wrong until a getter is read, and broadcasting undefined (which writes
nothing at all) breaks it just the same. Reproducing it needs grandchildren:
without a child that owns a list, there is no ledger left to orphan.
The recursive-getter check now matches families rather than exact suffixes.
"nodes.**.children.*.total" and "nodes.**.total" name the same concrete path at
consecutive depths — the collision rule the declaration check already applied,
which the write side lacked. Writing inside the value a getter derives is
rejected too; it used to reach Reflect.set on the cached object and count as
written.
The shape guard stays on the depth dimension only. Extending it to suffix lists
looks right until a suffix that contains the repeating sub-path re-walks the
recursion structure and reports a legitimate tree as shared — the same family
spelled two ways. Sharing created by broadcasting an array is recorded as a
known limitation instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e README (Phase E) The runtime side of recursive paths was done; this makes the rest of the system agree with it. vscode-wcs learns the $recursion declaration, stops reporting a recursive getter as a cycle because it refers to itself in the string, and reports statically what can be decided statically — leaving sharing, cycles and depth to the runtime, which is the only place they are knowable. The lint CLI is rebuilt from that validator core. examples/recursive-tree draws a tree with a self-referencing component and exercises the total, the clear-all broadcast and adding a deep child; it passes wcs-validate with no errors. The README gets a Recursive Paths section in both languages, including the double-counting trap that the union form invites inside an aggregate getter. Integration testing turned up eight defects. Four are fixed here. Re-setting a state after reading a recursive path left every later structural write on the anchor throwing for good: the _state setter clears _listPaths, but the dependency edges the generated accessors created outlive it, so the walk reached nodes.* with no listIndex. Values were still written, so the data and what the page showed drifted apart. The anchor's list path is decidable from the declaration, so it is registered when the declaration is read; and the previous generation's generated edges are dropped when the registry is rebuilt. Clearing the dependency maps wholesale was tried first and reverted — the full suite went green, but a probe showed aggregates no longer updating after a re-set, because the bindings' own edges went with them. A volume declaring $recursion was dropped in silence, where $streams is refused by name; a volume carrying a ** getter got as far as grafting the data and then failed inside accessor registration, leaving exactly the half-grafted state that file's header says must never exist. Both are now refused before grafting. A mounted component declaring $recursion got no mount-dollar-declaration guidance, so the author saw only a binding-path-missing naming a translated path that does not appear in their source. Ssr.extractStateData walked the raw state with Object.entries, which evaluates own enumerable getters with the raw object as this. A quoted-path getter yields NaN and lands in the snapshot as null; one that calls $getAll throws and takes the whole page's SSR down. Accessors are no longer evaluated: a snapshot carries data, and the client rebuilds derived values from the same declaration. That also answers the open question in the plan — the snapshot needs no record of how deep the tree was expanded. The remaining four are recorded as X6-X9. Two of them (rows not following after hydration, and after a re-set) are the same pre-existing class and are not specific to recursion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and the review follow-ups
Post-landing code review of the recursive-path branch (impl plan §7-3).
Blocking: with `$recursion: { "nodes.*": "branch.children.*" }`,
`$setAll("nodes.**.branch", [], …)` was not refused as a structural write.
`assertNotStructural` (runtime) and `structuralWriteTarget` (vscode-wcs)
compared the remainder only against `"." + repeatList`, so an object on the
way to the child list slipped through: measured 2 writes, the depth-1 node
gone, `treeTotal` stale. Both now treat every segment prefix of the repeating
sub-path ("" / ".branch" / ".branch.children") as structure; the editor gets a
third target kind (`branch`) with its own ja/en message. Regression tests on
both sides.
Also:
- bind.ts: use the already-matched parts instead of matching the anchor twice
- expand.ts: count wildcards from the string before interning, so a path over
the limit is not left in the permanent PathInfo cache (D10)
- IStateElement.recursionRegistry is typed via `import type`; the `unknown`
casts and the hand-written structural type in pathDiagnostics are gone
- "$setAll does not commit the baseline" was stated in set-all-design §6-2,
walk.ts and stateListBaseline.ts but the recursive $setAll commits; the
three places now say which form commits and why
- impl plan §1-2: the top-level omitted form throws (README/tests were right,
the table was stale); X5 row moved into the §3-3 table; X10 (getter cache
survives setInitialState) recorded; §7-3 review record added
- design doc §0 heading no longer says the gates are undecided
Characterization tests for two pre-existing defects found by the review
(neither recursion-specific): replacing row objects while keeping their
`children` arrays leaves that row's aggregate stale (X2), and a re-set does
not invalidate cached wildcard-free getters (X10). Plus the bound form read
from a row event handler, which the README promises and which works.
state 3188 / vscode-wcs 800 / lint smoke 17 green; packages/lint/dist/cli.cjs
rebuilt from the validator core.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Stacked on feat/state-recursive-path (post-review). What the review asked for before a release: - CHANGELOG [Unreleased]: the recursive-path feature (Added), the two new diagnostics, the state-owned list-diff baseline, the SSR snapshot change (own enumerable getters are no longer evaluated — an observable change for snapshot consumers), and $recursion joining the root-only declarations. - packages/vscode-wcs/CHANGELOG.md: an unreleased section for the static $recursion / `**` validation and the depth-folding path existence check. - packages/state/README(.ja): under "The input has to be a tree", the known limitation that replacing row objects while keeping their `children` arrays leaves that row's aggregate stale (list-identity defect X2, shared by hand-written multi-level getters), with the three shapes that do work; the structural-write rows now mention the multi-segment repeat case that the review fixed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
X2 (row replacement keeps the child ledger under the old row) -> #256, X5 (silent hang on an invalid first-mount declaration) -> #257, X6 / X7 / X10 (re-set and hydration keep the previous generation) -> #258, in the impl plan X tables, the README known limitation (en/ja) and the CHANGELOG entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the gated registry reads CI (#259) failed on the global branch threshold: 98.47% < 98.5%. The three `recursionRegistry ?? raiseError(...)` reads added by the review follow-up (bind.ts, getAllRecursive.ts, setAllRecursive.ts) are each a branch that no test can take — every caller is gated on `hasRecursion === true`, so the registry is always there. A non-null assertion says the same thing without an unreachable branch, which is how walkDependency already spells the same invariant (`address.listIndex!`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Docs only, stacked on #259 — merge after it. Until #259 lands this diff shows its commits too; afterwards it is the three docs commits below.
What the post-landing review asked for before a release:
CHANGELOG.md[Unreleased]: the recursive-path feature, the two new diagnostics (wcs/getter-depth-exceeded,wcs/index-param-range), the state-owned list-diff baseline, the SSR snapshot change (own enumerable getters are no longer evaluated — observable for snapshot consumers), and$recursionjoining the root-only declarations.packages/vscode-wcs/CHANGELOG.md: an unreleased section for the static$recursion/**validation and the depth-folding path existence check.packages/state/README.md/README.ja.md: under "The input has to be a tree", the known limitation that replacing row objects while keeping theirchildrenarrays leaves that row's aggregate stale (state: replacing row objects while keeping their children arrays leaves that row's aggregate stale (X2) #256), with the three shapes that do work; the structural-write rows mention the multi-segment repeat case the review fixed.No code or test changes;
packages/stateandpackages/vscode-wcssuites are unchanged from #259.🤖 Generated with Claude Code