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)"

View File

@@ -11,12 +11,15 @@
# name (-short).
# plugins go vet ./... in Journal, sitemap, menu, FAQ and the host; their
# Docker-free suites by required name (-short).
# admin the admin SPA typecheck and the vitest JSON report with required
# spec files and test names.
# admin the admin SPA typecheck and the vitest JSON reports with
# required spec files and test names: the happy-dom suite and the
# browser suite (vitest.browser.config.ts, Playwright Chromium;
# the richeditor read-only display, T-17-10).
# layers pnpm test (node --test, TAP) in both layers with required test
# files and names.
# docs TestDocsTree, summer docs:build --check, check-admin-openapi.sh
# --check, check-admin-dist.sh.
# --check, check-admin-dist.sh, and check-phase10.sh --hygiene, so
# admin changes made here cannot silently break Phase 10's gate.
# postgres Docker host only: the testcontainers suites by required name in
# every Go repository. Without a reachable Docker daemon this
# stage fails.
@@ -116,7 +119,6 @@ ADMIN_REQUIRE=(
"RichEditorField renders read-only values through the display policy, never as stored"
"richeditor policy helpers accepts policy links only"
"richeditor policy helpers compares tag names case-insensitively for the lossy note"
"richeditor policy helpers unwraps unknown elements and drops scripts for display"
"RichEditorField value contract an empty paragraph is the empty string, any content its HTML"
"RichEditorField value contract a value from outside replaces the document and leaves the field untouched"
"RichEditorField value contract shows the lossy note only for markup outside the editor schema"
@@ -142,6 +144,18 @@ ADMIN_REQUIRE=(
"reorder tree rules refuses a drop into the node itself or its own subtree and leaves the tree as it was"
"reorder tree rules moves only where a sibling or parent exists, and flattens pre-order from 1"
)
# admin stage, browser suite (vitest.browser.config.ts): spec files and full
# test names, copied from a --reporter=json run.
ADMIN_BROWSER_FILES=(tests/browser/richeditor-display.test.ts)
ADMIN_BROWSER_REQUIRE=(
"richeditor display in a real browser engine runs where form controls clobber form properties"
"richeditor display in a real browser engine drops clobbered forms with everything inside them"
"richeditor display in a real browser engine keeps a form control named localName from breaking the view"
"richeditor display in a real browser engine drops an off-origin form, iframes, meta refresh and disallowed links"
"richeditor display in a real browser engine parses into an inert document, so nothing loads before the value is bound"
"richeditor policy helpers unwraps unknown elements and drops scripts for display"
"RichEditorField renders read-only values exactly through the display policy"
)
# layers stage: test files (must exist and match the test script's glob) and
# TAP test names.
@@ -389,11 +403,13 @@ go_named() {
REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" $pkgs
}
# vitest_json DIR [VITEST ARGS...]: extra arguments (such as --config) go to vitest.
vitest_json() {
local dir="$1" report out rc=0 dc=0
shift
report="$(mktemp)"
out="$(mktemp)"
(cd "$dir" && ./node_modules/.bin/vitest run --reporter=json --outputFile="$report") >"$out" 2>&1 || rc=$?
(cd "$dir" && ./node_modules/.bin/vitest run "$@" --reporter=json --outputFile="$report") >"$out" 2>&1 || rc=$?
detect_vitest "$report" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
grep -E 'FAIL|Error|×' "$out" | tail -n 40 >&2 || true
@@ -529,6 +545,11 @@ ok 1 - a
REQUIRE_FILES='tests/a.ts' expect_files outside-glob 5 "$tmp" || { rm -rf "$tmp"; return 1; }
rm -rf "$tmp"
grep -qF 'bash "$ROOT/scripts/check-phase10.sh" --hygiene' "${BASH_SOURCE[0]}" || {
echo "refuse: the docs stage does not run check-phase10.sh --hygiene" >&2
return 1
}
local mode
for mode in self-test go plugins admin layers docs postgres all; do
grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || {
@@ -583,6 +604,8 @@ run_plugins() {
run_admin() {
(cd "$ROOT/admin" && ./node_modules/.bin/vue-tsc --noEmit)
REQUIRE_FILES="$(printf '%s\n' "${ADMIN_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${ADMIN_REQUIRE[@]}")" vitest_json "$ROOT/admin"
REQUIRE_FILES="$(printf '%s\n' "${ADMIN_BROWSER_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${ADMIN_BROWSER_REQUIRE[@]}")" \
vitest_json "$ROOT/admin" --config vitest.browser.config.ts
echo "phase17 admin passed"
}
@@ -599,6 +622,9 @@ run_docs() {
(cd "$ROOT" && go run ./cmd/summer docs:build --check)
bash "$ROOT/scripts/check-admin-openapi.sh" --check
bash "$ROOT/scripts/check-admin-dist.sh"
# Cross-phase: Phase 10's SPA hygiene (raw-HTML sinks, dist origins, every
# module imported by a test) must stay green after Phase 17 admin changes.
bash "$ROOT/scripts/check-phase10.sh" --hygiene
echo "phase17 docs passed"
}