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 # named test that did not run, OpenAPI or dist drift, a hygiene violation or
# an evidence gap. --self-test proves each detector fails closed. # 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 # Allow-list: none. The fonoteka.go parity failures TestMigrateSeedsCanonicalGenres
# and TestSchemaMatchesPHPSnapshot, accepted until then, pass since fonoteka.go # and TestSchemaMatchesPHPSnapshot, accepted until then, pass since fonoteka.go
# 21c0f12 (fix(09): update parity expectations for the backend admin schema), # 21c0f12 (fix(09): update parity expectations for the backend admin schema),
@@ -224,9 +231,10 @@ run_self_test() {
done done
# The hygiene stage passes on a scratch copy of the tree and fails once a # 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 # raw-HTML directive (also the RichEditorField display line or the lossy.ts
# upload, an app name, a browser-storage call or an extra admin/src/api file # template parse moved to another file), a direct fetch, an XMLHttpRequest
# is planted in it. # 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 local scratch
scratch="$(mktemp -d)" scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' RETURN 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; # Each plant is imported by a scratch test, so only its own rule can fire;
# the refusal must name that rule. # the refusal must name that rule.
local plant want out local plant want out
for plant in vhtml fetch appname storage xhr apifile; do 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" 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" mkdir -p "$scratch/admin/src/__plant"
case "$plant" in case "$plant" in
vhtml) vhtml)
@@ -251,6 +260,20 @@ run_self_test() {
printf "import Plant from '../src/__plant/Plant.vue'\n" >"$scratch/admin/tests/__plant.test.ts" printf "import Plant from '../src/__plant/Plant.vue'\n" >"$scratch/admin/tests/__plant.test.ts"
want="raw-HTML directive" 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) fetch)
printf 'export const load = () => fetch("/x")\n' >"$scratch/admin/src/__plant/plant.ts" printf 'export const load = () => fetch("/x")\n' >"$scratch/admin/src/__plant/plant.ts"
want="direct fetch" want="direct fetch"
@@ -330,6 +353,19 @@ run_spa() {
exit 1 exit 1
fi fi
grep -E 'Test Files|Tests ' "$log" || true 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" rm -f "$log"
echo "phase10 spa passed" 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)" 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" [[ -z "$hits" ]] || fail "application names in the framework: $hits"
# Plugin and server strings are rendered as text only. # 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 | 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" [[ -z "$hits" ]] || fail "raw-HTML directive in admin/src: $hits"
# All HTTP goes through the typed openapi-fetch client. # 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. # 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 | 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)" 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" [[ -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)" 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" [[ -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 | 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" [[ -z "$hits" ]] || fail "modules/boardwalk/dist JS names another origin: $hits"
# Icons: named imports only, and never the deprecated lucide package. # 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)" 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). # name (-short).
# plugins go vet ./... in Journal, sitemap, menu, FAQ and the host; their # plugins go vet ./... in Journal, sitemap, menu, FAQ and the host; their
# Docker-free suites by required name (-short). # Docker-free suites by required name (-short).
# admin the admin SPA typecheck and the vitest JSON report with required # admin the admin SPA typecheck and the vitest JSON reports with
# spec files and test names. # 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 # layers pnpm test (node --test, TAP) in both layers with required test
# files and names. # files and names.
# docs TestDocsTree, summer docs:build --check, check-admin-openapi.sh # 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 # postgres Docker host only: the testcontainers suites by required name in
# every Go repository. Without a reachable Docker daemon this # every Go repository. Without a reachable Docker daemon this
# stage fails. # stage fails.
@@ -116,7 +119,6 @@ ADMIN_REQUIRE=(
"RichEditorField renders read-only values through the display policy, never as stored" "RichEditorField renders read-only values through the display policy, never as stored"
"richeditor policy helpers accepts policy links only" "richeditor policy helpers accepts policy links only"
"richeditor policy helpers compares tag names case-insensitively for the lossy note" "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 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 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" "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 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" "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 # layers stage: test files (must exist and match the test script's glob) and
# TAP test names. # TAP test names.
@@ -389,11 +403,13 @@ go_named() {
REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" $pkgs 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() { vitest_json() {
local dir="$1" report out rc=0 dc=0 local dir="$1" report out rc=0 dc=0
shift
report="$(mktemp)" report="$(mktemp)"
out="$(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=$? detect_vitest "$report" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
grep -E 'FAIL|Error|×' "$out" | tail -n 40 >&2 || true 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; } REQUIRE_FILES='tests/a.ts' expect_files outside-glob 5 "$tmp" || { rm -rf "$tmp"; return 1; }
rm -rf "$tmp" 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 local mode
for mode in self-test go plugins admin layers docs postgres all; do for mode in self-test go plugins admin layers docs postgres all; do
grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || { grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || {
@@ -583,6 +604,8 @@ run_plugins() {
run_admin() { run_admin() {
(cd "$ROOT/admin" && ./node_modules/.bin/vue-tsc --noEmit) (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_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" echo "phase17 admin passed"
} }
@@ -599,6 +622,9 @@ run_docs() {
(cd "$ROOT" && go run ./cmd/summer docs:build --check) (cd "$ROOT" && go run ./cmd/summer docs:build --check)
bash "$ROOT/scripts/check-admin-openapi.sh" --check bash "$ROOT/scripts/check-admin-openapi.sh" --check
bash "$ROOT/scripts/check-admin-dist.sh" 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" echo "phase17 docs passed"
} }