3939
4040_ELEMENT_ID_RE = re .compile (r"""data-element-id\s*=\s*["']([^"']+)["']""" )
4141_URL_RE = re .compile (r"https?://[0-9A-Za-z\.\-]+:\d+\S*" )
42+ _SLIDE_ID_RE = re .compile (r"^[A-Za-z0-9][A-Za-z0-9._-]*$" )
4243# The provider's own working files. They appear under the project root by
4344# design, so they are expected artefacts rather than boundary violations.
4445_EDITOR_ARTEFACTS = (".local-html-editor" ,)
@@ -106,7 +107,23 @@ def _preflight(root: Path, page: str | None) -> tuple[Path, Path]:
106107 ),
107108 exit_code = EXIT_INPUT ,
108109 )
109- pages = sorted (pages_dir .glob ("*.html" ))
110+ pages_root = pages_dir .resolve ()
111+ page_candidates = sorted (candidate for candidate in pages_dir .glob ("*.html" ) if candidate .is_file ())
112+ escaped = [candidate for candidate in page_candidates if candidate .resolve ().parent != pages_root ]
113+ if escaped :
114+ raise CoreError (
115+ Diagnostic (
116+ rule_id = "EDITOR_PAGE_ESCAPES_ROOT" ,
117+ severity = "error" ,
118+ message = "A canonical page resolves outside deck/pages/." ,
119+ location = str (escaped [0 ]),
120+ expected = f"a direct HTML file inside { pages_root } " ,
121+ actual = str (escaped [0 ].resolve ()),
122+ recovery = "Replace the escaping symlink with a real canonical page inside deck/pages/." ,
123+ ),
124+ exit_code = EXIT_INPUT ,
125+ )
126+ pages = [candidate .resolve () for candidate in page_candidates ]
110127 if not pages :
111128 raise CoreError (
112129 Diagnostic (
@@ -121,15 +138,29 @@ def _preflight(root: Path, page: str | None) -> tuple[Path, Path]:
121138 exit_code = EXIT_INPUT ,
122139 )
123140 if page is None :
124- return deck_dir , pages_dir
125- target = pages_dir / f"{ page } .html"
126- if not target .is_file ():
141+ return deck_dir , pages_root
142+ if not _SLIDE_ID_RE .fullmatch (page ):
143+ raise CoreError (
144+ Diagnostic (
145+ rule_id = "EDITOR_PAGE_ID_INVALID" ,
146+ severity = "error" ,
147+ message = "The requested page is not a safe slide id." ,
148+ location = page ,
149+ expected = "an alphanumeric slide id containing only letters, digits, dot, underscore, or hyphen" ,
150+ actual = page ,
151+ recovery = "Pass the stem of a direct deck/pages/*.html file, without path separators or `..`." ,
152+ ),
153+ exit_code = EXIT_INPUT ,
154+ )
155+ pages_by_id = {candidate .stem : candidate for candidate in pages }
156+ target = pages_by_id .get (page )
157+ if target is None :
127158 raise CoreError (
128159 Diagnostic (
129160 rule_id = "EDITOR_PAGE_NOT_FOUND" ,
130161 severity = "error" ,
131162 message = f"No canonical page named { page } ." ,
132- location = str (target ),
163+ location = str (pages_root / f" { page } .html" ),
133164 expected = f"one of: { ', ' .join (p .stem for p in pages )} " ,
134165 actual = page ,
135166 recovery = "Pass --page with a slide id that exists, or omit it to open all pages." ,
@@ -318,8 +349,11 @@ def run(options: Any) -> tuple[Envelope, str | None, int]:
318349 )
319350 stdout_lines : list [str ] = []
320351 stderr_lines : list [str ] = []
352+ drainers : list [threading .Thread ] = []
321353 for stream , sink in ((child .stdout , stdout_lines ), (child .stderr , stderr_lines )):
322- threading .Thread (target = _drain , args = (stream , sink ), daemon = True ).start ()
354+ thread = threading .Thread (target = _drain , args = (stream , sink ), daemon = True )
355+ thread .start ()
356+ drainers .append (thread )
323357
324358 url = _await_ready (stdout_lines , child )
325359 if url is None :
@@ -345,16 +379,36 @@ def run(options: Any) -> tuple[Envelope, str | None, int]:
345379 sys .stderr .write ("[deckflow] press Ctrl-C when you are done editing\n " )
346380
347381 interrupted = _wait_for_session_end (child )
382+ for thread in drainers :
383+ thread .join (timeout = 1 )
384+ provider_exit_code = child .poll ()
385+ provider_failed = not interrupted and provider_exit_code != 0
386+ provider_output = summarize_output ("\n " .join (stderr_lines ), "\n " .join (stdout_lines ))
348387
349388 after = _snapshot (deck_dir )
350389 changed_pages , findings = _classify (before , after )
351390 envelope .extend (findings )
391+ if provider_failed :
392+ envelope .add (
393+ Diagnostic (
394+ rule_id = "EDITOR_PROVIDER_EXITED" ,
395+ severity = "error" ,
396+ message = "The editor provider exited unexpectedly after announcing readiness." ,
397+ location = str (target ),
398+ expected = "exit code 0, or a session ended by the supervising interrupt" ,
399+ actual = f"exit code { provider_exit_code } : { provider_output } " ,
400+ recovery = "Restore any affected pages from the editor backups, then re-run the session." ,
401+ )
402+ )
352403 envelope .extra .update ({
353404 "event" : "finished" ,
354405 "session_id" : session_id ,
355406 "project" : str (deck_dir .parent ),
356407 "changed_pages" : changed_pages ,
357- "ended_by" : "interrupt" if interrupted else "editor-exit" ,
408+ "ended_by" : (
409+ "interrupt" if interrupted else "provider-error" if provider_failed else "editor-exit"
410+ ),
411+ "provider_exit_code" : provider_exit_code ,
358412 "backups" : str (deck_dir / ".local-html-editor" / "backups" ),
359413 })
360414
@@ -388,4 +442,6 @@ def run(options: Any) -> tuple[Envelope, str | None, int]:
388442
389443 has_error = any (d .severity == "error" for d in envelope .diagnostics )
390444 envelope .status = STATUS_FAILED if has_error else STATUS_SUCCEEDED
445+ if provider_failed :
446+ return envelope , None , EXIT_EXECUTION
391447 return envelope , None , EXIT_CONTRACT if has_error else EXIT_OK
0 commit comments