Skip to content

Commit 4dd8a7b

Browse files
tntetsuclaude
andcommitted
fix: 文単位ステップが初期位置で暴走・1文2クリックになる不具合を修正(ADR-039)
「文」単位ステップ(stepStmtForward/stepStmtBackward)に2つの不具合があった。 1. 実行直後(cursor=0)に「文」を押すと最後まで実行されてしまう。cursor=0の 現在イベントはenter Programで、そのmatchIdxはトレース末尾を指すため、 dbg.stepOver()がProgram全体を1文として最後まで飛んでいた。関数呼び出しの 中(BlockStatement)でも同様の問題があった。 2. 1文の実行に2回クリックが必要(実行前enterと実行後exitの2段階)だった。 Program/BlockStatementを「複数の文の入れ物」として扱い、そこに滞在する間は stepOver()を使わず中へ入るよう#stmtForwardOnce()を実装。後退は前進を cursor=0から再生し目的地点の直前の着地点を採用する方式(#stmtBackwardOnce) にした。matchIdxを逆算する素朴な実装では、ネストしたブロックで「中に 入るべきか丸ごと読み飛ばすべきか」を正しく判定できないことが試作で判明 したため。JSInterpreter側のstepOver()自体は変更していない。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent dfb7c90 commit 4dd8a7b

5 files changed

Lines changed: 273 additions & 15 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ Runモード時、ヘッダー内ステップ操作バーは **1列(ワイド
204204
| 粒度名 | キー | API | 説明 |
205205
|--------|------|-----|------|
206206
| 式評価 | `b`/`←`、`n`/`→` | `cursor ± 1` | 全 AST ノードの enter/exit |
207-
| 文評価 | `V`/`v` | `stepOver()` → matchIdx | サブ式をスキップ、文単位 |
207+
| 文評価 | `V`/`v` | `#stmtForwardOnce`/`#stmtBackwardOnce`(`step-controller.js`、内部で`stepOver()`を使用) | サブ式をスキップ、文単位。`Program`/`BlockStatement`(複数の文の入れ物)は中へ入るだけで1文として扱わない([ADR-039](docs/adr/ADR-039-stmt-step-container-fix.md)) |
208208
| 人にやさしい単位 | `H`/`h` | `humanStepBack()`/`humanStep()` | 代入・条件判定・while/for 条件式評価(イテレーションごと)・ループ更新・関数呼び出し等の意味ある変化点 |
209209
| 関数呼び出し単位 | `F`/`f` | callDepth 変化まで cursor 移動 | 関数呼び出し・リターンをひとまとまりに |
210210
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# ADR-039: 文単位ステップの不具合修正(初期位置での暴走・1文2クリック問題)
2+
3+
## ステータス
4+
5+
採択済み(2026-09-24)
6+
7+
## コンテキスト
8+
9+
ユーザーから「文」単位ステップ(`stepStmtForward`/`stepStmtBackward`)について2つの不具合報告があった。
10+
11+
1. 実行直後(`cursor=0`)にいきなり「文」を押すと、コードの最後まで一気に実行されてしまう
12+
2. 1つの文を実行するのに2回クリックが必要(実行前と実行後の2段階)
13+
14+
調査の結果、両方とも根本原因は共通していた。「文」単位ステップは JSInterpreter の `dbg.stepOver()`(現在が`enter`ならその`matchIdx`=対応する`exit`へジャンプ、`exit`なら`cursor++`)をそのまま呼び出していたが、この実装は`enter`イベントが「複数の文の入れ物」ノード(`Program`・`BlockStatement`)である場合を特別扱いしていなかった。
15+
16+
- `cursor=0`時点の「現在のイベント」は`enter Program`で、その`matchIdx`はトレース全体の末尾(`exit Program`)を指す。そのため実行直後に「文」を押すと、プログラム全体を1つの文として最後まで飛んでしまう
17+
- 同様に、関数呼び出しの中に入った直後(関数本体`BlockStatement`の`enter`)に「文」を押すと、関数本体の全文をまとめて1回でスキップしてしまう
18+
- 「1文=1クリック」でない問題は、`stepOver()`の`enter→exit`という実装上、1回目のクリックで文の中身を評価してexitへ、2回目のクリックで次の文のenterへ、という2段階に分かれていたために生じていた
19+
20+
Node上でJSInterpreterの`JSDebugger`を直接操作してトレース構造を確認し、修正アルゴリズムを試作・検証した(トップレベル6文・関数呼び出し込み・if文・空ブロック・forループ・whileループ・関数内部から開始、の7パターンで前進・後退の対称性を確認)。
21+
22+
## 決定
23+
24+
修正は JSVisualizer 側(`src/core/step-controller.js`)のみで完結させ、JSInterpreter(別リポジトリ)の`stepOver()`自体は変更しない。`stepOver()`の生の意味(`enter`→対応する`exit`へジャンプ)自体は正しく、問題は「文」ボタンがこれを`Program`/`BlockStatement`という「複数の文の入れ物」ノードにも無条件に適用していた点にある。「文単位とは何を指すか」はJSVisualizerのUI側の定義であり、JSInterpreter側の汎用APIの意味を変える必要はない。
25+
26+
### `#stmtForwardOnce(dbg)`(新規)
27+
28+
```js
29+
const STMT_CONTAINER_TYPES = new Set(['Program', 'BlockStatement']);
30+
31+
#stmtForwardOnce(dbg) {
32+
if (dbg.isDone()) return;
33+
// 実際の文の enter に到達するまで、入れ物ノードの enter・前の文の exit を
34+
// stepIn() で1歩ずつ透過的に読み飛ばす
35+
while (!dbg.isDone()) {
36+
const ev = dbg.getCurrentEvent();
37+
if (ev.phase === 'enter' && !STMT_CONTAINER_TYPES.has(ev.nodeType)) break;
38+
dbg.stepIn();
39+
}
40+
if (!dbg.isDone()) dbg.stepOver(); // 実際の文の enter → exit へ一気に飛ぶ(1文の着地点)
41+
}
42+
```
43+
44+
### `#stmtBackwardOnce(dbg)`(新規、旧`#stepOverBack`を置き換え)
45+
46+
`matchIdx`を逆算する素朴な実装(`cursor--`して`exit`なら`matchIdx`へ、を繰り返す)では、ネストしたブロック(if文の本体など)で「入れ物ノードの中に再帰的に入るべきか、まるごと読み飛ばすべきか」を、親子関係を辿らずに正しく判定できないことが試作の過程で判明した(if文の本体`BlockStatement`は「複数の文の入れ物」として中に入るべきではないが、関数本体の`BlockStatement`は中に入るべき——という違いを`matchIdx`だけからは区別できない)。
47+
48+
そこで、`#stmtForwardOnce()`を`cursor=0`から再生し、目的の`cursor`の直前の着地点を採用する方式にした。前進アルゴリズムと構造的に厳密な対称性が保証されるため、この方式を採用した。
49+
50+
```js
51+
#stmtBackwardOnce(dbg) {
52+
if (dbg.cursor === 0) return;
53+
const target = dbg.cursor;
54+
dbg.cursor = 0;
55+
let last = 0;
56+
while (dbg.cursor < target && !dbg.isDone()) {
57+
this.#stmtForwardOnce(dbg);
58+
if (dbg.cursor >= target) break;
59+
last = dbg.cursor;
60+
}
61+
dbg.cursor = last;
62+
}
63+
```
64+
65+
典型的なトレース長(教育用サンプル、数百〜数千イベント程度)では、このO(n)の再生コストはボタンクリック1回あたりとして無視できる。
66+
67+
### スコープ外にした挙動
68+
69+
関数呼び出しの内部に人/式単位で入ってから「文」に切り替え、関数内の最後の文を実行し終えた場合、呼び出し文自体の`exit`(=関数から呼び出し元へ戻る境界)は独立した着地点にしない。次の「文」クリックで、呼び出し元の次の文の完了地点まで一気に進む(既存の「文単位は粗い粒度」という方針を維持)。これは今回報告された不具合ではなく、独立した設計判断(呼び出し境界を毎回明示するかどうか)のため、今回は変更しなかった。
70+
71+
### 変更ファイル
72+
73+
- **`src/core/step-controller.js`**: `STMT_CONTAINER_TYPES`定数追加、`#stmtForwardOnce`/`#stmtBackwardOnce`追加(旧`#stepOverBack`を置き換え)、`stepStmtForward`/`stepStmtBackward`の呼び出し先変更
74+
- **`tests/core/step-controller.test.js`**: 実際のJSInterpreterトレースに近い構造(`Program`が複数の文を持ち、うち1つが`BlockStatement`=関数本体を子に持つ)のモックトレース`makeStmtTrace()`を追加し、上記の不具合の回帰テストと前進・後退の対称性テストを追加
75+
76+
### 安全性の担保
77+
78+
- `tests/core/step-controller.test.js`に新規テストを追加(`cursor=0`からのProgram丸ごとスキップの回帰確認、1クリック=1文の確認、BlockStatement内からの開始確認、前進・後退の対称性、末尾からの後退)
79+
- `npm test`(107件、既存101件+新規6件)が全て合格
80+
- Playwright(headless Chromium)で、`let a = 2; let b; let x = a; b=3; let y = x + b; x = a + 1;`を実行直後に「文」を6回押し、カーソルが`0→4→6→10→16→24→34`と1文ずつ進むこと(末尾へ飛ばないこと)、逆方向ボタンで`34→24→16`と正確に戻ることを確認
81+
82+
## 結果
83+
84+
- 実行直後に「文」を押しても最後まで実行されなくなった
85+
- 「文」ボタン1回のクリックで1文が実行されるようになった(従来は2回必要だった)
86+
- 既存Jestテストスイート(107件、全て合格)
87+
88+
## 代替案
89+
90+
- **`dbg.stepOver()`自体(JSInterpreter側)にコンテナ判定を組み込む**: 不採用。`stepOver()`の汎用的な意味(enter→対応するexitへジャンプ)自体は正しく、他の用途に影響を与えるリスクがある。「文単位とは何を指すか」はJSVisualizer固有のUI定義であるため、JSVisualizer側で吸収する方が影響範囲を局所化できる
91+
- **後退を`matchIdx`の逆算(素朴な実装)で組む**: 不採用。if文の本体などネストしたブロックで、入れ物ノードを「中に入るべきか」「まるごと読み飛ばすべきか」を親子関係の情報なしに正しく判定できないことが試作で判明した。「前進の再生」方式は前進アルゴリズムをそのまま再利用するため、対称性が構造的に保証される
92+
- **関数呼び出しの`exit`(呼び出し境界)を常に独立した着地点にする**: 不採用(スコープ外)。今回報告された不具合の対象外であり、既存の「文単位は粗い粒度」という方針とのバランスを検討する必要がある独立した設計判断のため、別途要望があれば改めて検討する

‎docs/adr/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@ git 履歴(2026-05-25〜2026-06-08)と設計ドキュメントをもとに 2
4747
| [036](ADR-036-url-query-initial-view.md) | URLクエリ(`view`)による初期表示ビューの指定 | 2026-08-27 |
4848
| [037](ADR-037-exectrace-array-pointer-overlay.md) | ExecTraceへのArraysポインタ・オーバーレイ統合 | 2026-09-24 |
4949
| [038](ADR-038-tdz-sentinel-display-fix.md) | TDZセンチネル値が`Symbol(TDZ)`として表示される不具合の修正 | 2026-09-24 |
50+
| [039](ADR-039-stmt-step-container-fix.md) | 文単位ステップの不具合修正(初期位置での暴走・1文2クリック問題) | 2026-09-24 |

‎src/core/step-controller.js‎

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,25 @@
44
* 粒度:
55
* expr … stepIn / stepBack 全 AST ノード(最細粒度)
66
* human … humanStep / humanStepBack 人間にわかりやすい変化点
7-
* stmt … stepOver / stepOverBack 文単位
7+
* stmt … #stmtForwardOnce / #stmtBackwardOnce 文単位(1クリック=1文。
8+
* Program/BlockStatementを「複数の文の入れ物」として扱い、
9+
* dbg.stepOver()をそのまま使わず内部でラップしている)
810
* call … callDepth 変化点 関数呼び出し/リターン境界(最粗粒度)
911
*/
1012

1113
import { sessionLogger } from './session-logger.js';
1214

1315
/** @typedef {'expr'|'stmt'|'call'|'human'} Granularity */
1416

17+
/**
18+
* 「複数の文の入れ物」ノード種別。文単位ステップがここに滞在している間は
19+
* dbg.stepOver()(enter→対応するexitへジャンプ)を使わず、中へ入る(stepIn)。
20+
* これを区別しないと、Program(cursor=0時点の現在ノード)や関数本体の
21+
* BlockStatement に対して stepOver() を適用してしまい、複数の文をまとめて
22+
* 1回でスキップしてしまう(実行直後に「文」を押すと最後まで進んでしまう不具合)。
23+
*/
24+
const STMT_CONTAINER_TYPES = new Set(['Program', 'BlockStatement']);
25+
1526
export class StepController {
1627
/** @type {import('./debugger-adapter.js').DebuggerAdapter} */
1728
#adapter;
@@ -66,23 +77,23 @@ export class StepController {
6677

6778
// ── ステップ操作(文単位) ────────────────────────────────────────────────
6879

69-
/** 文単位で 1 ステップ前進(stepOver) */
80+
/** 文単位で 1 ステップ前進(次の文の完了地点まで、1クリック=1文) */
7081
stepStmtForward() {
7182
const dbg = this.#adapter.getDebugger();
7283
if (!dbg || dbg.isDone()) return;
7384
const before = dbg.cursor;
74-
dbg.stepOver();
85+
this.#stmtForwardOnce(dbg);
7586
this.#adapter.moveTo(dbg.cursor);
7687
const { loc, callDepth } = this.#locInfo(dbg);
7788
sessionLogger.logStep('stmtFwd', before, dbg.cursor, loc, callDepth);
7889
}
7990

80-
/** 文単位で 1 ステップ後退(stepOver の逆) */
91+
/** 文単位で 1 ステップ後退(前の文の完了地点まで、1クリック=1文) */
8192
stepStmtBackward() {
8293
const dbg = this.#adapter.getDebugger();
8394
if (!dbg || dbg.cursor === 0) return;
8495
const before = dbg.cursor;
85-
this.#stepOverBack(dbg);
96+
this.#stmtBackwardOnce(dbg);
8697
this.#adapter.moveTo(dbg.cursor);
8798
const { loc, callDepth } = this.#locInfo(dbg);
8899
sessionLogger.logStep('stmtBack', before, dbg.cursor, loc, callDepth);
@@ -245,18 +256,40 @@ export class StepController {
245256
}
246257

247258
/**
248-
* stmt 粒度の後退:
249-
* 現在が exit → 対応する enter へ戻る。
250-
* 現在が enter → stepBack で 1 つ前へ。
259+
* stmt 粒度の前進を1回分進める。
260+
* - 実際の文の enter に到達するまで、入れ物ノード(STMT_CONTAINER_TYPES)の
261+
* enter・前の文の exit を stepIn() で1歩ずつ透過的に読み飛ばす
262+
* - 実際の文の enter に着いたら stepOver() で対応する exit へ一気に飛ぶ
263+
* (これが「1文実行」の着地点)
251264
*/
252-
#stepOverBack(dbg) {
265+
#stmtForwardOnce(dbg) {
266+
if (dbg.isDone()) return;
267+
while (!dbg.isDone()) {
268+
const ev = dbg.getCurrentEvent();
269+
if (ev.phase === 'enter' && !STMT_CONTAINER_TYPES.has(ev.nodeType)) break;
270+
dbg.stepIn();
271+
}
272+
if (!dbg.isDone()) dbg.stepOver();
273+
}
274+
275+
/**
276+
* stmt 粒度の後退を1回分進める。
277+
* #stmtForwardOnce() を cursor=0 から再生し、目的の cursor の直前の着地点を採用する
278+
* (前進アルゴリズムと厳密に対称になることをテストで確認済み。ネストしたブロック・
279+
* ループ・関数呼び出しなど、matchIdx を逆算する素朴な実装では正しく扱えないケースが
280+
* あったため、この「前進の再生」方式を採用した)。
281+
*/
282+
#stmtBackwardOnce(dbg) {
253283
if (dbg.cursor === 0) return;
254-
dbg.cursor--;
255-
const ev = dbg.getCurrentEvent();
256-
if (ev && ev.phase === 'exit') {
257-
// matchIdx は対応する enter を指している
258-
dbg.cursor = ev.matchIdx;
284+
const target = dbg.cursor;
285+
dbg.cursor = 0;
286+
let last = 0;
287+
while (dbg.cursor < target && !dbg.isDone()) {
288+
this.#stmtForwardOnce(dbg);
289+
if (dbg.cursor >= target) break;
290+
last = dbg.cursor;
259291
}
292+
dbg.cursor = last;
260293
}
261294

262295
/**

0 commit comments

Comments
 (0)