diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 2086d92..d5041b5 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -118,6 +118,18 @@ touches_promptcaps=$(printf '%s\n' "$staged" | grep -E '(^|/)(index\.html|play\. # growth/ is gitignored, so these paths only appear here when someone force-adds them — which is when it matters. touches_launchfacts=$(printf '%s\n' "$staged" | grep -E '(^|/)(index\.html|README\.md|growth/(launch-checklist|show-hn-draft|product-hunt-draft|launch-alternativeto|2026-07-17-ecosystem-shares|shares)\.md|scripts/check-launch-facts\.mjs)$' || true) +# index.html + play.html share 893 hand-maintained identical lines (identical after the leading and +# trailing whitespace strip); the targeted twin checks (resize/pricing/combine/cost) only pin named +# functions. This one pins the WHOLE overlap: a one-sided edit to any shared line fails, a 2-sided +# DIVERGENT edit fails, and new duplication cannot enter silently. +touches_twindrift=$(printf '%s\n' "$staged" | grep -E '(^|/)(index\.html|play\.html|scripts/check-twin-drift\.mjs|scripts/twin-drift-baseline\.json)$' || true) +# The sandbox matrix behind that guard: 16 mutations of the 2 surfaces, each with the verdict the +# guard must return — the 9 planned extractions of docs/twin-drift.md report 0 divergences (8 of them +# are fully silent; row 8 keeps the 5 one-sided deletions it cannot avoid), and a 2-sided DIVERGENT +# edit must still fail. It re-reads both surfaces, so it runs when the RULES move (the guard, the +# baseline or the matrix), not on every index.html edit. +touches_twindriftcases=$(printf '%s\n' "$staged" | grep -E '(^|/)(scripts/check-twin-drift\.mjs|scripts/check-twin-drift-cases\.mjs|scripts/twin-drift-baseline\.json)$' || true) + # an imported (#a=) app's spend-capable bridge calls must be refused until a real Run gesture arms it (consent gate). touches_consent=$(printf '%s\n' "$staged" | grep -E '(^|/)(play\.html|scripts/check-bridge-consent\.mjs)$' || true) # play.html bakes creator sample results into #a= shares so keyless recipients see what the app makes; @@ -130,7 +142,7 @@ touches_llmsfull=$(printf '%s\n' "$staged" | grep -E '(^|/)(llms-full\.txt|llms\ # Nothing to check unless an .html file, one of the checkers, or a guarded doc changed. # Every touches_* variable belongs in this chain — a gate left out of it never runs on a # commit that stages only its own files, which is exactly the commit it exists to catch. -[ -z "$staged_html" ] && [ -z "$touches_launchfacts" ] && [ -z "$touches_njseditor" ] && [ -z "$touches_llmgate" ] && [ -z "$touches_export" ] && [ -z "$touches_login" ] && [ -z "$touches_sw" ] && [ -z "$touches_compat" ] && [ -z "$touches_run" ] && [ -z "$touches_jsparity" ] && [ -z "$touches_seedcache" ] && [ -z "$touches_imgports" ] && [ -z "$touches_langpages" ] && [ -z "$touches_editorports" ] && [ -z "$touches_settings" ] && [ -z "$touches_quickadd" ] && [ -z "$touches_i18n" ] && [ -z "$touches_updates" ] && [ -z "$touches_createapp" ] && [ -z "$touches_stale" ] && [ -z "$touches_vresume" ] && [ -z "$touches_lipsync" ] && [ -z "$touches_aresume" ] && [ -z "$touches_share" ] && [ -z "$touches_pricing" ] && [ -z "$touches_combine" ] && [ -z "$touches_pacing" ] && [ -z "$touches_graphpersist" ] && [ -z "$touches_graphsigs" ] && [ -z "$touches_describeapply" ] && [ -z "$touches_authlc" ] && [ -z "$touches_mediapriv" ] && [ -z "$touches_verhist" ] && [ -z "$touches_rungating" ] && [ -z "$touches_appsstorage" ] && [ -z "$touches_linkerr" ] && [ -z "$touches_deploy" ] && [ -z "$touches_hiddencss" ] && [ -z "$touches_connect" ] && [ -z "$touches_undo" ] && [ -z "$touches_costaccrue" ] && [ -z "$touches_resize" ] && [ -z "$touches_catalog" ] && [ -z "$touches_upscale" ] && [ -z "$touches_drift" ] && [ -z "$touches_promptcaps" ] && [ -z "$touches_consent" ] && [ -z "$touches_sharesamples" ] && [ -z "$touches_llmsfull" ] && [ -z "$touches_examples" ] && exit 0 +[ -z "$staged_html" ] && [ -z "$touches_launchfacts" ] && [ -z "$touches_njseditor" ] && [ -z "$touches_llmgate" ] && [ -z "$touches_export" ] && [ -z "$touches_login" ] && [ -z "$touches_sw" ] && [ -z "$touches_compat" ] && [ -z "$touches_run" ] && [ -z "$touches_jsparity" ] && [ -z "$touches_seedcache" ] && [ -z "$touches_imgports" ] && [ -z "$touches_langpages" ] && [ -z "$touches_editorports" ] && [ -z "$touches_settings" ] && [ -z "$touches_quickadd" ] && [ -z "$touches_i18n" ] && [ -z "$touches_updates" ] && [ -z "$touches_createapp" ] && [ -z "$touches_stale" ] && [ -z "$touches_vresume" ] && [ -z "$touches_lipsync" ] && [ -z "$touches_aresume" ] && [ -z "$touches_share" ] && [ -z "$touches_pricing" ] && [ -z "$touches_combine" ] && [ -z "$touches_pacing" ] && [ -z "$touches_graphpersist" ] && [ -z "$touches_graphsigs" ] && [ -z "$touches_describeapply" ] && [ -z "$touches_authlc" ] && [ -z "$touches_mediapriv" ] && [ -z "$touches_verhist" ] && [ -z "$touches_rungating" ] && [ -z "$touches_appsstorage" ] && [ -z "$touches_linkerr" ] && [ -z "$touches_deploy" ] && [ -z "$touches_hiddencss" ] && [ -z "$touches_connect" ] && [ -z "$touches_undo" ] && [ -z "$touches_costaccrue" ] && [ -z "$touches_resize" ] && [ -z "$touches_catalog" ] && [ -z "$touches_upscale" ] && [ -z "$touches_drift" ] && [ -z "$touches_promptcaps" ] && [ -z "$touches_consent" ] && [ -z "$touches_sharesamples" ] && [ -z "$touches_llmsfull" ] && [ -z "$touches_examples" ] && [ -z "$touches_twindrift" ] && [ -z "$touches_twindriftcases" ] && exit 0 if ! command -v node >/dev/null 2>&1; then echo "pre-commit: node not found, skipping HTML JS checks" >&2 exit 0 @@ -516,3 +528,54 @@ fi if [ -n "$touches_llmsfull" ]; then node "$root/scripts/check-llms-full.mjs" fi + +# 43) TWIN DRIFT: index.html (editor) and play.html (app player + export) carry 893 hand-maintained +# identical lines — identical after a leading/trailing whitespace strip, because play.html nests +# the same code deeper; only 415 of the 893 are byte-identical in the files. The other twin +# checks each pin ONE named function; nothing compared the two files as wholes, so a one-sided +# edit anywhere else shipped silently. This guard pins the whole shared set against +# scripts/twin-drift-baseline.json. It FAILS on a line that leaves the set because one surface +# moved and the other did not, on a line deleted from one surface while still live on the other, +# on a line that loses a copy on one surface only (the baseline stores per-surface occurrence +# counts, because a line can appear twice in one file), and on a line that leaves BOTH surfaces +# while each surface keeps its own DIFFERENT replacement of it. The count is a ratchet — +# extraction may lower it freely, new duplication may not raise it silently. The ratchet number +# is DERIVED from the baseline line list, and the required digest pins the per-line counts and +# the stored text, so no hand edit of the baseline can lift it. +# What leaves the set without failing: a line gone from BOTH surfaces where at most ONE surface +# grew a NEW near-identical replacement. The baseline stores, per line, the hashes of the lines +# that ALREADY looked like it, because a "replacement" that was there first replaced nothing — +# without that memory the guard failed 4 of the 9 planned extractions in docs/twin-drift.md. +# Generated regions are excluded, so the library is never booked as hand duplication; the +# njs-engine block is found by its data-hash, NOT by a script-tag regex that play.html's own +# export-builder string literal can trigger. Offline. Timings vary by machine and by load, and +# the load matters more than the code: each range below is scoped to the load band it was +# measured in and says nothing outside it. On 2026-07-29, over 30 samples of each case on a +# shared 18-core machine with the load average between 3.19 and 5.68, a clean tree took 0.14s to +# 0.18s. The worst case is a refactor that moves many twins at once: at the 200-departure +# classification ceiling it took 1.99s to 3.19s when every departure left both surfaces, and +# 0.91s to 1.64s when they left one. Never a hang: +# 201 departures reports totals instead. Each failure list NAMES its first 12 lines and counts +# the rest. +# Refresh deliberately: +# TWIN_DRIFT_UPDATE=1 node scripts/check-twin-drift.mjs +# Ranked extraction plan: docs/twin-drift.md +if [ -n "$touches_twindrift" ]; then + node "$root/scripts/check-twin-drift.mjs" +fi + +# 44) The sandbox matrix behind check 43. 16 mutations of index.html + play.html in a scratch copy, +# each with the verdict the guard must return: the clean tree, the 9 planned extractions of +# docs/twin-drift.md deleted from BOTH surfaces (8 silent, row 8 with the 5 one-sided reports it +# cannot avoid), a 2-sided DIVERGENT edit, a 1-sided edit, a 1-sided deletion, a correctly +# mirrored edit, and a fresh duplication. It exists because the guard's 2 directions pull against +# each other: the first divergence rule failed 4 of those 9 extractions, row 1 included, where +# the deletion is perfectly mirrored and sig = deletes = 25. Runs when the RULES change (the +# guard, the baseline, or the matrix), not on every surface edit: it runs the guard 16 times over +# mutated copies of 1.7 MB of HTML, which took 10.38s to 13.13s wall over 6 samples on 2026-07-29 +# at a load average of 3.18 to 5.19, against the guard's own 0.14s to 0.18s. Load dominates that +# figure: an earlier 15-case run of the same matrix, at a load average of 23.5 and 6 GB into +# swap, took 65.9s — roughly 5 times today's slowest sample, on the same verdicts. +if [ -n "$touches_twindriftcases" ]; then + node "$root/scripts/check-twin-drift-cases.mjs" +fi diff --git a/docs/twin-drift.md b/docs/twin-drift.md new file mode 100644 index 0000000..b412518 --- /dev/null +++ b/docs/twin-drift.md @@ -0,0 +1,683 @@ +# Twin drift: the index.html ↔ play.html extraction map + +Date: 2026-07-28, revised 2026-07-29. Line ranges are on `index.html` and `play.html` as this branch +commits them, after `origin/main` was merged in (which moved both surfaces); every count here comes +from `scripts/check-twin-drift.mjs` or `scripts/twin-drift-worklist.mjs`, every verdict from +`scripts/check-twin-drift-cases.mjs`, and every timing in the Runtime table from +`scripts/twin-drift-bench.mjs`. The 2 timings outside that table — the baseline refresh and the +sandbox matrix — were timed by running those 2 commands directly. Every timing states the load +average of the machine it was measured on. + +## The measurement + +`index.html` is 12,604 lines. `play.html` is 13,658 lines. They are the two engine surfaces: + +- `index.html` — the editor. It may load files from `vendor/`. +- `play.html` — the app player and the single-file `.html` export. It must stay 1 self-contained file. + +**893 distinct lines longer than 40 characters are identical in both files.** They occur +958 times in `index.html` and 1,011 times in `play.html`. The generated `` in the file. That match blanked `play.html:11259-13656`: 2,398 lines, 17.6% of +the file, all of it hand-written player code. 197 shared lines were invisible while it did. + +The guard therefore matches `` match. play.html +// also contains the STRING LITERAL that the export builder uses to re-emit the tag: +// const engTag = engText ? '` in the file. That blanked 2,398 +// lines of hand-written player code — boot(), the export builder, the balance cache, the +// __appready__ handoff, the model picker — and made a one-sided edit anywhere in them invisible. +// +// scripts/gen-js-engine.mjs:165 writes data-hash = sha256(bundle).slice(0,16). We re-derive it. A +// quoted string inside RUNTIME_JS cannot forge a body whose sha256 matches its own declared hash. +const NJS_BLOCK = /", + "64ec20d158e524c1 1 1 - - return { url: normalizeLoraUrl(r.url), scale: (r.strength==null||r.strength===\"\") ? 1 : Number(r.strength) };", + "6552cb2e39aa8a3d 1 1 - - const n = samples.length, dataLen = n*2, ab = new ArrayBuffer(44+dataLen), dv = new DataView(ab);", + "65d35cc6f5cd7704 17 13 8a61b799060b3516,c5521b76b218c14a,e1893250b982420d 8a61b799060b3516,c5521b76b218c14a,e1893250b982420d /* ======================================================================", + "6630ac328cbea226 1 1 - - for(let i=0;i rej(new Error(\"couldn’t read that image to resize\"));", + "67c3dca541f159ed 1 1 - - if(f.reasoningEffort && f.reasoningEffort!==\"default\") o.reasoning_effort = f.reasoningEffort;", + "67ddf1729306bc1c 1 1 - - // buttons can rebuild them against a freshly-shortened link at click time (see below).", + "67f344afa5ef0f66 1 1 - - function audioUnitUsd(pricing, chars, seconds){", + "68e31cdca7ce4479 1 1 - - if(prompt) body.prompt = prompt; // omit when blank — upscalers run with no instruction; every other path guarantees a non-empty prompt before reaching here", + "696fa37f0d59a1c8 1 1 - - if(p.per_target_megapixel_second!=null){ const mp=_num(p.default_target_megapixels)||1; v=_num(p.per_target_megapixel_second)*mp*dur; return Math.max(v, _num(p.minimum_price)||0); }", + "6982f55283b9e63f 1 1 - - ", + "69f32ca62ef39f92 1 1 - - //