fix(scripts): green check-phase10 hygiene after Phase 17 and run it from check-phase17 (T-10-24, T-10-25)

- line-exact, path-bound T-17-10 raw-HTML exceptions for the RichEditorField
  read-only display and the inert template parse in lossy.ts
- dist-origin allow-list adds the never-requested literals
  https://prosemirror.net and https://admin.invalid
- self-test plants: the display line in another file, a template parse
  elsewhere, a foreign origin in the dist JS
- --spa also runs the Vitest browser suite, fail-closed
- check-phase17: browser-suite required names in the admin stage, Phase 10
  hygiene in the docs stage, asserted by its self-test
This commit is contained in:
2026-10-12 20:40:24 +02:00
parent c1d767f0f8
commit fd46d475a2
2 changed files with 85 additions and 14 deletions

View File

@@ -4,6 +4,13 @@
# named test that did not run, OpenAPI or dist drift, a hygiene violation or
# an evidence gap. --self-test proves each detector fails closed.
#
# Hygiene exceptions are line-exact and bound to their file: the MarkdownField
# preview (server-rendered), and since Phase 17 (T-17-10) the RichEditorField
# read-only view (DOMPurify displayHtml, T-10-16) and the inert template parse
# in richeditor/lossy.ts. The dist-origin rule allows only the literals that
# are never requested (namespace URIs, docs links in error text, the
# reserved-TLD parse base of isAllowedLink).
#
# Allow-list: none. The fonoteka.go parity failures TestMigrateSeedsCanonicalGenres
# and TestSchemaMatchesPHPSnapshot, accepted until then, pass since fonoteka.go
# 21c0f12 (fix(09): update parity expectations for the backend admin schema),
@@ -224,9 +231,10 @@ run_self_test() {
done
# The hygiene stage passes on a scratch copy of the tree and fails once a
# raw-HTML directive, a direct fetch, an XMLHttpRequest outside the files.ts
# upload, an app name, a browser-storage call or an extra admin/src/api file
# is planted in it.
# raw-HTML directive (also the RichEditorField display line or the lossy.ts
# template parse moved to another file), a direct fetch, an XMLHttpRequest
# outside the files.ts upload, an app name, a browser-storage call, an extra
# admin/src/api file or another origin in the dist JS is planted in it.
local scratch
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' RETURN
@@ -242,8 +250,9 @@ run_self_test() {
# Each plant is imported by a scratch test, so only its own rule can fire;
# the refusal must name that rule.
local plant want out
for plant in vhtml fetch appname storage xhr apifile; do
rm -rf "$scratch/admin/src/__plant" "$scratch/admin/tests/__plant.test.ts" "$scratch/admin/src/api/__plant.ts"
for plant in vhtml vhtml-elsewhere template-parse fetch appname storage xhr apifile origin; do
rm -rf "$scratch/admin/src/__plant" "$scratch/admin/tests/__plant.test.ts" "$scratch/admin/src/api/__plant.ts" \
"$scratch/modules/boardwalk/dist/assets/__plant.js"
mkdir -p "$scratch/admin/src/__plant"
case "$plant" in
vhtml)
@@ -251,6 +260,20 @@ run_self_test() {
printf "import Plant from '../src/__plant/Plant.vue'\n" >"$scratch/admin/tests/__plant.test.ts"
want="raw-HTML directive"
;;
vhtml-elsewhere)
# The RichEditorField exception is bound to its path.
printf '<template>\n%s\n</template>\n' " <div v-if=\"display !== ''\" class=\"summer-markdown\" v-html=\"display\" />" >"$scratch/admin/src/__plant/Plant.vue"
printf "import Plant from '../src/__plant/Plant.vue'\n" >"$scratch/admin/tests/__plant.test.ts"
want="raw-HTML directive"
;;
template-parse)
printf "export function parse(html: string) {\n const template = document.createElement('template')\n template.innerHTML = html\n return template.content\n}\n" >"$scratch/admin/src/__plant/plant.ts"
want="raw-HTML directive"
;;
origin)
printf 'const u="https://evil.example/x";export{u};\n' >"$scratch/modules/boardwalk/dist/assets/__plant.js"
want="JS names another origin"
;;
fetch)
printf 'export const load = () => fetch("/x")\n' >"$scratch/admin/src/__plant/plant.ts"
want="direct fetch"
@@ -330,6 +353,19 @@ run_spa() {
exit 1
fi
grep -E 'Test Files|Tests ' "$log" || true
# The richeditor display needs a real engine (T-10-16): Vitest browser mode
# in Playwright Chromium, refused the same fail-closed way.
set +e
npm --prefix "$ROOT/admin" run test:browser >"$log" 2>&1
rc=$?
set -e
if [[ "$rc" -ne 0 ]] || grep -qE 'No test files found|Unhandled|FAIL | skipped| todo' "$log"; then
tail -n 60 "$log" >&2
rm -f "$log"
echo "refuse: admin Vitest browser run failed (exit $rc)" >&2
exit 1
fi
grep -E 'Test Files|Tests ' "$log" || true
rm -f "$log"
echo "phase10 spa passed"
}
@@ -364,9 +400,16 @@ hygiene_checks() {
admin/src admin/tests admin/openapi modules/boardwalk modules/cabana modules/phrasebook 2>/dev/null || true)"
[[ -z "$hits" ]] || fail "application names in the framework: $hits"
# Plugin and server strings are rendered as text only.
# The markdown preview binds only the HTML POST /markdown/preview answers (cabana.RenderMarkdown); every other raw-HTML sink is still refused.
# The markdown preview binds only the HTML POST /markdown/preview answers (cabana.RenderMarkdown).
# T-17-10: the RichEditorField read-only view binds only displayHtml output (DOMPurify, T-10-16), never the stored value.
# T-17-10: lossy.ts parses into a template, whose contents are inert and are read for tag names only, never bound.
# Each exception is the exact line in its own file; every other raw-HTML sink is still refused.
local richeditor_display
richeditor_display="^admin/src/components/form/fields/RichEditorField\\.vue:[0-9]+:[[:space:]]*<div v-if=\"display !== ''\" class=\"summer-markdown\" v-html=\"display\" />[[:space:]]*\$"
hits="$(cd "$tree" && grep -rnE 'v-html|innerHTML|outerHTML|insertAdjacentHTML' admin/src 2>/dev/null |
grep -vE '^admin/src/components/form/fields/MarkdownField\.vue:[0-9]+:[[:space:]]*v-html="sanitizedHtml"[[:space:]]*$' || true)"
grep -vE -e '^admin/src/components/form/fields/MarkdownField\.vue:[0-9]+:[[:space:]]*v-html="sanitizedHtml"[[:space:]]*$' \
-e "$richeditor_display" \
-e '^admin/src/components/form/richeditor/lossy\.ts:[0-9]+:[[:space:]]*template\.innerHTML = html[[:space:]]*$' || true)"
[[ -z "$hits" ]] || fail "raw-HTML directive in admin/src: $hits"
# All HTTP goes through the typed openapi-fetch client.
# Uploads in admin/src/api/files.ts use XMLHttpRequest because only XHR reports upload progress (12.2-04); that XHR sends X-Requested-With (T-12.2-32) and refreshes through client.ts; every other XHR, fetch or axios call is still refused.
@@ -387,11 +430,13 @@ hygiene_checks() {
hits="$(cd "$tree" && grep -rnE 'localStorage|sessionStorage|indexedDB|document\.cookie' admin/src 2>/dev/null |
grep -v -e '^admin/src/state/useSidebar.ts:' -e '^admin/src/state/listState.ts:' || true)"
[[ -z "$hits" ]] || fail "browser storage outside admin/src/state/useSidebar.ts and admin/src/state/listState.ts: $hits"
# The embedded build loads nothing from another origin.
# The embedded build loads nothing from another origin. Allowed literals are
# never requested: https://prosemirror.net is ProseMirror error text and
# https://admin.invalid the reserved-TLD parse base in isAllowedLink.
hits="$(cd "$tree" && grep -noE '(src|href)="(https?:)?//[^"]*"|url\((["'"'"']?)(https?:)?//' modules/boardwalk/dist/index.html modules/boardwalk/dist/assets/*.css 2>/dev/null || true)"
[[ -z "$hits" ]] || fail "modules/boardwalk/dist references another origin: $hits"
hits="$(cd "$tree" && grep -ohE 'https?://[A-Za-z0-9.-]+' modules/boardwalk/dist/assets/*.js 2>/dev/null | sort -u |
grep -vxE 'http://www\.w3\.org|https://vuejs\.org|http://local' || true)"
grep -vxE 'http://www\.w3\.org|https://vuejs\.org|http://local|https://prosemirror\.net|https://admin\.invalid' || true)"
[[ -z "$hits" ]] || fail "modules/boardwalk/dist JS names another origin: $hits"
# Icons: named imports only, and never the deprecated lucide package.
hits="$(cd "$tree" && grep -rnE "import \* as [A-Za-z_]+ from '@lucide/vue'|lucide-vue-next" admin/src admin/package.json 2>/dev/null || true)"