@wcstack/state 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -114,6 +114,7 @@
114
114
  - **event token** — command token の双対。wc-bindable 要素が dispatch するイベントを `eventToken.<prop>: tokenName` + `$on` マップで state が受信
115
115
  - **stream** — `$streams` 宣言で連続的な非同期フロー(async iterable / `ReadableStream`)を fold して reactive プロパティ化。switchMap 型の依存駆動 restart 付き
116
116
  - **パス getter** — ドットパスキー getter(`get "users.*.fullName"()`)によるデータツリーの任意の深さへのフラットな仮想プロパティ定義、自動依存追跡・キャッシュ
117
+ - **再帰パス** — `$recursion: { "nodes.*": "children.*" }` で木の形が繰り返す場所を宣言し、1 本の `**` getter(`get "nodes.**.total"()`)が全深さを覆う。`$getAll(path, [])` は全深さを合併し、`$setAll(path, [], value)` は全深さへブロードキャストする
117
118
  - **Mustache 構文** — テキストノードでの `{{ path|filter }}`
118
119
  - **複数の状態ソース** — JSON, JS モジュール, インラインスクリプト, API, 属性
119
120
  - **SVG サポート** — `<svg>` 要素内でのフルバインディング対応
@@ -1102,6 +1103,207 @@ export default {
1102
1103
  };
1103
1104
  ```
1104
1105
 
1106
+ ## 再帰パス(`$recursion`)
1107
+
1108
+ パスは深さを文字列に焼き付けます。`nodes.*.children.*.total` はワイルドカードちょうど 2 段のパスであり、木が 1 段深くなっても 3 段には伸びません。しかし木の深さはコードではなく**データの性質**です。`$recursion` はこの隔たりを埋めます。形が繰り返す場所を宣言し、あとは「いま何段目であれ」を `**` と書きます。
1109
+
1110
+ ```javascript
1111
+ export default {
1112
+ $recursion: { "nodes.*": "children.*" }, // アンカー → 反復サブパス
1113
+
1114
+ nodes: [
1115
+ { value: 1, selected: false, children: [
1116
+ { value: 10, selected: false, children: [
1117
+ { value: 100, selected: false, children: [] }
1118
+ ]},
1119
+ { value: 20, selected: false, children: [] }
1120
+ ]},
1121
+ { value: 2, selected: false, children: [] }
1122
+ ],
1123
+
1124
+ // getter は 1 本で全深さぶん。`**` は評価されている深さに束縛される
1125
+ get "nodes.**.total"() {
1126
+ return this["nodes.**.value"]
1127
+ + this.$getAll("nodes.**.children.*.total").reduce((a, b) => a + b, 0);
1128
+ },
1129
+
1130
+ // 木全体の集計。`[]` は全深さの合併
1131
+ get treeTotal() {
1132
+ return this.$getAll("nodes.**.value", []).reduce((a, b) => a + b, 0);
1133
+ },
1134
+
1135
+ clearSelection() {
1136
+ this.$setAll("nodes.**.selected", [], false);
1137
+ }
1138
+ };
1139
+ ```
1140
+
1141
+ この森の total は `131 / 110 / 100 / 20 / 2`、`treeTotal` は `133` になります。
1142
+
1143
+ **`**` はオーサリング層だけの記号で、エンジンには決して降りません。** 具体パス(`nodes.*.children.*.total`)を読んだ時点で、その深さの getter が遅延実体化されます(実際に触れた深さのぶんだけアクセサが生えます)。その先 —— `PathInfo`、依存グラフ、`$1`…`$n`、`$resolve`、リスト差分 —— が見るのは、いつもどおりワイルドカード本数が固定された普通のパスです。リアクティブの中核は新しい形を覚えていません。
1144
+
1145
+ ### 再帰点を宣言する
1146
+
1147
+ `$recursion` は 1 つの**アンカー**を、1 段深くする**反復サブパス**へ対応づけます。どちらも「固定プロパティ列 + 末尾の `.*`」の形で、リストそのものではなくリストの**要素**を名指します:
1148
+
1149
+ ```javascript
1150
+ $recursion: { "nodes.*": "children.*" } // nodes[i].children[j].children[k]…
1151
+ $recursion: { "data.tree.*": "kids.*" } // 深い位置のアンカーも可
1152
+ $recursion: { "nodes.*": "nodes.*" } // 自己相似な綴りも可
1153
+ ```
1154
+
1155
+ `**` に意味を与えるのはこの宣言だけです。`$recursion` の無い state では `**` はパスの文字ですらなく(`wcs/recursion-unsupported`)、この記法が子孫検索へ黙って滑り落ちることはありません。このバージョンが受け付けるのは **state ごとに単一の自己再帰アンカー**です。アンカーの途中のワイルドカード、2 つ目のエントリ、2 つのアンカー間の相互再帰、1 本のパスに 2 つ目の `**`、`get "nodes.**"`(これはノード自身であって、ノード配下の計算パスではありません)、接尾辞が構造そのものを名指す `**` getter(`get "nodes.**.children"()` / `.children.*` / `.children.length` —— 全深さで実データの子リストを影にしてしまいます)、同じ具体パスへ展開される 2 本の `**` getter、そして再帰 **setter** は、宣言を読んだ時点で拒否します —— 別の意味に解釈することはありません。
1156
+
1157
+ 宣言が定義する族は無限ですが、state に生えるのは実際に要求された深さだけです:
1158
+
1159
+ ```
1160
+ k=0 nodes.*
1161
+ k=1 nodes.*.children.*
1162
+ k=2 nodes.*.children.*.children.*
1163
+ ```
1164
+
1165
+ ### `**` はどこで何を意味するか
1166
+
1167
+ `**` は深さを表す変数で、**束縛**されるか**合併**されるかは文脈が決めます。これは新しい規則ではなく、`*` が既に持っている「現在行」と「全行」の書き分けをそのまま継いだものです:
1168
+
1169
+ | `**` が現れる場所 | 意味 |
1170
+ |---|---|
1171
+ | getter のキー(`get "nodes.**.total"()`) | 評価されている深さに束縛 |
1172
+ | その getter 本体でのパス読み(`this["nodes.**.value"]`) | 同じ深さに束縛 |
1173
+ | `$getAll(path)`(添字**省略**) | その深さに束縛。展開されるのは `**` より**後ろ**のワイルドカードだけ |
1174
+ | `$getAll(path, [])`(**明示**) | **全深さの合併** —— 深さ優先・行きがけ・添字昇順 |
1175
+ | `$getAll(path, [i, …])` | 拒否。接頭辞ではどの深さの話か言えない(`wcs/recursion-getall-form`) |
1176
+ | `$setAll(path, [], value)` | 全深さへのブロードキャスト(合併と同じ走査・同じ順序) |
1177
+ | `$resolve` / `$postUpdate` / `$trackDependency` / `$watch` のキー / `$listKeys` のキー / markup の `data-wcs` / 直接代入 | 拒否(`wcs/recursion-unsupported`) |
1178
+
1179
+ 束縛形は束縛先の深さを必要とするので、**再帰 getter の中**でしか解決できません(アンカー配下の普通の行 getter や、その行に紐づくイベントハンドラも同じく実体の `ListIndex` を持つので使えます)。トップレベルで `this["nodes.**.value"]` を読むと `wcs/recursion-context` になります —— どのノードのつもりだったかを黙って推測することはありません。深さは行の添字と同じく**最も内側の評価フレームだけ**から読みます。再帰 getter が呼ぶ普通の getter(`get "nodes.**.x"() { return this.helper }` と `get helper() { return this["nodes.**.value"] }`)は自分の行を持たないので、これも `wcs/recursion-context` になります —— `**` は再帰 getter の側で読み、値を渡してください。合併形は深さを要求しないので、トップレベルの getter でも普通の行 getter でもメソッドでも読めます。
1180
+
1181
+ ```javascript
1182
+ this.$getAll("nodes.**.value", []); // [1, 10, 100, 20, 2] —— 深さ優先・行きがけ
1183
+ ```
1184
+
1185
+ `**` より**後ろ**の `*` は、各ノードで固定本数のパスと同じ順に展開し、そのノードの分を出し切ってから子へ降ります。上の木のノードに `tags` があるとき(`1` → `[3, 4]`、`10` → `[5]`、`20` → `[7]`、他は空):
1186
+
1187
+ ```javascript
1188
+ this.$getAll("nodes.**.tags.*.v", []); // [3, 4, 5, 7] —— ノード 1 の tags、次にノード 10 の、次にノード 20 の
1189
+ ```
1190
+
1191
+ ### 孫を二重に数えない集計
1192
+
1193
+ 木を畳むのは再帰 getter なので、この書き分けが集計の成否そのものになります:
1194
+
1195
+ ```javascript
1196
+ // ✅ 省略 —— この深さに束縛されるので、直下の子だけを合計する
1197
+ get "nodes.**.total"() {
1198
+ return this["nodes.**.value"]
1199
+ + this.$getAll("nodes.**.children.*.total").reduce((a, b) => a + b, 0);
1200
+ }
1201
+
1202
+ // ❌ `[]` —— 全深さの子 total。各ノードの total が自分の子孫の total を再び含み、
1203
+ // getter が自分自身を要求することになる。実際には誤った値ではなく
1204
+ // `wcs/getter-cycle` になる。
1205
+ get "nodes.**.total"() {
1206
+ return this["nodes.**.value"]
1207
+ + this.$getAll("nodes.**.children.*.total", []).reduce((a, b) => a + b, 0);
1208
+ }
1209
+ ```
1210
+
1211
+ 同じ間違いを再帰の**外側**でやると、こちらは静かです。踏む循環が無く、もっともらしい大きすぎる値が返るだけになります。集計値の合併は、孫を「親の total の内訳」として 1 回、「合併の要素」としてもう 1 回数えます:
1212
+
1213
+ ```javascript
1214
+ // ❌ 363 —— 全ノードの total を合併しているが、total は既に部分木を含んでいる
1215
+ get treeTotalWrong() {
1216
+ return this.$getAll("nodes.**.total", []).reduce((a, b) => a + b, 0);
1217
+ }
1218
+ // ✅ 133 —— 生の値を合併する
1219
+ get treeTotal() {
1220
+ return this.$getAll("nodes.**.value", []).reduce((a, b) => a + b, 0);
1221
+ }
1222
+ // ✅ 133 —— あるいはルートだけを足す(各ルートの total が既に部分木を畳んでいる)
1223
+ get treeTotalFromRoots() {
1224
+ return this.$getAll("nodes.*.total", []).reduce((a, b) => a + b, 0);
1225
+ }
1226
+ ```
1227
+
1228
+ **合併するのは生の値か、さもなくばルートだけを足すこと。自分の部分木を既に集計している値を合併してはいけません。** 二重計上かどうかはパス文字列からは決定できないので、これを捕まえると約束する診断はありません。
1229
+
1230
+ ### 書き込みはブロードキャストのみ
1231
+
1232
+ 再帰 `$setAll` が受け付けるのは `[]` + 素の値という 1 つの形だけで、戻り値は書き込んだアドレスの件数です(上の森なら 5):
1233
+
1234
+ ```javascript
1235
+ this.$setAll("nodes.**.selected", [], false); // 全深さの全ノード
1236
+ ```
1237
+
1238
+ それ以外の形は、走査が 1 件でも書く**前に**拒否します。拒否された呼び出しは木を一切変更しません。この保証は**形**の検査についてのもので、添字綴りの葉(`nodes.**.children.0.value`)は**データ**の条件で途中で止まることがあります —— `children` が空のノードには書き込む子 `0` が無いためで、固定本数の `$setAll("nodes.*.children.0.value", [], v)` と同じ振る舞いです:
1239
+
1240
+ | 形 | 拒否する理由 |
1241
+ |---|---|
1242
+ | 非空の接頭辞 | 接頭辞ではどの深さに適用されるのか言えない(`wcs/recursion-setall-form`) |
1243
+ | 添字の省略 | 書き込み API は文脈を取らないので束縛する深さが無い —— `[]` を渡す |
1244
+ | mapper 関数 | `(current, ...indexes)` の添字の本数が深さごとに変わる |
1245
+ | `{ spread: true }` | 1 次元配列を木へ配るには作者が走査順を知っている必要があり、契約として使えない |
1246
+ | `nodes.**` / `nodes.**.children` / `nodes.**.children.*` / `nodes.**.children.length` —— 反復サブパスが多段(`branch.children.*`)なら、子リストへ至る途中の `nodes.**.branch` も。添字綴りも同じ形に畳まれる(`nodes.**.children.0` は子ノード、`nodes.**.children.0.total` は getter) | 構造そのものへの書き込み(`length` への代入はリストを切り詰める)は、その書き込み自身が確定済みの子アドレスを壊す(`wcs/recursion-structural-write`) |
1247
+ | `nodes.**.total`、およびその値の内側を指すパス | 再帰 getter に setter は無い。導出元を書く(`wcs/recursion-readonly`) |
1248
+
1249
+ 読み取り専用の規則は `**` の綴りに依存しません。再帰 getter の具体的な展開形 —— `nodes.*.total` / `nodes.*.children.*.total` / … —— への書き込みも、固定本数の `$setAll` でも値付きの `$resolve(path, indexes, value)` でも直接代入でも、またその深さが実体化済みかどうかに関わらず、書き込みの入口で拒否します。この検査が無かったときは、未実体化の展開形が「無いキー」に見えてノードのオブジェクトに書き込まれ、代入値が getter のキャッシュ結果として固定されていました。
1250
+
1251
+ ### 入力は木でなければならない
1252
+
1253
+ 走査は深さ方向に降りながら、必要な形をその場で検査します。**同じ配列インスタンス**に 2 度到達したら拒否します。その配列が現在のノードの祖先のものなら循環(`wcs/recursion-cycle`)、そうでなければ 2 つのノードが 1 本の子リストを共有しています(`wcs/recursion-shared-list`)。各ノードに自分の `children` 配列を持たせてください —— **空**配列の使い回しは行を持たず別名化のしようがないので、追跡もせず正当です。
1254
+
1255
+ **走査は受け付けるのにエンジンがまだ追えない形が 1 つあります —— 行オブジェクトを作り直して `children` 配列を引き継ぐ置換です。** `this.nodes = this.nodes.map(n => ({ ...n }))` の後も子リストの台帳は配列をキーにしたままなので、その行は**旧**行オブジェクトに結び付いたままになります。その行の集計を一度読んだあとに、その下の葉を更新すると、その行の `nodes.*.total` だけが古いまま残ります —— 葉・より深い集計・すべての `[]` 合併は正しいので、何も警告は出ません。行は path 経由で in-place に書く(`$resolve` / `$setAll`)、行オブジェクトを引き継ぐ(`[...this.nodes]`)、部分木ごと置き換える(深いクローン)のいずれかにすると集計が追従します。これは `**` ではなくリストの同一性の制限で、手書きの `nodes.*.total` / `nodes.*.children.*.total` getter でも同じ挙動になります([#256](https://github.com/wcstack/wcstack/issues/256))。
1256
+
1257
+ 上限は展開後のパスの**ワイルドカード 128 段**です。上の集計 getter は評価中のノードより 1 段下を読むので、127 段の鎖までは畳めて、128 段で `wcs/recursion-depth-exceeded` になります(アンカー・到達した深さ・組み立てようとしたパス・上限を名指しします)。この検査は getter 評価スタック自身の 128 段の上限(`wcs/getter-depth-exceeded`)より先に効くので、深い木は「深い」と報告され、循環の疑いを掛けられることはありません。途中で打ち切ることもしません —— 部分的な集計は、誤った値を正しい値として返すことだからです。
1258
+
1259
+ ### 木を描画する
1260
+
1261
+ `**` は markup には書けず、再帰 `<template>` もありません。木は**自己参照コンポーネント**で描画します —— 子ごとに自分自身を shadow の中でマウントするカスタム要素 1 つです。各スコープの中で使うパスは常に 1 段だけ(`node.children.*`)なので markup が深さに依存せず、`node.total` はマウントを通ってルート state の再帰 getter に解決されるので、各ノードが自分の部分木の集計を表示できます。
1262
+
1263
+ ```html
1264
+ <!-- ホスト側 -->
1265
+ <template data-wcs="for: nodes">
1266
+ <tree-node data-wcs="state.node: nodes.*"></tree-node>
1267
+ </template>
1268
+ ```
1269
+
1270
+ ```javascript
1271
+ const markup = `
1272
+ <wcs-state bind-component="state"></wcs-state>
1273
+ <span data-wcs="textContent: node.label"></span>
1274
+ <span data-wcs="textContent: node.total"></span>
1275
+ <template data-wcs="for: node.children">
1276
+ <tree-node data-wcs="state.node: node.children.*"></tree-node>
1277
+ </template>`;
1278
+
1279
+ customElements.define("tree-node", class extends HTMLElement {
1280
+ state = {}; // ← `node` を自分で持たない(マウントから届く)
1281
+ constructor() { super(); this.attachShadow({ mode: "open" }); }
1282
+ connectedCallback() { // ← shadow は constructor ではなくここで組む
1283
+ if (this.shadowRoot.childNodes.length === 0) this.shadowRoot.innerHTML = markup;
1284
+ }
1285
+ });
1286
+ ```
1287
+
1288
+ ここには 2 つの罠があり、どちらも実際に踏んだものです:
1289
+
1290
+ - **コンポーネントの `state` に、マウント先と同名のキーを置かないこと。** 無関係なメソッドや私有キーは構いませんが、自分の `node` を持つとマウントを隠し、子は自分の既定値を表示したまま一段も降りません(実行時に `wcs/mount-own-key-shadow` で名指されます)。
1291
+ - **shadow は constructor ではなく `connectedCallback` で組むこと。** constructor で `innerHTML` を入れると、`<template>` の中身を inert に保たない実装ではその中の要素まで upgrade され、自己参照コンポーネントは自分の constructor の中で無限再帰します。実ブラウザは通ってしまうので、素直なクラッシュではなく環境依存の地雷になります。
1292
+
1293
+ 深さが固定なら、ここまでは要りません。展開後のパスは普通のパスなので、入れ子の `for` テンプレートから `nodes.*.total` や `nodes.*.children.*.total` を他と同じようにバインドできます。
1294
+
1295
+ ### このバージョンに含まれないもの
1296
+
1297
+ 以下はいずれも診断になります。黙って別の意味に解釈されることはありません。
1298
+
1299
+ - 複数アンカー、相互再帰、アンカー途中のワイルドカード、1 本のパスに 2 つ目の `**`
1300
+ - 再帰 setter、接尾辞が構造そのものを名指す `**` getter(`get "nodes.**.children"()`)、`**` getter の展開形と同名の具体 getter、そして代入による `**` 経由の書き込み(`this["nodes.**.x"] = v`、`++` も含む)
1301
+ - 再帰 `$setAll` の mapper・`{ spread: true }`・添字省略・非空の接頭辞・配列でない `indexes`。書き込み API には深さを束縛する評価文脈が無いので、`[]` は必須です
1302
+ - 再帰 `$getAll` の非空の接頭辞・配列でない `indexes`。**添字省略は正当です** —— 再帰 getter の中では束縛形で、評価中の深さを読みます
1303
+ - `data-wcs` / `$watch` や `$listKeys` のキー / `$resolve` / `$postUpdate` / `$trackDependency` への `**`
1304
+ - ボリューム(`mount=`)やマウントされたコンポーネント(`bind-component`)の `$recursion` と `**` getter —— ルートの state に置きます
1305
+ - 再帰 `<template>`、`$depth` 変数、公開の `maxDepth` オプション(3 つとも存在しません)
1306
+
1105
1307
  ## イベントハンドリング
1106
1308
 
1107
1309
  `on*` プロパティでイベントハンドラをバインドします:
@@ -2261,13 +2463,26 @@ dropped. Validate statically: npx @wcstack/lint <file>.
2261
2463
 
2262
2464
  ### 添字の本数・階数・循環も検査されます
2263
2465
 
2264
- パス文字列から機械的に決まる整合は、実行時にも lint にも同じ診断 code で現れます。
2466
+ パス文字列から機械的に決まる整合は、実行時にも lint にも同じ診断 code で現れます。ただし下表の 6 つ —— `wcs/getter-depth-exceeded` / `wcs/index-param-range` / `wcs/recursion-context` / `wcs/recursion-shared-list` / `wcs/recursion-cycle` / `wcs/recursion-depth-exceeded` —— はこのリリースでは**実行時専用**で、lint は出しません。
2265
2467
 
2266
2468
  | 診断 | 何を見るか | 直し方 |
2267
2469
  |---|---|---|
2268
2470
  | `wcs/index-arity` | `$resolve(path, indexes)` は `*` の本数と**厳密一致**、`$getAll(path, indexes)` / `$setAll(path, indexes, …)` は**上限**(不足は「残りの階層を全展開」という正当な接頭辞) | 本数を合わせる |
2269
2471
  | `wcs/wildcard-rank` | パスの `*` の本数(と `$N` の N)が、囲む `for` の段数を超えていないか | `for` を足すか、`$resolve(path, indexes)` で行を明示する |
2270
- | `wcs/getter-cycle` | パス getter どうしが循環参照していないか | 循環を断つ |
2472
+ | `wcs/getter-cycle` | パス getter どうしが循環参照していないか。実行時は「アドレススタックが既に積んでいるアドレスへ戻る」ことで判定する | 循環を断つ |
2473
+ | `wcs/getter-depth-exceeded` | getter の評価が 1 パスで評価できる深さ(128 段)を超え、かつ同じアドレスを 2 度通っていない = データが単に深い | 集計の段数を減らすか、木を平らにする |
2474
+ | `wcs/index-param-range` | `$N` は実在するワイルドカード段を指すこと(`$1`〜`$128`・先頭ゼロ不可) | 実在する段を使う |
2475
+ | `wcs/recursion-unsupported` | `**` を解釈しない場所へ `**` が渡った —— markup・`$watch` / `$listKeys` のキー・`$resolve` / `$postUpdate` / `$trackDependency`・代入、あるいは state が `$recursion` を宣言していない | 具体パスを使うか、アンカーを宣言する |
2476
+ | `wcs/recursion-declaration-invalid` | `$recursion` 宣言か `**` getter のキーが、このバージョンが受け付けない形 —— 要素を指さない・途中に添字セグメントを持つ(`"nodes.0.items.*"`)アンカー / 反復サブパス、複数アンカー、getter でない・setter を持つ `**` キー、`get "nodes.**"`、構造を名指す getter、同じ具体パスへ展開する 2 本の getter、展開形と同名の具体 getter。lint が先に出し、実行時は宣言を読んだ時点で throw する | 文面のとおり宣言を直す |
2477
+ | `wcs/recursion-anchor` | 宣言済みのアンカーと合致しない `**` パス(このバージョンは state ごとに単一の自己再帰アンカー)、または `**` の後ろが整形されていない —— 空セグメント(`nodes.**.` / `nodes.**..x`)や `**` 直後の素の `*`(`nodes.**.*`) | 宣言どおりに綴り、その後ろに実在するパスを書く |
2478
+ | `wcs/recursion-context` | **束縛**形の `**` を、束縛先の深さが無い場所で読んだ —— トップレベル、またはアンカー外の getter | 再帰 getter か行 getter の中から読むか、`[]` で全深さを合併する |
2479
+ | `wcs/recursion-getall-form` / `wcs/recursion-setall-form` | `**` に対して定義できない `indexes` の形。コードが付くのは非空の接頭辞(両 API)と、`$getAll` の配列でない `indexes`(`null`・文字列など)。`$setAll` の省略・mapper・`{ spread: true }` も同じ誤りで、lint は同じコードで報告するが、実行時は形を名指しした文面で throw するだけでコードは付かない | 現在の深さなら省略、全深さなら `[]` |
2480
+ | `wcs/recursion-structural-write` | 再帰 `$setAll` が構造そのもの(ノード・子リスト・その `length`・子ノード・反復サブパスが多段なら子リストへ至る途中のオブジェクト)を指している | 葉のプロパティへブロードキャストする |
2481
+ | `wcs/recursion-readonly` | 書き込みが再帰 getter、またはその導出値の内側を指している —— 再帰 `$setAll` の `nodes.**.total` でも、`nodes.*.children.*.total` のような具体的な展開形への書き込み(固定本数の `$setAll`・値付き `$resolve`・直接代入)でも | getter の導出元を書く |
2482
+ | `wcs/recursion-shared-list` / `wcs/recursion-cycle` | 走査が同じ配列インスタンスに 2 度到達した —— 2 つのノードが 1 本の子リストを共有、または自分の祖先から到達可能 | 各ノードに自分の子配列を持たせる |
2483
+ | `wcs/recursion-depth-exceeded` | 展開後のパスがワイルドカード 128 段を超える —— 木がエンジンのアドレス可能な深さより深いか、循環している | 木を平らにするか、循環を探す |
2484
+
2485
+ `wcs/recursion-*` の各行がどの形を拒否していて、代わりに何を書けばよいのかは、上の**再帰パス**の節に書いてあります。
2271
2486
 
2272
2487
  `$resolve` / `$getAll` の**添字の超過は以前は黙って捨てられ**、取り違えたまま「もっともらしい値」が返っていました。現在はどちらもエラーです:
2273
2488
 
package/README.md CHANGED
@@ -114,6 +114,7 @@ That's it. No build, no bootstrap code, no framework.
114
114
  - **Event tokens** — the dual of command tokens: receive a wc-bindable element's dispatched events in state via `eventToken.<prop>: tokenName` + the `$on` map
115
115
  - **Streams** — fold continuous async flows (async iterables / `ReadableStream`) into reactive properties via the `$streams` declaration, with switchMap-style dependency-driven restart
116
116
  - **Path getters** — dot-path key getters (`get "users.*.fullName"()`) for virtual properties at any depth in a data tree, all defined flat in one place with automatic dependency tracking and caching
117
+ - **Recursive paths** — `$recursion: { "nodes.*": "children.*" }` declares where a tree's shape repeats, and one `**` getter (`get "nodes.**.total"()`) covers every depth; `$getAll(path, [])` unions all depths and `$setAll(path, [], value)` broadcasts to all of them
117
118
  - **Mustache syntax** — `{{ path|filter }}` in text nodes
118
119
  - **Multiple state sources** — JSON, JS module, inline script, API, attribute
119
120
  - **SVG support** — full binding support inside `<svg>` elements
@@ -1103,6 +1104,207 @@ export default {
1103
1104
  };
1104
1105
  ```
1105
1106
 
1107
+ ## Recursive Paths (`$recursion`)
1108
+
1109
+ A path burns its depth into the string. `nodes.*.children.*.total` has exactly two wildcard levels, and nothing about it stretches to three when the tree grows a level — but a tree's depth belongs to the data, not to the code. `$recursion` closes that gap: declare where the shape repeats, then write `**` for "however deep this is".
1110
+
1111
+ ```javascript
1112
+ export default {
1113
+ $recursion: { "nodes.*": "children.*" }, // anchor → repeating sub-path
1114
+
1115
+ nodes: [
1116
+ { value: 1, selected: false, children: [
1117
+ { value: 10, selected: false, children: [
1118
+ { value: 100, selected: false, children: [] }
1119
+ ]},
1120
+ { value: 20, selected: false, children: [] }
1121
+ ]},
1122
+ { value: 2, selected: false, children: [] }
1123
+ ],
1124
+
1125
+ // One getter, every depth: `**` is bound to the depth being evaluated
1126
+ get "nodes.**.total"() {
1127
+ return this["nodes.**.value"]
1128
+ + this.$getAll("nodes.**.children.*.total").reduce((a, b) => a + b, 0);
1129
+ },
1130
+
1131
+ // Whole-tree aggregate: `[]` unions every depth
1132
+ get treeTotal() {
1133
+ return this.$getAll("nodes.**.value", []).reduce((a, b) => a + b, 0);
1134
+ },
1135
+
1136
+ clearSelection() {
1137
+ this.$setAll("nodes.**.selected", [], false);
1138
+ }
1139
+ };
1140
+ ```
1141
+
1142
+ For that forest the totals are `131 / 110 / 100 / 20 / 2` and `treeTotal` is `133`.
1143
+
1144
+ **`**` is authoring notation only — it never reaches the engine.** Reading a concrete path (`nodes.*.children.*.total`) materializes the getter for *that* depth on demand, one accessor per depth you actually touch, and everything downstream — `PathInfo`, the dependency graph, `$1`…`$n`, `$resolve`, the list diff — still sees an ordinary fixed-arity path. The reactive core did not learn a new shape.
1145
+
1146
+ ### Declaring the recursion point
1147
+
1148
+ `$recursion` maps one **anchor** to the **repeating sub-path** that descends one level. Both name the *element* of a list — a fixed property chain ending in `.*`, never the list itself:
1149
+
1150
+ ```javascript
1151
+ $recursion: { "nodes.*": "children.*" } // nodes[i].children[j].children[k]…
1152
+ $recursion: { "data.tree.*": "kids.*" } // a deeper anchor is fine
1153
+ $recursion: { "nodes.*": "nodes.*" } // self-similar spelling is fine too
1154
+ ```
1155
+
1156
+ The declaration is what gives `**` a meaning at all: with no `$recursion` on the state, `**` is not a path character (`wcs/recursion-unsupported`), so the notation can never quietly slide into a descendant search. This version accepts **exactly one self-recursive anchor per state**. A wildcard in the middle of an anchor, a second entry, mutual recursion between two anchors, a second `**` in one path, `get "nodes.**"` (that names the node itself, not a computed path under it), a `**` getter whose suffix names the structure (`get "nodes.**.children"()`, `.children.*`, `.children.length` — it would hide the real child list at every depth), two `**` getters that expand to the same concrete path, and recursive *setters* are all rejected when the declaration is read — never reinterpreted.
1157
+
1158
+ The family a declaration defines is infinite, and the state only ever grows the depths it is asked for:
1159
+
1160
+ ```
1161
+ k=0 nodes.*
1162
+ k=1 nodes.*.children.*
1163
+ k=2 nodes.*.children.*.children.*
1164
+ ```
1165
+
1166
+ ### What `**` means where
1167
+
1168
+ `**` is a variable over depth, and whether it is *bound* or *unioned* is decided by context — the same split `*` already has between "the current row" and "every row":
1169
+
1170
+ | Where `**` appears | What it means |
1171
+ |---|---|
1172
+ | A getter key — `get "nodes.**.total"()` | Bound to the depth being evaluated |
1173
+ | A path read inside that getter — `this["nodes.**.value"]` | Bound to the same depth |
1174
+ | `$getAll(path)`, indexes **omitted** | Bound to that depth; only the wildcards *after* `**` expand |
1175
+ | `$getAll(path, [])`, **explicit** | **Union of every depth** — depth-first, pre-order, ascending index |
1176
+ | `$getAll(path, [i, …])` | Rejected: a prefix cannot say which depth it applies to (`wcs/recursion-getall-form`) |
1177
+ | `$setAll(path, [], value)` | Broadcast to every depth, in that same order |
1178
+ | `$resolve`, `$postUpdate`, `$trackDependency`, `$watch` keys, `$listKeys` keys, `data-wcs` in markup, direct assignment | Rejected (`wcs/recursion-unsupported`) |
1179
+
1180
+ The bound forms need a depth to bind to, so they only resolve **inside** a recursive getter — or inside an ordinary row getter under the anchor, or an event handler bound to such a row — each of those carries a real `ListIndex` to read the depth from. Read `this["nodes.**.value"]` from the top level and you get `wcs/recursion-context`, not a silent guess at which node you meant. The depth is read from the innermost evaluation frame only, the same frame the row index comes from: a plain getter that a recursive getter calls (`get "nodes.**.x"() { return this.helper }` with `get helper() { return this["nodes.**.value"] }`) has no row of its own and gets `wcs/recursion-context` too — read `**` in the recursive getter and pass the value on. The union form needs no depth, so it can be read from anywhere: a top-level getter, a plain row getter, a method.
1181
+
1182
+ ```javascript
1183
+ this.$getAll("nodes.**.value", []); // [1, 10, 100, 20, 2] — depth-first, pre-order
1184
+ ```
1185
+
1186
+ Wildcards *after* `**` expand at each node in the ordinary fixed-arity order, and the walk finishes them before descending to that node's children. With `tags` on the nodes above (`1` → `[3, 4]`, `10` → `[5]`, `20` → `[7]`, the rest empty):
1187
+
1188
+ ```javascript
1189
+ this.$getAll("nodes.**.tags.*.v", []); // [3, 4, 5, 7] — node 1's tags, then node 10's, then node 20's
1190
+ ```
1191
+
1192
+ ### Aggregating without counting grandchildren twice
1193
+
1194
+ That split is the whole game for aggregation, because the recursive getter is what folds the tree:
1195
+
1196
+ ```javascript
1197
+ // ✅ Omitted — bound to this depth, so the sum walks only the direct children
1198
+ get "nodes.**.total"() {
1199
+ return this["nodes.**.value"]
1200
+ + this.$getAll("nodes.**.children.*.total").reduce((a, b) => a + b, 0);
1201
+ }
1202
+
1203
+ // ❌ `[]` — every child total at every depth. Each node's total would contain its
1204
+ // own descendants' totals again, and the getter ends up asking for itself: in
1205
+ // practice you do not get a wrong number, you get `wcs/getter-cycle`.
1206
+ get "nodes.**.total"() {
1207
+ return this["nodes.**.value"]
1208
+ + this.$getAll("nodes.**.children.*.total", []).reduce((a, b) => a + b, 0);
1209
+ }
1210
+ ```
1211
+
1212
+ The same mistake made from *outside* the recursion is the quiet one — there is no cycle to trip over, just a plausible number that is too big. A union of an aggregate counts every grandchild once inside its parent's total, and once more as an element of the union:
1213
+
1214
+ ```javascript
1215
+ // ❌ 363 — every node's total, and every total already contains its subtree
1216
+ get treeTotalWrong() {
1217
+ return this.$getAll("nodes.**.total", []).reduce((a, b) => a + b, 0);
1218
+ }
1219
+ // ✅ 133 — union the raw leaf values
1220
+ get treeTotal() {
1221
+ return this.$getAll("nodes.**.value", []).reduce((a, b) => a + b, 0);
1222
+ }
1223
+ // ✅ 133 — or add up the roots, since each root total already folds its subtree
1224
+ get treeTotalFromRoots() {
1225
+ return this.$getAll("nodes.*.total", []).reduce((a, b) => a + b, 0);
1226
+ }
1227
+ ```
1228
+
1229
+ **Union raw values, or sum the roots — never union something that already aggregates its own subtree.** Whether an aggregate double-counts is not decidable from the path string, so no diagnostic claims to catch this one.
1230
+
1231
+ ### Writing: broadcast only
1232
+
1233
+ `$setAll` accepts `**` in exactly one form — `[]` plus a plain value — and returns the number of addresses written (5 for the forest above):
1234
+
1235
+ ```javascript
1236
+ this.$setAll("nodes.**.selected", [], false); // every node, at every depth
1237
+ ```
1238
+
1239
+ Every other form is refused *before* the walk writes anything, so a rejected call leaves the tree untouched. That guarantee covers the checks on the *form*; a leaf under an index spelling (`nodes.**.children.0.value`) can still stop part-way on the *data* — a node whose `children` is empty has no child `0` to write into — exactly as the fixed-arity `$setAll("nodes.*.children.0.value", [], v)` does.
1240
+
1241
+ | Form | Why it is refused |
1242
+ |---|---|
1243
+ | a non-empty prefix | A prefix cannot say which depth it applies to (`wcs/recursion-setall-form`) |
1244
+ | omitted indexes | The write API takes no context, so there is no depth to bind to — pass `[]` |
1245
+ | a mapper function | `(current, ...indexes)` has a different arity at every depth |
1246
+ | `{ spread: true }` | Handing a flat array to a tree needs the author to know the walk order |
1247
+ | `nodes.**`, `nodes.**.children`, `nodes.**.children.*`, `nodes.**.children.length` — and, for a multi-segment repeat such as `branch.children.*`, the `nodes.**.branch` on the way to the list. Index spellings fold to the same forms: `nodes.**.children.0` is a child node, `nodes.**.children.0.total` is the getter | Writing the structure itself (assigning `length` truncates the list) invalidates the child addresses this very write already resolved (`wcs/recursion-structural-write`) |
1248
+ | `nodes.**.total`, or a path inside its value | A recursive getter has no setter — write what it derives from (`wcs/recursion-readonly`) |
1249
+
1250
+ The read-only rule does not depend on spelling `**`. A recursive getter's concrete expansions — `nodes.*.total`, `nodes.*.children.*.total`, … — are refused at the write entry as well, whether the write is a fixed-arity `$setAll`, a `$resolve(path, indexes, value)` or a direct assignment, and whether or not that depth has been materialized yet. Before this check, an unmaterialized expansion looked like a plain missing key and the write landed on the node object, pinning the assigned value as the getter's cached result.
1251
+
1252
+ ### The input has to be a tree
1253
+
1254
+ The walk descends by depth and checks the shape it needs as it goes: reaching the **same array instance** twice is refused. If that array belongs to one of the current node's ancestors it is a cycle (`wcs/recursion-cycle`); otherwise two nodes share one child list (`wcs/recursion-shared-list`). Give every node its own `children` array — sharing an *empty* one is fine and untracked, because it has no rows to alias.
1255
+
1256
+ **One shape the walk accepts but the engine cannot follow yet: replacing a row object while keeping its `children` array.** After `this.nodes = this.nodes.map(n => ({ ...n }))` the child list's ledger is still keyed by the array, so its rows stay attached to the *old* row object. Once that row's aggregate has been read, the next leaf update below it leaves that row's `nodes.*.total` stale — the leaf, the deeper totals and every `[]` union are still right, so nothing complains. Write rows in place through paths (`$resolve`, `$setAll`), keep the row objects (`[...this.nodes]`), or replace the whole subtree (a deep clone), and the aggregates follow. This is a limit of list identity, not of `**`: hand-written `nodes.*.total` / `nodes.*.children.*.total` getters behave the same way ([#256](https://github.com/wcstack/wcstack/issues/256)).
1257
+
1258
+ The ceiling is **128 wildcard levels** on the expanded path. The aggregate above reads one level below the node it is evaluating, so it folds a chain 127 deep and stops at 128 with `wcs/recursion-depth-exceeded`, naming the anchor, the depth reached, the path it was building, and the limit. That check trips before the getter stack's own 128-frame limit (`wcs/getter-depth-exceeded`), so a deep tree is reported as deep instead of being accused of a cycle. Nothing is truncated on the way: a partial aggregate would be a wrong number reported as a right one.
1259
+
1260
+ ### Rendering the tree
1261
+
1262
+ `**` cannot appear in markup and there is no recursive `<template>`. A tree is rendered by a **self-referential component** — one custom element whose shadow mounts itself for each child. Inside every scope only one level of path is ever used (`node.children.*`), so the markup does not depend on the depth, and `node.total` resolves through the mount onto the root state's recursive getter, so each node shows its own subtree's aggregate.
1263
+
1264
+ ```html
1265
+ <!-- host -->
1266
+ <template data-wcs="for: nodes">
1267
+ <tree-node data-wcs="state.node: nodes.*"></tree-node>
1268
+ </template>
1269
+ ```
1270
+
1271
+ ```javascript
1272
+ const markup = `
1273
+ <wcs-state bind-component="state"></wcs-state>
1274
+ <span data-wcs="textContent: node.label"></span>
1275
+ <span data-wcs="textContent: node.total"></span>
1276
+ <template data-wcs="for: node.children">
1277
+ <tree-node data-wcs="state.node: node.children.*"></tree-node>
1278
+ </template>`;
1279
+
1280
+ customElements.define("tree-node", class extends HTMLElement {
1281
+ state = {}; // ← no own `node` key — it arrives from the mount
1282
+ constructor() { super(); this.attachShadow({ mode: "open" }); }
1283
+ connectedCallback() { // ← build the shadow here, not in the constructor
1284
+ if (this.shadowRoot.childNodes.length === 0) this.shadowRoot.innerHTML = markup;
1285
+ }
1286
+ });
1287
+ ```
1288
+
1289
+ Two things bite here, and both were hit for real:
1290
+
1291
+ - **The component's `state` must not declare the key it is mounted over.** Unrelated methods and private keys are fine — a `node` of its own is not: it hides the mount, so the child shows its own default and never descends. The runtime names that one (`wcs/mount-own-key-shadow`).
1292
+ - **Build the shadow in `connectedCallback`, not in the constructor.** Assigning `innerHTML` in the constructor upgrades the elements inside `<template>` on implementations that do not keep template content inert, and a self-referential element then recurses forever in its own constructor. Real browsers survive it, which makes it an environment-dependent trap rather than an honest crash.
1293
+
1294
+ Fixed depths need none of this: the expanded paths are ordinary paths, so nested `for` templates bind `nodes.*.total` and `nodes.*.children.*.total` like anything else.
1295
+
1296
+ ### Not in this version
1297
+
1298
+ Each of these is a diagnostic, never a silent reinterpretation:
1299
+
1300
+ - More than one anchor, mutual recursion, a wildcard in the middle of an anchor, a second `**` in one path
1301
+ - Recursive setters, a `**` getter whose suffix names the structure (`get "nodes.**.children"()`), a concrete getter with the same name as a `**` getter's expansion, and writing through `**` by assignment (`this["nodes.**.x"] = v`, `++` included)
1302
+ - In a recursive `$setAll`: a mapper, `{ spread: true }`, omitted indexes, a non-empty prefix, or a non-array `indexes`. The write API has no evaluation context to bind a depth to, so `[]` is mandatory
1303
+ - In a recursive `$getAll`: a non-empty prefix, or a non-array `indexes`. **Omitting the indexes is valid** — inside a recursive getter it is the bound form, and it reads the depth being evaluated
1304
+ - `**` in `data-wcs`, in `$watch` or `$listKeys` keys, or in `$resolve` / `$postUpdate` / `$trackDependency`
1305
+ - `$recursion` and `**` getters in a volume (`mount=`) or a mounted component (`bind-component`) — declare them on the root state
1306
+ - A recursive `<template>`, a `$depth` variable, and a public `maxDepth` option — none of the three exist
1307
+
1106
1308
  ## Event Handling
1107
1309
 
1108
1310
  Bind event handlers with `on*` properties:
@@ -2265,13 +2467,26 @@ So **no warning is not a proof of correctness.** For exhaustive checking, run `n
2265
2467
 
2266
2468
  ### Index arity, wildcard rank, and getter cycles are checked too
2267
2469
 
2268
- Anything that follows mechanically from the path string is reported at runtime and by the linter under the same diagnostic code.
2470
+ Anything that follows mechanically from the path string is reported at runtime and by the linter under the same diagnostic code. Six of the codes below are **runtime-only** in this release — the linter does not emit them: `wcs/getter-depth-exceeded`, `wcs/index-param-range`, `wcs/recursion-context`, `wcs/recursion-shared-list`, `wcs/recursion-cycle` and `wcs/recursion-depth-exceeded`.
2269
2471
 
2270
2472
  | Diagnostic | What it checks | Fix |
2271
2473
  |---|---|---|
2272
2474
  | `wcs/index-arity` | `$resolve(path, indexes)` must match the `*` count **exactly**; `$getAll(path, indexes)` / `$setAll(path, indexes, …)` have it as an **upper bound** (fewer is a legitimate prefix meaning "expand the rest") | Match the count |
2273
2475
  | `wcs/wildcard-rank` | The path's `*` count (and the N in `$N`) must not exceed the enclosing `for` nesting | Add a `for`, or name the row with `$resolve(path, indexes)` |
2274
- | `wcs/getter-cycle` | Path getters must not form a dependency cycle | Break the cycle |
2476
+ | `wcs/getter-cycle` | Path getters must not form a dependency cycle. At runtime this is the address stack revisiting an address it already holds | Break the cycle |
2477
+ | `wcs/getter-depth-exceeded` | Getter evaluation nests deeper than the engine evaluates in one pass (128 frames), with no address visited twice — the data is simply that deep | Aggregate in fewer levels, or flatten the tree |
2478
+ | `wcs/index-param-range` | `$N` must name an existing wildcard level: `$1` through `$128`, no leading zeros | Use a level that exists |
2479
+ | `wcs/recursion-unsupported` | `**` reached something that does not interpret it — markup, a `$watch` or `$listKeys` key, `$resolve` / `$postUpdate` / `$trackDependency`, an assignment — or the state declares no `$recursion` at all | Use a concrete path, or declare the anchor |
2480
+ | `wcs/recursion-declaration-invalid` | The `$recursion` declaration or a `**` getter key has a shape this version refuses: an anchor or repeat that is not a list element or carries an index segment (`"nodes.0.items.*"`), more than one anchor, a `**` key that is not a getter or has a setter, `get "nodes.**"`, a getter that names the structure, two getters expanding to one concrete path, a concrete getter with the same name as an expansion. The linter reports it first; at runtime it throws when the declaration is read | Fix the declaration as the message says |
2481
+ | `wcs/recursion-anchor` | A `**` path that does not match the one declared anchor (this version takes exactly one self-recursive anchor per state), or whose suffix after `**` is not well-formed — an empty segment (`nodes.**.`, `nodes.**..x`) or a bare `*` right after `**` (`nodes.**.*`) | Spell the anchor as declared, and a real path after it |
2482
+ | `wcs/recursion-context` | A **bound** `**` was read where there is no depth to bind to — the top level, or a getter outside the anchor | Read it from a recursive or row getter, or pass `[]` to union every depth |
2483
+ | `wcs/recursion-getall-form` / `wcs/recursion-setall-form` | An `indexes` argument `**` cannot define. The code is carried by the non-empty prefix (both APIs) and by a non-array `indexes` on `$getAll` (`null`, a string…); omission, a mapper and `{ spread: true }` in a `$setAll` are the same mistake and the linter reports them under the same code, but at runtime they throw with the form named in the message and no code | Omit for the current depth, `[]` for every depth |
2484
+ | `wcs/recursion-structural-write` | A recursive `$setAll` targets the structure itself — a node, its child list, that list's `length`, a child node, or an object on the way to the child list when the repeating sub-path has several segments | Broadcast to a leaf property instead |
2485
+ | `wcs/recursion-readonly` | A write targets a recursive getter, or a path inside the value it derives — a recursive `$setAll` on `nodes.**.total`, or any write to a concrete expansion such as `nodes.*.children.*.total` (fixed-arity `$setAll`, `$resolve` with a value, direct assignment) | Write what the getter derives from |
2486
+ | `wcs/recursion-shared-list` / `wcs/recursion-cycle` | The walk reached the same array instance twice: two nodes sharing one child list, or a list reachable from its own ancestor | Give every node its own child array |
2487
+ | `wcs/recursion-depth-exceeded` | The expanded path needs more than 128 wildcard levels — the tree nests deeper than the engine can address, or it contains a cycle | Flatten the tree, or find the cycle |
2488
+
2489
+ The form each `wcs/recursion-*` row is refusing — and the form to write instead — is spelled out under [Recursive Paths](#recursive-paths-recursion).
2275
2490
 
2276
2491
  Previously **extra indexes were silently discarded** by both APIs, so a mixed-up call returned a plausible-looking wrong value. Both now throw:
2277
2492