Skip to content

Commit 758f463

Browse files
authored
Merge pull request #128 from jmjava/cursor/wizard-state-segments-2ccd
Fail closed when wizard state segments is not a mapping
2 parents 17728df + 82cf4a3 commit 758f463

5 files changed

Lines changed: 185 additions & 7 deletions

File tree

‎milestones/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ repositories that install `docgen` and maintain their own demo bundle. The
55
library no longer ships an in-repo dogfood; consumers are the integration test
66
of record.
77

8-
**Active:** **[timing-inner-lists.md](timing-inner-lists.md)** —
9-
`timing.json` `words` / `segments` must be JSON arrays of objects.
8+
**Active:** **[wizard-state-segments.md](wizard-state-segments.md)** —
9+
wizard `.docgen-state.json` `segments` must be a mapping of objects.
1010

1111
**Shipped:**
12+
- **[timing-inner-lists.md](timing-inner-lists.md)** —
13+
`timing.json` `words` / `segments` must be JSON arrays of objects
14+
(#127).
1215
- **[timing-stem-objects.md](timing-stem-objects.md)** —
1316
`timing.json` per-stem values must be JSON objects (not lists/scalars)
1417
(#126).

‎milestones/timing-inner-lists.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Milestone: timing.json words/segments must be object arrays
22

3-
**Status:** Active
3+
**Status:** Shipped
44
**PR:** [#127](https://github.com/jmjava/documentation-generator/pull/127)
55
**Depends on:** `milestones/timing-stem-objects.md` (PR #126)
66

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Milestone: wizard state segments must be objects
2+
3+
**Status:** Active
4+
**PR:** [#128](https://github.com/jmjava/documentation-generator/pull/128)
5+
**Depends on:** `milestones/timing-inner-lists.md` (PR #127)
6+
7+
## Problem
8+
9+
``load_state`` accepts any JSON object. ``GET /api/segments`` then does
10+
``state.get("segments", {}).get(seg_id, {})``. A list, string, or
11+
per-id non-object under ``segments`` raises ``AttributeError`` (500
12+
traceback). ``POST /api/state`` writes that payload unchanged.
13+
14+
Corrupt JSON / a non-object root still reset to ``{"segments": {}}``
15+
so a broken file does not brick the wizard.
16+
17+
## Goal
18+
19+
When ``segments`` is present, it must be a JSON object whose values
20+
are objects. ``POST /api/state`` rejects a non-object body and invalid
21+
``segments`` with 400. ``GET`` on a corrupt-typed file returns 500 with
22+
the parse error (not ``AttributeError``).
23+
24+
## Done when
25+
26+
- [x] ``load_state`` raises ``WizardError`` on list / scalar ``segments``
27+
- [x] per-id non-object rows raise
28+
- [x] missing / null ``segments`` still means ``{}``
29+
- [x] corrupt JSON still resets to empty (existing contract)
30+
- [x] ``POST /api/state`` rejects a list body and list ``segments``
31+
- [x] `ruff check src/ tests/`
32+
- [x] `pytest tests/` (741 passed, 1 skipped)
33+
- [x] `docgen benchmark` (no clock change; meets baseline)
34+
35+
## Out of scope
36+
37+
- Changing the corrupt-JSON → empty reset
38+
- Wizard ``except Exception`` around ``narration_topic_label``

‎src/docgen/wizard.py‎

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,31 @@
1212
STATE_FILENAME = ".docgen-state.json"
1313

1414

15+
class WizardError(RuntimeError):
16+
"""Raised when wizard persisted state or API payload is invalid."""
17+
18+
19+
def _json_kind(value: Any) -> str:
20+
return "null" if value is None else type(value).__name__
21+
22+
23+
def require_state_segments(data: dict[str, Any], *, label: str) -> dict[str, Any]:
24+
"""Return the ``segments`` mapping, or raise if it is present but not objects."""
25+
segs = data.get("segments")
26+
if segs is None:
27+
return {}
28+
if not isinstance(segs, dict):
29+
raise WizardError(
30+
f"{label} segments must be a JSON object, not {_json_kind(segs)}"
31+
)
32+
for sid, row in segs.items():
33+
if not isinstance(row, dict):
34+
raise WizardError(
35+
f"{label} segments[{sid!r}] must be a JSON object, not {_json_kind(row)}"
36+
)
37+
return segs
38+
39+
1540
def session_payload(config: Any | None) -> dict[str, Any]:
1641
"""GUI session: frozen shell vs pip CLI, and whether a bundle is attached."""
1742
from docgen.resources import is_frozen
@@ -229,7 +254,11 @@ def load_state(base_dir: Path) -> dict[str, Any]:
229254
data = json.loads(p.read_text(encoding="utf-8"))
230255
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
231256
return {"segments": {}}
232-
return data if isinstance(data, dict) else {"segments": {}}
257+
if not isinstance(data, dict):
258+
return {"segments": {}}
259+
out = dict(data)
260+
out["segments"] = require_state_segments(data, label=".docgen-state.json")
261+
return out
233262

234263

235264
def save_state(base_dir: Path, state: dict[str, Any]) -> None:
@@ -648,13 +677,26 @@ def api_generate_narration():
648677
def api_get_state():
649678
cfg = _cfg()
650679
base = cfg.base_dir if cfg else Path.cwd()
651-
return jsonify(load_state(base))
680+
try:
681+
return jsonify(load_state(base))
682+
except WizardError as exc:
683+
return jsonify({"error": str(exc)}), 500
652684

653685
@app.route("/api/state", methods=["POST"])
654686
def api_set_state():
655687
cfg = _cfg()
656688
base = cfg.base_dir if cfg else Path.cwd()
657-
state = request.json or {}
689+
raw = request.get_json(silent=True)
690+
if raw is None:
691+
raw = {}
692+
if not isinstance(raw, dict):
693+
return jsonify({"error": "state must be a JSON object"}), 400
694+
try:
695+
segs = require_state_segments(raw, label="state")
696+
except WizardError as exc:
697+
return jsonify({"error": str(exc)}), 400
698+
state = dict(raw)
699+
state["segments"] = segs
658700
save_state(base, state)
659701
return jsonify({"ok": True})
660702

@@ -668,7 +710,10 @@ def api_segments():
668710
from docgen.yaml_generate import read_hint_focus_paths
669711

670712
base = cfg.base_dir
671-
state = load_state(base)
713+
try:
714+
state = load_state(base)
715+
except WizardError as exc:
716+
return jsonify({"error": str(exc)}), 500
672717
result = []
673718
for seg_id in cfg.segments_all:
674719
seg_name = cfg.resolve_segment_name(seg_id)

‎tests/test_wizard.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,98 @@ def test_load_state_corrupt_json_returns_empty(tmp_path):
6666
assert load_state(tmp_path) == {"segments": {}}
6767

6868

69+
def test_load_state_rejects_non_object_segments(tmp_path):
70+
import json
71+
72+
from docgen.wizard import WizardError, load_state
73+
74+
(tmp_path / ".docgen-state.json").write_text(
75+
json.dumps({"segments": ["01"]}), encoding="utf-8"
76+
)
77+
with pytest.raises(WizardError, match=r"\.docgen-state.json segments must be a JSON object, not list"):
78+
load_state(tmp_path)
79+
80+
81+
def test_load_state_rejects_non_object_segment_row(tmp_path):
82+
import json
83+
84+
from docgen.wizard import WizardError, load_state
85+
86+
(tmp_path / ".docgen-state.json").write_text(
87+
json.dumps({"segments": {"01": "draft"}}), encoding="utf-8"
88+
)
89+
with pytest.raises(
90+
WizardError,
91+
match=r"\.docgen-state.json segments\['01'\] must be a JSON object, not str",
92+
):
93+
load_state(tmp_path)
94+
95+
96+
def test_load_state_null_segments_is_empty(tmp_path):
97+
import json
98+
99+
from docgen.wizard import load_state
100+
101+
(tmp_path / ".docgen-state.json").write_text(
102+
json.dumps({"segments": None, "extra": 1}), encoding="utf-8"
103+
)
104+
assert load_state(tmp_path) == {"segments": {}, "extra": 1}
105+
106+
107+
def _wizard_client(tmp_path):
108+
from docgen.config import Config
109+
from docgen.wizard import create_app
110+
111+
yaml_path = tmp_path / "docgen.yaml"
112+
yaml_path.write_text(
113+
"repo_root: .\nsegments:\n default: ['01']\n all: ['01']\n",
114+
encoding="utf-8",
115+
)
116+
cfg = Config.from_yaml(yaml_path)
117+
return create_app(cfg).test_client(), cfg
118+
119+
120+
def test_api_segments_rejects_list_state_segments(tmp_path):
121+
import json
122+
123+
client, cfg = _wizard_client(tmp_path)
124+
(cfg.base_dir / ".docgen-state.json").write_text(
125+
json.dumps({"segments": []}), encoding="utf-8"
126+
)
127+
res = client.get("/api/segments")
128+
assert res.status_code == 500
129+
assert "segments must be a JSON object" in res.get_json()["error"]
130+
131+
132+
def test_api_state_post_rejects_list_body(tmp_path):
133+
client, _cfg = _wizard_client(tmp_path)
134+
res = client.post("/api/state", json=["not", "an", "object"])
135+
assert res.status_code == 400
136+
assert res.get_json()["error"] == "state must be a JSON object"
137+
138+
139+
def test_api_state_post_rejects_list_segments(tmp_path):
140+
client, cfg = _wizard_client(tmp_path)
141+
res = client.post("/api/state", json={"segments": ["01"]})
142+
assert res.status_code == 400
143+
assert "segments must be a JSON object" in res.get_json()["error"]
144+
assert not (cfg.base_dir / ".docgen-state.json").exists()
145+
146+
147+
def test_api_state_roundtrip_object_segments(tmp_path):
148+
client, cfg = _wizard_client(tmp_path)
149+
payload = {"segments": {"01": {"status": "ready", "revision_notes": "n"}}}
150+
res = client.post("/api/state", json=payload)
151+
assert res.status_code == 200
152+
got = client.get("/api/state")
153+
assert got.status_code == 200
154+
assert got.get_json()["segments"]["01"]["status"] == "ready"
155+
segs = client.get("/api/segments")
156+
assert segs.status_code == 200
157+
assert segs.get_json()["segments"][0]["status"] == "ready"
158+
assert (cfg.base_dir / ".docgen-state.json").is_file()
159+
160+
69161
def test_api_file_rejects_prefix_escape(tmp_path):
70162
from docgen.config import Config
71163
from docgen.wizard import create_app

0 commit comments

Comments
 (0)