Make the shell-scroll gate runnable, and catch orphan gates
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
verify-shell-scroll.mjs was written and negative-tested, then left with no package.json entry and no importer, so nothing could ever run it again. It also defaulted to localhost:8899 -- an ad-hoc port from an earlier session -- so it failed with ERR_CONNECTION_REFUSED for anyone else and read as a broken gate rather than a missing server. Now check:shell-scroll, defaulting to BASE_URL or 127.0.0.1:4173 like every other browser gate, and listed in verify-browser-all's GATES array. check:no-orphan-gates fails any gate-shaped file in internal/ that nothing can invoke. The repo carded this once already; it recurs because writing a gate and registering one are separate acts and only the first feels like work. Not every unreferenced file is a lost gate, so the rule is not "everything must be wired": theme-flash-probe is a dated diagnostic for two fixed bugs, worth keeping as a reproduction recipe. It now carries an @one-shot tag saying so, which makes the author state which kind of thing they wrote. Two self-referential defects in the first version of that gate, both from reading evidence out of documentation: - it cited theme-flash-probe.mjs in its own header as an example, then grepped for importers, found itself, and declared the orphan reachable. The gate written to catch orphans made an orphan pass by mentioning it. A gate must never be its own witness, so it now excludes its own file. - it matched @one-shot anywhere in prose, including its own paragraph explaining the tag, and reported "1 tagged" when nothing had been tagged. The tag must now be a marker at line start. Negative-tested: removing check:shell-scroll from package.json fails by name. The scroll fix itself is NOT done. contain:layout and .vtl-host overflow-y are confirmed in the built bundle, and a real wheel gesture still moves the grid 0px with 2586px mounted and unreachable. The gate is what caught that; the diff looked correct.
This commit is contained in:
parent
8e4884469b
commit
b2fc0e4dda
5 changed files with 259 additions and 3 deletions
113
internal/check-no-orphan-gates.mjs
Normal file
113
internal/check-no-orphan-gates.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-no-orphan-gates.mjs — a gate that nothing can run is not a gate.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES. `internal/verify-shell-scroll.mjs` was written, negative-tested, and
|
||||
* proved a real Driver-reported bug with hard numbers (2586px of mounted grid, a real wheel gesture
|
||||
* moving 0px). Then it sat unreachable: no `package.json` entry, no importer. It would never have
|
||||
* run again. The repo has already carded this once —
|
||||
* BUILD-BROWSER-GATES-WERE-ALL-ORPHANS-ONE-COMMAND-RUNS-THEM — and it recurred anyway, because
|
||||
* writing a gate and REGISTERING a gate are two separate acts and only the first feels like work.
|
||||
*
|
||||
* ★ THE DISTINCTION THIS GATE HAS TO GET RIGHT, or it becomes noise nobody reads: not every
|
||||
* unreferenced file in internal/ is a lost gate.
|
||||
* - `vite-plugin-strip-shadow-css-comments.mjs` is imported by vite.config.js — it runs on every
|
||||
* build. Reachable.
|
||||
* - `long-frames-probe.mjs` is imported by two library composables. Reachable.
|
||||
* - `mobile-sheet-probe.mjs`, `theme-flash-probe.mjs`, `readme-shots-fix.mjs` are DATED ONE-SHOT
|
||||
* DIAGNOSTICS (Sept 17/21) written to answer one question about a bug that is now fixed. They
|
||||
* are history, not decay — deleting them would throw away a reproduction recipe, and wiring them
|
||||
* up would add three checks nobody wants run.
|
||||
* So the rule is not "everything must be wired". The rule is: a file that PRESENTS ITSELF as a gate
|
||||
* must be runnable, and a file that is a one-shot probe must SAY SO. Anything else is ambiguous, and
|
||||
* ambiguity is exactly how a real gate hides among abandoned scripts.
|
||||
*
|
||||
* HOW A FILE DECLARES ITS INTENT (either is enough):
|
||||
* - be referenced from package.json scripts, or imported by something that is; or
|
||||
* - carry `@one-shot` in its header comment, naming it a diagnostic that is not expected to run
|
||||
* again. The tag is cheap to add and makes the author state which kind of thing they wrote.
|
||||
*
|
||||
* WHAT IT ASSERTS: every internal/*.mjs whose name or header marks it as a gate (check-*, verify-*,
|
||||
* smoke-*, or a header containing "GATE") is either reachable or explicitly tagged @one-shot.
|
||||
*
|
||||
* NEGATIVE-TESTED: removing the check:shell-scroll entry from package.json makes this fail by name.
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execSync } from 'node:child_process'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const pkgRaw = readFileSync(join(ROOT, 'package.json'), 'utf8')
|
||||
|
||||
const files = readdirSync(join(ROOT, 'internal')).filter((f) => f.endsWith('.mjs'))
|
||||
|
||||
const SELF = 'check-no-orphan-gates.mjs'
|
||||
|
||||
/**
|
||||
* A file is reachable if package.json names it, or if some OTHER source file imports it.
|
||||
*
|
||||
* ★ THE EXCLUSION OF THIS FILE IS LOad-BEARING, not tidiness. First version of this gate listed
|
||||
* `theme-flash-probe.mjs` in its own header as an example of a dated diagnostic — then grepped for
|
||||
* importers, found ITSELF, and declared the file reachable. The gate written to catch orphans made
|
||||
* an orphan pass, by citing it. A gate must never be its own witness.
|
||||
*
|
||||
* Same family as the `@one-shot` bug found in the same run: the tag-detection matched this file's
|
||||
* own prose explaining what the tag means, so the gate reported "1 tagged @one-shot" when no file
|
||||
* had ever been tagged. Both defects come from reading EVIDENCE out of DOCUMENTATION. Where a gate
|
||||
* must describe the thing it checks, it has to exclude its own text from the check.
|
||||
*/
|
||||
function reachable(base) {
|
||||
if (pkgRaw.includes(base)) return 'package.json'
|
||||
try {
|
||||
const hits = execSync(
|
||||
`grep -rl -- ${JSON.stringify(base.replace(/\.mjs$/, ''))} --include=*.mjs --include=*.js --include=*.ts . 2>/dev/null | grep -v node_modules | grep -v "internal/${base}" | grep -v "internal/${SELF}" || true`,
|
||||
{ cwd: ROOT, encoding: 'utf8' }
|
||||
).trim()
|
||||
if (hits) return hits.split('\n')[0]
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
|
||||
const orphans = []
|
||||
let checked = 0
|
||||
let tagged = 0
|
||||
|
||||
for (const f of files) {
|
||||
const src = readFileSync(join(ROOT, 'internal', f), 'utf8')
|
||||
const header = src.slice(0, 2600)
|
||||
|
||||
// Does this file present itself as a GATE (something meant to run repeatedly)?
|
||||
const looksLikeGate = /^(check|verify|smoke)-/.test(f) || /\bGATE\b|\bgate for\b/i.test(header)
|
||||
if (!looksLikeGate) continue
|
||||
checked++
|
||||
|
||||
// The tag must be a MARKER on its own line (` * @one-shot <reason>`), not the string appearing
|
||||
// anywhere in prose. This gate's own header discusses `@one-shot` at length, and a loose test
|
||||
// counted that discussion as a tag — reporting "1 tagged" when nothing had ever been tagged.
|
||||
// Requiring line-start-plus-comment-prefix makes the tag a deliberate act instead of a word.
|
||||
if (/^\s*(?:\*|\/\/)\s*@one-shot\b/m.test(header) && f !== SELF) { tagged++; continue }
|
||||
const via = reachable(f)
|
||||
if (!via) orphans.push(f)
|
||||
}
|
||||
|
||||
if (orphans.length) {
|
||||
console.error(`NO-ORPHAN-GATES: FAIL — ${orphans.length} gate(s) exist but nothing can run them.\n`)
|
||||
for (const f of orphans) {
|
||||
console.error(` internal/${f}`)
|
||||
}
|
||||
console.error('\n A gate nobody can invoke is a file, not a check. Pick one:')
|
||||
console.error(' 1. add a package.json script (and, if it needs a browser, an entry in the GATES')
|
||||
console.error(' array of internal/verify-browser-all.mjs so the one-command suite runs it);')
|
||||
console.error(' 2. or add `@one-shot` to its header if it was a diagnostic for a single bug and')
|
||||
console.error(' is kept only as a reproduction recipe.')
|
||||
console.error('\n This already happened once (card BUILD-BROWSER-GATES-WERE-ALL-ORPHANS). It recurs')
|
||||
console.error(' because writing a gate and registering it are separate acts, and only the first')
|
||||
console.error(' one feels like the work.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`NO-ORPHAN-GATES: PASS — ${checked} gate-shaped file(s); all reachable (${tagged} tagged @one-shot).`)
|
||||
console.log(' Reachable means named in package.json or imported by something that is. The @one-shot')
|
||||
console.log(' tag exists so a dated diagnostic can be KEPT as a reproduction recipe without either')
|
||||
console.log(' masquerading as a live check or being deleted for tidiness.')
|
||||
|
|
@ -4,6 +4,12 @@
|
|||
* when it is supposed to be pinned DARK (useMapTheme THEME_TOGGLE=false →
|
||||
* lightPreset 'night'). Gate for DXM-007 (simple map) + DXM-010 (research map).
|
||||
*
|
||||
* @one-shot Written 2026-09-17 to prove DXM-007/DXM-010 (a LIGHT basemap frame flashing
|
||||
* before useMapTheme pinned it dark). Both are fixed. Kept as the reproduction recipe
|
||||
* — it measures first-paint colour through the real map pipeline, which is expensive
|
||||
* to rebuild — but it is NOT a standing check and is not expected to run again
|
||||
* unless that class of bug returns.
|
||||
*
|
||||
* WHY: the Driver sees (a) a light→dark FLASH on load and (b) the basemap flipping
|
||||
* FULLY light after a legend click / tab click. "It looks dark to me" is not
|
||||
* evidence — this tool turns the basemap's brightness into NUMBERS sampled over
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ const GATES = [
|
|||
// gate above can tell those apart — the defect lives in the host's ancestor chain, not the
|
||||
// component. See internal/check-fixed-anchoring.mjs.
|
||||
{ script: 'check:fixed-anchoring', why: 'the filter dropdown stays anchored in BOTH hosts, not just standalone' },
|
||||
// Same family as the gate above, and found the same week: the cap-5 shell's pane geometry breaks a
|
||||
// plugin that is perfect standalone. Here .vtl-host was inset:0 with overflow:visible, so 2586px of
|
||||
// mounted grid was unreachable and a real wheel gesture moved 0px. Asserts with a WHEEL, not with
|
||||
// scrollHeight — scrollHeight > innerHeight was the FALSE PASS that closed this bug twice.
|
||||
{ script: 'check:shell-scroll', why: 'a wheel gesture in the cap-5 shell actually reaches the grid below the dashboard' },
|
||||
]
|
||||
|
||||
// ── PRECONDITION 1: a built tree ────────────────────────────────────────────
|
||||
|
|
|
|||
130
internal/verify-shell-scroll.mjs
Normal file
130
internal/verify-shell-scroll.mjs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-shell-scroll.mjs — THE GATE FOR BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID.
|
||||
*
|
||||
* ★ WHAT IT ASSERTS, and why each assertion exists rather than the obvious one.
|
||||
*
|
||||
* The obvious assertion — `scrollHeight > innerHeight` — is a FALSE PASS and was used twice to
|
||||
* close this bug while the Driver could still not scroll. In the cap-5 shell the document measures
|
||||
* scrollHeight 900 === clientHeight 900 while the composite element measures 3380/769: the
|
||||
* overflow is real, but it belongs to an element with `overflow:visible` that no scroller owns.
|
||||
* Geometry said "tall"; the user's wheel moved nothing.
|
||||
*
|
||||
* So this gate asserts the ONLY thing the Driver actually cares about: that a REAL WHEEL GESTURE
|
||||
* over the map area moves the grid up the screen and brings it into view. It measures the grid's
|
||||
* position before and after, in the SHELL, which is the surface he uses — not the standalone leaf,
|
||||
* which passed all along and is why the two previous fixes looked green.
|
||||
*
|
||||
* ★ NEGATIVE-TESTED. Reverting the fix (restoring `.ps-pane`/`.vtl-host` to `position:absolute;
|
||||
* inset:0` with no overflow, and `.vtl-content` to `contain:layout paint`) makes this exit 1 with
|
||||
* "grid did not move". A gate nobody has broken is a gate nobody knows works.
|
||||
*
|
||||
* Usage: node internal/verify-shell-scroll.mjs [baseUrl]
|
||||
* (defaults to http://localhost:8899; serve ./public)
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
// Default matches every other browser gate in this repo: `bun run serve` on loopback 4173.
|
||||
// It was `localhost:8899` — one of the ad-hoc ports a previous session happened to have running,
|
||||
// which meant the gate failed with ERR_CONNECTION_REFUSED for anyone else and looked like a broken
|
||||
// gate rather than a missing server. Prefer BASE_URL (env) over a positional argument, because
|
||||
// `bun run <script> <arg>` cannot forward arguments past an inline `#` comment — see
|
||||
// check:script-flags. The positional form is kept as a fallback for direct `node` invocation.
|
||||
const BASE = process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173'
|
||||
const SHELL = '/doxa-maps/plugin-studio/plugin-studio/'
|
||||
const LEAF = '/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/'
|
||||
|
||||
// Shadow DOM DOES NOT PIERCE. A single-level querySelector lies about every composite, so every
|
||||
// lookup here is a recursive walk with a seen-Set, re-injected after each interaction (a page
|
||||
// navigation or re-render drops anything we defined earlier).
|
||||
const WALK = `
|
||||
function __walk(sel) {
|
||||
const seen = new Set(); const out = [];
|
||||
const visit = (root) => {
|
||||
if (!root || seen.has(root)) return; seen.add(root);
|
||||
let els = []; try { els = Array.from(root.querySelectorAll('*')) } catch (e) { return }
|
||||
for (const el of els) {
|
||||
try { if (el.matches && el.matches(sel)) out.push(el) } catch (e) {}
|
||||
if (el.shadowRoot) visit(el.shadowRoot);
|
||||
}
|
||||
};
|
||||
visit(document); return out;
|
||||
}
|
||||
// ★ ANCHOR ON .pgg-root, NOT the <people-groups-grid> TAG. In the cap-4 composite the grid is
|
||||
// IMPORTED as a Vue child component and compiled into one bundle — so there is no custom-element
|
||||
// tag to find, and a tag-based lookup reports "not mounted" for a grid that is right there. That
|
||||
// wrong answer cost a run of this very gate. .pgg-root is the grid's own root div and exists in
|
||||
// both shapes (standalone element and baked-in child).
|
||||
function __gridTop() {
|
||||
const g = __walk('.pgg-root')[0];
|
||||
if (!g) return null;
|
||||
const r = g.getBoundingClientRect();
|
||||
return { top: Math.round(r.top), h: Math.round(r.height) };
|
||||
}
|
||||
`
|
||||
|
||||
const fail = (m) => { console.error('SHELL-SCROLL: FAIL — ' + m); process.exit(1) }
|
||||
|
||||
async function wheel(page, x, y, dy, times = 10) {
|
||||
await page.mouse.move(x, y)
|
||||
for (let i = 0; i < times; i++) { await page.mouse.wheel(0, dy); await page.waitForTimeout(80) }
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const browser = await chromium.launch({
|
||||
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--use-angle=swiftshader']
|
||||
})
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
await page.goto(BASE + SHELL, { waitUntil: 'load' })
|
||||
await page.waitForTimeout(6000)
|
||||
|
||||
// HEAVY TABS LAZY-MOUNT ON ACTIVATION. Before this click the composite does not exist and every
|
||||
// query returns 0 — which reads as "no bug" if you stop here. This is the step both previous
|
||||
// investigations skipped.
|
||||
const tab = await page.evaluate(`(() => { ${WALK}
|
||||
for (const t of __walk('.vtl-tab')) {
|
||||
if ((t.textContent || '').toLowerCase().includes('dashboard-plus-grid')) { t.click(); return t.textContent.trim() }
|
||||
}
|
||||
return null
|
||||
})()`)
|
||||
if (!tab) { await browser.close(); fail('could not find the dashboard-plus-grid tab in the shell rail') }
|
||||
await page.waitForTimeout(9000)
|
||||
|
||||
// 1. The grid must be MOUNTED. If it is not rendered at all, "cannot scroll to it" has a
|
||||
// completely different cause and this gate must say so rather than blaming the scroll.
|
||||
const before = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
|
||||
if (!before) { await browser.close(); fail('.pgg-root is NOT MOUNTED in the shell — different bug, not a scroll bug') }
|
||||
if (before.h < 400) { await browser.close(); fail('.pgg-root mounted but collapsed (h=' + before.h + ') — not a scroll bug') }
|
||||
|
||||
// 2. A REAL WHEEL GESTURE over the MAP AREA must move the grid up the screen. This is the
|
||||
// gesture the Driver makes, over the surface he makes it on. Mapbox scrollZoom eating the
|
||||
// wheel would show up here as no movement.
|
||||
await wheel(page, 850, 420, 300)
|
||||
const afterMap = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
|
||||
const movedByMap = before.top - afterMap.top
|
||||
|
||||
// 3. ...and a gesture BELOW the map must too (in case the map swallows its own area).
|
||||
await wheel(page, 850, 800, 300)
|
||||
const afterLow = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
|
||||
const movedTotal = before.top - afterLow.top
|
||||
|
||||
// 4. THE REAL QUESTION: is the grid actually ON SCREEN now? A grid that moved 50px but still
|
||||
// sits below the fold is still unreachable. Require its top inside the viewport.
|
||||
const onScreen = afterLow.top < 900
|
||||
|
||||
console.log('SHELL-SCROLL measurements (cap-5 shell, tab="' + tab + '"):')
|
||||
console.log(' grid mounted : h=' + before.h + 'px, top=' + before.top)
|
||||
console.log(' wheel over MAP area : grid moved ' + movedByMap + 'px (top ' + before.top + ' -> ' + afterMap.top + ')')
|
||||
console.log(' wheel BELOW map : grid moved ' + movedTotal + 'px total (top -> ' + afterLow.top + ')')
|
||||
console.log(' grid on screen : ' + onScreen)
|
||||
|
||||
await browser.close()
|
||||
|
||||
if (movedTotal < 200) fail('grid did not move — a real wheel gesture in the SHELL scrolled ' + movedTotal + 'px. The grid is mounted at h=' + before.h + 'px and is UNREACHABLE.')
|
||||
if (!onScreen) fail('grid moved ' + movedTotal + 'px but its top is still at ' + afterLow.top + ' (below the 900px fold) — still not reachable.')
|
||||
|
||||
console.log('SHELL-SCROLL: PASS — the grid is reachable by wheel in the cap-5 shell.')
|
||||
}
|
||||
run().catch(e => { console.error('SHELL-SCROLL: FAIL — ' + e.message); process.exit(1) })
|
||||
|
|
@ -46,7 +46,8 @@
|
|||
"check:public-hygiene": "node internal/public-hygiene-check.mjs",
|
||||
"check:label-joins": "node internal/check-label-joins.mjs",
|
||||
"check:dxlife": "node internal/check-dxlife-untouched.mjs",
|
||||
"check:facet-cascade": "node internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. the `check:facet-cascade:self-test` script runs the NEGATIVE test (reverts the cascade and requires this gate to FAIL); the `check:facet-cascade:self-test` script uses only the cached rows.",
|
||||
"check:facet-cascade": "node internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
|
||||
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
|
||||
"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
|
||||
"check:template-literal": "node internal/template-literal-check.mjs",
|
||||
"verify:map-paint": "node internal/verify-map-paint.mjs",
|
||||
|
|
@ -89,12 +90,13 @@
|
|||
"check:verification-tags": "node internal/check-verification-tags.mjs",
|
||||
"check:fixed-anchoring": "node internal/check-fixed-anchoring.mjs",
|
||||
"check:no-public-binds": "node internal/check-no-public-binds.mjs",
|
||||
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
|
||||
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
|
||||
"check:shot-fidelity:strict": "node internal/check-shot-fidelity.mjs --strict",
|
||||
"check:embed-targets:self-test": "node internal/check-embed-targets-exist.mjs --self-test",
|
||||
"verify:browser-all:plugin-gallery": "node internal/verify-browser-all.mjs --plugin-gallery",
|
||||
"check:script-flags": "node internal/check-script-flags-reachable.mjs"
|
||||
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
|
||||
"check:shell-scroll": "node internal/verify-shell-scroll.mjs",
|
||||
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
Loading…
Reference in a new issue