Parcourir la source

templates: pure-ASCII stm-* expressions and script bodies (entity corruption fix)

statum-mkpstm HTML-escapes the entire compiled page, including <script> bodies and stm-* attribute expression values, converting non-ASCII to named entities. Script bodies are raw text in HTML so the browser never decodes them: the entity text lands in JS source and renders literally (the user-visible '&mdash;' / '&middot;' bug). Attribute values are decoded once, but the decoded ENTITY TEXT is what lands in the expression string, so it too renders literally.

App-side fix (the compiler is external and cannot be changed here): every stm-* expression value and every <script> body in src/pages is now pure ASCII.

- stm-text expressions restructured so the glyph is plain body text, which the browser decodes correctly (goal plan-revision header 'Revision #n - when'; home activity '-> task #n'; saddles 'daemon X' with a separate 'middot up <uptime>' span). stm-confirm strings (passed verbatim to window.confirm) use an ASCII hyphen.
- Script bodies use JS unicode escapes (\u2014, \u00b7, \u2026) so the exact glyphs render unchanged; script comments use ASCII hyphens.
- Plain body text keeps its entities and non-ASCII (decoded correctly; deliberately untouched, e.g. 'Priority (1-9)' en dash).

New test/test_template_ascii.py makes the invariant permanent: it extracts every stm-* attribute value and <script> body from all of src/pages and fails on any non-ASCII character, reporting file, kind, line and codepoint. Mutation-tested: reintroducing each defect class (em dash/middle dot in stm-text, hyphen->dash in stm-confirm, unicode in script strings/comments) fails the test.

Verified against the live app: served pages carry single-escaped body entities (source &larr; serves as &larr;) and script-body backslashes survive compilation, so the restructures and \uXXXX escapes are safe.
saddle-Clanker-Saddle il y a 1 mois
Parent
commit
cee6e61b01

+ 6 - 6
src/pages/goal_detail.html

@@ -4,7 +4,7 @@
     <pstm-uri>/goals/{goal_id}</pstm-uri>
     <pstm-template>main</pstm-template>
 <script>
-    // The goal page: supervision surface of one goal — status head
+    // The goal page: supervision surface of one goal - status head
     // with lifecycle actions (plus run-control parity: retry
     // supervision, requeue-to-resume), branch/push-state chips, one
     // muted activity line, the agent's live progress feed, the ATTENTION
@@ -67,7 +67,7 @@
                 window.initQuoteComments({
                     matches: function (node) {
                         // The pending plan body feeds the REMARKS
-                        // popover instead (below) — everything else
+                        // popover instead (below) - everything else
                         // long-form on the goal quote-comments.
                         return node.closest && node.closest('.goal-body') != null
                             && node.closest('[data-plan-body]') == null;
@@ -81,11 +81,11 @@
 
             // The goal's plan-review remarks ride the SHARED
             // engine (main.html's initPlanReviews, plan 16 R3/arch
-            // 15 — the duplicated ~180-line collect-remarks pattern
+            // 15 - the duplicated ~180-line collect-remarks pattern
             // now lives once); only the block resolution is page
             // state.
             // Plan 16 S2: the block for a review container resolves
-            // from the ACTUAL [data-plan-review] element — the pending
+            // from the ACTUAL [data-plan-review] element - the pending
             // completion review renders as an attention block (NOT the
             // plan section), so goal.plan_pending_block alone never
             // resolves it; every open block plus the plan section's
@@ -173,7 +173,7 @@
                          recovery for the no-pending-wake stall. -->
                     <button type="button" stm-if="goal.force_wake != null"
                             stm-action="goal.force_wake"
-                            stm-confirm="Force-woke this goal's parked agent? It resumes immediately on its saddle — one wake per press.">Force wake</button>
+                            stm-confirm="Force-woke this goal's parked agent? It resumes immediately on its saddle - one wake per press.">Force wake</button>
                     <button type="button" class="danger" stm-if="goal.cancel != null"
                             stm-action="goal.cancel"
                             stm-confirm="Cancel this goal? Its tasks are cancelled with it.">Cancel goal</button>
@@ -321,7 +321,7 @@
                     <div stm-for-revision-in="goal.plan_revisions" class="plan-revision">
                         <p><span stm-attribute.class="'badge ' + (revision.approved ? 'badge-done' : (revision.answered ? 'badge-failed' : 'badge-plan'))"
                                  stm-text="revision.approved ? 'approved' : (revision.answered ? 'rejected' : 'pending')">verdict</span>
-                            <span class="muted" stm-text="'Revision #' + revision.id + ' — ' + revision.created_display">when</span></p>
+                            <span class="muted">Revision #<span stm-text="revision.id">n</span> &mdash; <span stm-text="revision.created_display">when</span></span></p>
                         <details>
                             <summary>Submitted markdown</summary>
                             <div class="md rfc-body" stm-markdown="revision.body">body</div>

+ 8 - 8
src/pages/home.html

@@ -114,14 +114,14 @@
             // the same way and looked up in the list. Each browser
             // then shows its own truth: subscribed here ("Turn off")
             // vs subscribed elsewhere only ("Enable" + a count). The
-            // client-side check is authoritative — the server flag is
+            // client-side check is authoritative - the server flag is
             // just a convenience for other consumers.
             var browserSubscribed = null; // null = still asking the browser
             var hereNow = false;          // the displayed verdict (click acts on it)
 
             function sha256Hex16(text) {
                 // Mirrors PushService.endpoint_fingerprint: first 8
-                // bytes of the SHA-256, hex — crypto.subtle needs a
+                // bytes of the SHA-256, hex - crypto.subtle needs a
                 // secure context, exactly like pushManager itself.
                 var data = new TextEncoder().encode(text);
                 return crypto.subtle.digest('SHA-256', data).then(function (digest) {
@@ -177,14 +177,14 @@
                     if (help) {
                         if (here) {
                             help.textContent = others > 0
-                                ? '— this browser receives question, RFC, review and waiting-on-drafts alerts; '
+                                ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
                                   + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
-                                : '— this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
+                                : '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
                         } else if (list.length > 0) {
-                            help.textContent = '— you are subscribed on ' + list.length
+                            help.textContent = '\u2014 you are subscribed on ' + list.length
                                 + ' other browser(s); enable here too to be notified in this browser';
                         } else {
-                            help.textContent = '— be told when an agent asks a question, raises an RFC or review';
+                            help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
                         }
                     }
                 });
@@ -215,7 +215,7 @@
                     event.preventDefault();
                     // Follow the DISPLAYED verdict: a browser holding a
                     // subscription the server no longer knows (a pruned
-                    // row) shows Enable — and enabling re-registers the
+                    // row) shows Enable - and enabling re-registers the
                     // existing subscription, healing it.
                     if (hereNow) disablePush();
                     else enablePush();
@@ -347,7 +347,7 @@
                             <a stm-attribute.href="'/goals/' + item.goal_id" stm-text="item.goal_title">goal</a>
                             <a stm-if="item.task_id > 0" class="muted"
                                stm-attribute.href="'/tasks/' + item.task_id"
-                               stm-text="'→ task #' + item.task_id">task</a></td>
+                               >&rarr; <span stm-text="'task #' + item.task_id">task</span></a></td>
                         <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
                         <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
                             <!-- Comment rows read as footnotes (plan 13

+ 9 - 9
src/pages/main.html

@@ -13,19 +13,19 @@
     <script>
         // Shared page plumbing. showToast: slide-in notifications (used in
         // place of alert). initQuoteComments: registers a container matcher
-        // for the text-highlight -> comment popover — selecting text inside
+        // for the text-highlight -> comment popover - selecting text inside
         // a matched container shows a floating button, then a popover that
         // posts the comment with the quoted text via the action the page
         // supplies.
         // Shared UI engines (parameterised per page):
     //
-    // showToast — every page's toast corner.
+    // showToast - every page's toast corner.
     //
-    // initQuoteComments — the text-selection quote popover + dialog
+    // initQuoteComments - the text-selection quote popover + dialog
     // posting a comment with the quoted text; pages pass a `matches`
     // node predicate and the action accessor.
     //
-    // initPlanReviews (plan 16 R3, arch 15 — the ~180-line pattern
+    // initPlanReviews (plan 16 R3, arch 15 - the ~180-line pattern
     // that lived duplicated between the task and goal pages, now
     // goal-only after the task-page plan review died with the poll
     // era): the plan/completion REVIEW remark queue. "Add remark"
@@ -36,8 +36,8 @@
     // clears on accept. Remark-less rejection is allowed when the
     // feedback textarea carries the substance (plan 16 S2). The
     // rejection body rides `feedback`; the SERVER wraps the per-kind
-    // stable prefix (R4 — templates carry no verdict constants). The
-    // page supplies `resolveBlock(id)` — how a [data-plan-review]
+    // stable prefix (R4 - templates carry no verdict constants). The
+    // page supplies `resolveBlock(id)` - how a [data-plan-review]
     // container's block row resolves from page state.
     window.initPlanReviews = function (config) {
         if (!window.statum || !statum.afterEvaluate) return;
@@ -170,7 +170,7 @@
             var extra = review.querySelector('[data-plan-feedback] textarea[name="feedback"]');
             var extraText = extra && extra.value.trim().length > 0 ? extra.value.trim() : '';
             // Remark-less rejection is allowed when the feedback
-            // textarea carries the review's substance — one of the
+            // textarea carries the review's substance - one of the
             // two must be non-empty (plan 16 S2).
             if (list.length === 0 && extraText.length === 0) {
                 if (window.showToast) window.showToast('Highlight some text and add a remark, or write feedback first', 'error');
@@ -239,7 +239,7 @@
     // plan-remarks pattern) toggles which side shows. Why JS-applied
     // values and not stm-attribute.value: statum's attribute binding
     // sets the CONTENT attribute, which the browser ignores on
-    // <textarea> (its value lives in the text node) — so the textarea
+    // <textarea> (its value lives in the text node) - so the textarea
     // is re-prefilled from the card's data-value payload after every
     // render cycle, while the page state carries the source of truth.
     // The card contract:
@@ -285,7 +285,7 @@
             }
         }
 
-        // Prefill bookkeeping: applied ONCE per open — a statum
+        // Prefill bookkeeping: applied ONCE per open - a statum
         // re-render (pulse refresh, action response) rebinds
         // attributes but never textarea VALUES, so re-applying on
         // every cycle would clobber the user's in-progress typing.

+ 2 - 1
src/pages/saddles.html

@@ -38,7 +38,8 @@
                     <p><span stm-attribute.class="'badge badge-health-' + saddle.health"
                               stm-text="saddle.health">healthy</span>
                         <span class="muted" stm-if="saddle.daemon_state.length > 0"
-                              stm-text="'daemon ' + saddle.daemon_state + (saddle.uptime_display.length > 0 ? ' · up ' + saddle.uptime_display : '')">daemon</span>
+                              stm-text="'daemon ' + saddle.daemon_state">daemon</span>
+                        <span class="muted" stm-if="saddle.daemon_state.length > 0 && saddle.uptime_display.length > 0">&middot; up <span stm-text="saddle.uptime_display">uptime</span></span>
                         <span class="muted" stm-text="'contacted ' + saddle.last_contact_display + ' ago'">contacted</span></p>
                     <p stm-if="saddles.is_admin && saddle.token.length > 0" class="muted">API token:
                         <code stm-text="saddle.token">token</code></p>

+ 3 - 3
src/pages/task_detail.html

@@ -6,7 +6,7 @@
 <script>
     // Live updates: the page subscribes to the global pulse (see the
     // entrypoint); whenever task or saddle state changes anywhere in
-    // the app, the sealed refresh action rehydrates the task slot —
+    // the app, the sealed refresh action rehydrates the task slot -
     // no fixed-interval polling. RFC bodies get the shared
     // text-highlight -> comment popover (see main.html).
     if (window.statum) {
@@ -29,7 +29,7 @@
             });
             // Belt and braces for SSE starvation: browsers cap ~6
             // connections per origin over HTTP/1.1, and every open
-            // terminal tab holds a /terminal-stream EventSource — when
+            // terminal tab holds a /terminal-stream EventSource - when
             // the budget is exhausted the pulse channel is silently
             // starved. Refreshing on focus recovers without a manual
             // reload.
@@ -118,7 +118,7 @@
                              wake). -->
                         <button type="button" stm-if="task.force_wake != null"
                                 stm-action="task.force_wake"
-                                stm-confirm="Force-woke this parked task? Its agent resumes immediately on its saddle — one wake per press.">Force wake</button>
+                                stm-confirm="Force-woke this parked task? Its agent resumes immediately on its saddle - one wake per press.">Force wake</button>
                         <button type="button" stm-if="!task.draft && !task.terminal"
                                 stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
                     </form>

+ 8 - 8
src/pages/task_log.html

@@ -4,8 +4,8 @@
     <pstm-uri>/tasks/{task_id}/log</pstm-uri>
     <pstm-template>main</pstm-template>
     <script>
-    // The task's event log (plan 17 §3.2): the unified ledger's human
-    // view. The feed is client-side over the plan-17 endpoints — the
+    // The task's event log (plan 17 section 3.2): the unified ledger's human
+    // view. The feed is client-side over the plan-17 endpoints - the
     // SSE stream seeds the most-recent-30 window and streams live
     // appends, load-more pages backwards through the JSON endpoint in
     // batches of 30 (the button pins at the top and appears when the
@@ -15,7 +15,7 @@
     // carrying head+tail truncation display and byte counts.
     // Filters (commands/results/comments/transitions/usage) and the
     // full-text search run client-side over the loaded window. All
-    // rendering goes through textContent/createElement — event
+    // rendering goes through textContent/createElement - event
     // payloads never touch innerHTML.
     if (window.statum) {
         (function () {
@@ -141,7 +141,7 @@
                 head.appendChild(badge);
                 var actor = document.createElement('span');
                 actor.className = 'muted';
-                actor.textContent = ' ' + (evt.actor || '') + ' · ' + (evt.created_display || '');
+                actor.textContent = ' ' + (evt.actor || '') + ' \u00b7 ' + (evt.created_display || '');
                 head.appendChild(actor);
                 row.appendChild(head);
                 var body = document.createElement('div');
@@ -207,7 +207,7 @@
                     bits.push('turn ' + events[0].payload.turn);
                 }
                 if (events[0].session_id) bits.push('session ' + events[0].session_id);
-                head.textContent = bits.length > 0 ? bits.join(' · ') : 'agent turn';
+                head.textContent = bits.length > 0 ? bits.join(' \u00b7 ') : 'agent turn';
                 card.appendChild(head);
                 events.forEach(function (evt) {
                     card.appendChild(eventNode(evt));
@@ -223,7 +223,7 @@
                     var empty = document.createElement('p');
                     empty.className = 'muted';
                     empty.textContent = state.events.length === 0
-                        ? 'Waiting for the first events…' : 'No events match the filters.';
+                        ? 'Waiting for the first events\u2026' : 'No events match the filters.';
                     feed.appendChild(empty);
                 }
                 // Grouping: consecutive turn-family events sharing a
@@ -263,7 +263,7 @@
                     moreButton.hidden = state.exhausted;
                     moreButton.disabled = state.loading;
                     moreButton.textContent = state.loading
-                        ? 'Loading…' : 'Load older events';
+                        ? 'Loading\u2026' : 'Load older events';
                 }
             }
 
@@ -337,7 +337,7 @@
                 feed = document.querySelector('[data-log-feed]');
                 moreButton = document.querySelector('[data-log-more]');
                 var header = document.querySelector('[data-log-title]');
-                if (header) header.textContent = '#' + page.task_id + ' — ' + page.task_title;
+                if (header) header.textContent = '#' + page.task_id + ' \u2014 ' + page.task_title;
                 if (!stream && feed) connect();
                 render();
             });

+ 1 - 1
src/pages/tasks.html

@@ -5,7 +5,7 @@
     <pstm-template>main</pstm-template>
 <script>
     // The task queue. Plan 12: the filters (status / project / goal)
-    // run CLIENT-SIDE — the Statum entrypoint fetch never carries the
+    // run CLIENT-SIDE - the Statum entrypoint fetch never carries the
     // page's own query string to the server, so selects filter the
     // rendered rows directly (window.taskFilters + taskRowVisible)
     // and survive re-renders; the create form gains the mode select

+ 91 - 0
test/test_template_ascii.py

@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""Template hygiene: stm-* expressions and <script> bodies must be pure ASCII.
+
+Why this invariant exists (verified against the live compiled app):
+
+  statum-mkpstm HTML-escapes the entire compiled page - including
+  <script> bodies and stm-* attribute EXPRESSION values - converting
+  non-ASCII characters to named entities (&mdash;, &middot;, ...).
+
+  * A <script> body is raw text in HTML: the browser never entity-decodes
+    it. An entity landing there reaches JavaScript source verbatim and
+    renders literally ("&mdash;" as text, or worse, a syntax error).
+
+  * An stm-* attribute value IS entity-decoded by the parser - but the
+    decoded ENTITY TEXT ("&mdash;", 7 characters) is what lands in the
+    expression string, not an em dash. It evaluates and renders
+    literally.
+
+  Plain element body text is unaffected (the browser decodes it once,
+  correctly), so non-ASCII and entities in body content are fine and are
+  deliberately NOT checked here.
+
+The fix is app-side (the compiler is external): keep every stm-*
+expression and every <script> body pure ASCII. Inside expressions and JS
+string literals, use JS unicode escapes ('\u2014', '\u00b7', '\u2026',
+'\u2192') to keep the exact glyph; in prose-only strings (stm-confirm
+reaches window.confirm verbatim) prefer ASCII punctuation. This test
+makes that rule permanent.
+
+Standalone: python3 test/test_template_ascii.py
+"""
+
+import os
+import re
+import sys
+import unittest
+
+PAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                         "..", "src", "pages")
+
+# stm-* attributes carry Statum expression/JS values (stm-text, stm-if,
+# stm-attribute.href, stm-action, stm-confirm, stm-values, ...). All of
+# them are treated as JS or passed through raw, so all must be ASCII.
+STM_ATTR_RE = re.compile(r'\b(stm-[A-Za-z0-9_-]+)\s*=\s*"([^"]*)"')
+# <script> without a pstm-res-src (that form has no body).
+SCRIPT_RE = re.compile(r'<script\b[^>]*>(.*?)</script>', re.S)
+
+
+def non_ascii(text):
+    """[(offset, char)] for every non-ASCII character in text."""
+    return [(i, c) for i, c in enumerate(text) if ord(c) >= 128]
+
+
+def extract_violations(path):
+    """[(kind, detail)] for every non-ASCII character in an stm-*
+    attribute value or a <script> body of one template."""
+    with open(path, encoding="utf-8") as fh:
+        src = fh.read()
+    out = []
+    for m in STM_ATTR_RE.finditer(src):
+        for off, ch in non_ascii(m.group(2)):
+            line = src.count("\n", 0, m.start() + off) + 1
+            out.append(("stm attribute %s" % m.group(1),
+                        "line %d: U+%04X %r" % (line, ord(ch), ch)))
+    for m in SCRIPT_RE.finditer(src):
+        for off, ch in non_ascii(m.group(1)):
+            line = src.count("\n", 0, m.start() + off) + 1
+            out.append(("script body",
+                        "line %d: U+%04X %r" % (line, ord(ch), ch)))
+    return out
+
+
+class TemplateAsciiTest(unittest.TestCase):
+    def test_stm_expressions_and_scripts_are_ascii(self):
+        pages = sorted(f for f in os.listdir(PAGES_DIR) if f.endswith(".html"))
+        self.assertTrue(pages, "no templates found in src/pages")
+        failures = []
+        for name in pages:
+            for kind, detail in extract_violations(os.path.join(PAGES_DIR, name)):
+                failures.append("%s (%s): %s" % (name, kind, detail))
+        self.assertEqual(
+            failures, [],
+            "stm-* expressions and <script> bodies must be pure ASCII "
+            "(statum-mkpstm entity-encodes them; the browser never decodes "
+            "scripts, and decoded entity text renders literally in "
+            "expressions). Use \\uXXXX escapes or ASCII punctuation.\n"
+            + "\n".join(failures))
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)