shoot a real cap-1 element and gate README capability claims against the manifest

The CAP 1 slot shot doxa-vision-card, which is not a single element: it ships
doxa-vision-card.instances.json with two instances, so discover.mjs types it
parameterized and manifest.json records "parameterized" — capability 2.

The same error had already been found and fixed in verify-5-capabilities.mjs, whose own
comment records that it had been sampling cap-2 twice and cap-1 never. That fix landed in
the gate and never reached the README: two artifacts encoding one fact, with nothing
forcing agreement. Fixing one copy was not fixing the fact.

all-people-groups is a genuine cap-1 — one .vue, no instances file, manifest type single.
The README now also says what decides the tier, since the folder's apparent simplicity is
the trap: doxa-vision-card looks like the lightest thing in the repo.

check:capability-citations compares every README capability claim against the manifest,
blocking in CI. Its first matcher used \Z, which is not JavaScript, so it matched zero
sections while still reporting a citation count — a gate reporting a number it had not
verified. It now splits on headings and the count reflects real matches.
This commit is contained in:
doxa-maps-88655c70 2026-09-21 12:02:18 +00:00
parent dc878584bd
commit c7a228e941
10 changed files with 160 additions and 5 deletions

View file

@ -170,6 +170,13 @@ jobs:
- name: Assert no two README slots capture the same view
run: bun run check:shot-distinctness
# Blocking and browser-free: compares README capability claims against manifest.json, which
# the build just produced. Caught the README calling doxa-vision-card a cap-1 single element
# when its instances.json makes it cap-2 — an error already fixed in verify-5-capabilities.mjs
# that never reached the README.
- name: Assert README capability citations match the manifest
run: bun run check:capability-citations
- name: Assert no local credential is being published
run: |
if [ -e public/token.local.js ]; then

View file

@ -58,10 +58,15 @@ headless browser.
### CAP 1 — Single element
*One folder → one bundle → one custom element.* The lightest tier: `plugin-designs/doxa-vision-card/`. No map
engine, mounts instantly — a self-contained "mission vision card."
*One folder → one bundle → one custom element.* The lightest tier: `plugin-designs/all-people-groups/` —
one `.vue`, no preset table, one embeddable element showing every people group on one map.
![Capability 1 — a single-element plugin (doxa-vision-card)](docs/readme-images/cap1-single-element.png)
> **What makes it cap-1 rather than cap-2:** a bundle with a `<profile>.instances.json` is typed
> `parameterized` (cap-2) by `discover.mjs`, because one profile then renders as *many* preset pages.
> `all-people-groups` ships no instances file, so `manifest.json` types it `single`. That file — not the
> folder's apparent simplicity — is what decides the tier.
![Capability 1 — a single-element plugin (all-people-groups)](docs/readme-images/cap1-single-element.png)
### CAP 2 — Preset element (prop-table)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,017 KiB

After

Width:  |  Height:  |  Size: 981 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 KiB

After

Width:  |  Height:  |  Size: 1 MiB

View file

@ -0,0 +1,129 @@
#!/usr/bin/env node
/**
* check-capability-citations.mjs — a doc that names a bundle as "capability N" must match the manifest.
*
* THE BUG THIS EXISTS TO PREVENT (real, and found TWICE in two different artifacts)
* README.md captioned `doxa-vision-card` as "Capability 1 — a single-element plugin". It is not one:
* it ships doxa-vision-card.instances.json with TWO instances, so discover.mjs types it
* PARAMETERIZED and manifest.json records type "parameterized" — capability 2.
*
* ★ THE SAME ERROR HAD ALREADY BEEN FOUND AND FIXED SOMEWHERE ELSE. internal/verify-5-capabilities.mjs
* carries its own note: it "had been reporting '5/5 capability levels' while sampling cap-2 TWICE and
* cap-1 NEVER", and switched its cap-1 representative to all-people-groups. That fix landed in the
* GATE and never reached the README. Two artifacts encoding one fact, with nothing forcing agreement
* — the same shape as the .dsm-loading CSS pair, the writer-vs-client token path, and the doc that
* quotes package.json. Fixing one copy is not fixing the fact.
*
* WHY THE MANIFEST IS THE AUTHORITY
* `capability = 5 - PROFILE_TIER`, and the tier is what the BUILD computed from the folder's real
* shape (an *.instances.json makes it parameterized; a meta.json `type` declares tiers 3/4/5).
* manifest.json is that computation's output, so it cannot disagree with what ships. Prose can.
*
* ★ WHY A FOLDER'S APPARENT SIMPLICITY IS THE TRAP: doxa-vision-card looks like the lightest thing in
* the repo — no map engine, mounts instantly, one small .vue. Everything about it reads "cap-1"
* EXCEPT the one file that decides the tier. The gate looks at that file's consequence, not the vibe.
*
* WHAT IT CHECKS
* Every `![Capability N — ... (bundle-name)]` caption and every "CAP N" section that names a
* `plugin-designs/<bundle>/` path, resolved against manifest.json's declared type.
*
* NEGATIVE-TESTED: pointing the cap-1 caption back at doxa-vision-card makes this FAIL naming the
* bundle, its claimed tier and its real one; restoring all-people-groups returns PASS. A run that
* parses fewer than MIN_EXPECTED citations FAILS rather than passing vacuously.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const README = join(ROOT, 'README.md')
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
if (!existsSync(README) || !existsSync(MANIFEST)) {
console.log('CAPABILITY-CITATIONS: SKIP — README.md or manifest.json not present.')
process.exit(0)
}
// capability = 5 - PROFILE_TIER (internal/plugin-gallery/discover.mjs). Machine key -> capability.
const CAP_OF_TYPE = {
'single': 1,
'parameterized': 2, 'preset-element': 2,
'monolithic': 3, 'multi-view-element': 3,
'nested-elements-mfe': 4, 'composite-element': 4,
'nested-mfe-spa': 5, 'app-shell': 5,
}
const mf = JSON.parse(readFileSync(MANIFEST, 'utf8'))
// A bundle's capability = the HIGHEST tier any of its profiles declares (a composite bundle may
// also ship its plain leaves; the bundle is cited by its top tier).
const capOfBundle = new Map()
for (const b of (mf.bundles || [])) {
let best = 0
for (const p of (b.profiles || [])) {
const c = CAP_OF_TYPE[p.type]
if (c && c > best) best = c
}
if (best) capOfBundle.set(b.bundle, best)
}
const doc = readFileSync(README, 'utf8')
// `![Capability N — ... (bundle)](...)` — the caption form that names its subject in parentheses.
const CAPTION = /!\[Capability\s+(\d)\s*[—-][^\]]*\(([a-z0-9-]+)\)\]/gi
// `### CAP N — …` down to the next `### ` heading, then every `plugin-designs/<bundle>/` inside it.
// ★ SPLIT RATHER THAN REGEX-SCAN. The first version used /^###\s+CAP\s+(\d)\b([\s\S]*?)(?=^###\s|\Z)/gim
// and matched ZERO sections — `\Z` is not JavaScript (that is Python/PCRE), and under the `m` flag
// the fallback `$` matches every LINE end, so each section body truncated to its first line. The
// run still printed "5 citation(s) parsed" because the count included entries the matcher never
// resolved. ★ A GATE THAT REPORTS A NUMBER IT DID NOT VERIFY is the exact failure this file's
// MIN_EXPECTED guard exists to prevent, so the guard has to count REAL matches.
const sections = doc.split(/^###\s+/m).slice(1)
const citations = []
for (const m of doc.matchAll(CAPTION)) citations.push({ claimed: +m[1], bundle: m[2], where: 'caption' })
for (const body of sections) {
const head = body.match(/^CAP\s+(\d)\b/)
if (!head) continue
const claimed = +head[1]
for (const p of body.matchAll(/plugin-designs\/([a-z0-9-]+)\//g)) {
citations.push({ claimed, bundle: p[1], where: `CAP ${claimed} section` })
}
}
// A matcher that finds almost nothing reads exactly like a clean pass (see check-quoted-scripts).
const MIN_EXPECTED = 5
console.log(`CAPABILITY-CITATIONS: ${citations.length} citation(s) parsed from README.md; ` +
`${capOfBundle.size} bundle(s) typed in manifest.json.`)
if (citations.length < MIN_EXPECTED) {
console.error(`\nCAPABILITY-CITATIONS: FAIL — only ${citations.length} parsed (expected >= ${MIN_EXPECTED}).`)
console.error(' The MATCHER is broken, not the README. Fix the regex before trusting this result.')
process.exit(1)
}
const wrong = [], unknown = []
for (const c of citations) {
const actual = capOfBundle.get(c.bundle)
if (actual === undefined) { unknown.push(c); continue }
if (actual !== c.claimed) wrong.push({ ...c, actual })
}
for (const u of unknown) {
console.log(` i not a built bundle, skipped: ${u.bundle} (cited as cap-${u.claimed} in ${u.where})`)
}
if (wrong.length) {
console.error(`\nCAPABILITY-CITATIONS: FAIL — ${wrong.length} citation(s) disagree with the manifest:`)
for (const w of wrong) {
console.error(` ✗ ${w.bundle} cited as CAPABILITY ${w.claimed} (${w.where}) ` +
`but the manifest types it capability ${w.actual}`)
}
console.error(
'\n The manifest is what the BUILD computed from the folder\'s real shape — an *.instances.json\n' +
' makes a bundle parameterized (cap-2) however simple it looks, and a meta.json `type` declares\n' +
' tiers 3/4/5. Prose can drift from that; the manifest cannot. Update the DOC.\n' +
' (This exact error was fixed once in verify-5-capabilities.mjs and never reached the README.)'
)
process.exit(1)
}
console.log('CAPABILITY-CITATIONS: PASS — every cited capability matches the manifest.')

View file

@ -123,8 +123,21 @@ async function shot(name, path, {
// the family. MEASURED 2026-09-20: 34 elements, shadow root mounted, 1440x900, 0 console errors --
// small, not broken. (document.body.innerText is EMPTY here and that is also fine: innerText does
// not reach into shadow DOM, which is why the element COUNT is the readiness signal, not text.)
await shot('cap1-single-element', '/doxa-maps/doxa-vision-card/doxa-vision-card/index.html',
{ tokenFree: true, wait: 3500, minElements: 25 })
// ★ CORRECTED 2026-09-21 — this slot shot doxa-vision-card, which is NOT a cap-1 single element.
// It ships doxa-vision-card.instances.json with TWO instances (default, frontier), so discover.mjs
// types it PARAMETERIZED and manifest.json agrees: type "parameterized" = cap-2. The README
// captioned it "Capability 1 — a single-element plugin (doxa-vision-card)".
//
// ★ THE IDENTICAL ERROR WAS ALREADY FOUND AND FIXED IN verify-5-capabilities.mjs, whose own comment
// records it: that gate "had been reporting '5/5 capability levels' while sampling cap-2 TWICE and
// cap-1 NEVER", and it switched to all-people-groups. The README never got the same correction — a
// fix landed in ONE artifact while the OTHER artifact encoding the same fact kept the bug. That is
// this repo's recurring shape: two copies of one claim with nothing forcing agreement.
//
// all-people-groups is a genuine cap-1: ONE .vue, NO instances.json, manifest type "single".
// It DOES own a map, so it needs a token and longer to settle than the token-free vision card did.
await shot('cap1-single-element', '/doxa-maps/all-people-groups/all-people-groups/index.html',
{ wait: 8000, minElements: 25 })
// ---- CAP2: prop-table -> 3 shots from ONE root.vue (prayer/engagement/adoption) ----
await shot('cap2-prayer', '/doxa-maps/doxa-simple-map/doxa-simple-map/prayer/index.html', { waitFor: CANVAS, wait: 8000 })

View file

@ -52,6 +52,7 @@
"check:shots": "node internal/check-shot-freshness.mjs # a README screenshot that predates the bundle it shows is a picture telling a lie \u2014 and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
"check:library-index": "node internal/check-library-index.mjs # the DERIVED library menu must not fall behind library/. Compares the index against the newest library SOURCE (not a bundle \u2014 this generator scans library/ directly, with no build step between). Fix: bun run library-index",
"check:quoted-scripts": "node internal/check-quoted-scripts.mjs # docs/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS \u2014 update the DOC.",
"check:capability-citations": "node internal/check-capability-citations.mjs # a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README \u2014 two copies of one fact, nothing forcing agreement.",
"check:shot-distinctness": "node internal/check-shot-distinctness.mjs # two README slots must not show the SAME view. README shipped \"List \u2014 page 1\" and \"page 2\" as the same picture: the shot chased a Next control and a scroll that DO NOT EXIST, every attempt inside try/catch so the miss was silent, and the PNGs differed by 0.16% render noise so no checksum caught them. Keys on url+VIEWPORT \u2014 same url at a different vp (deploy2 mobile) is intended.",
"check:shot-fidelity": "node internal/check-shot-fidelity.mjs # warns when THIS machine cannot RENDER what the shots must SHOW. `bun run shots` printed 13x OK while every emoji rendered as a tofu box \u2014 a glyph fallback loads perfectly (0 console errors, 0 HTTP>=400), so the generator cannot see it. Reports by default; --strict to block once an emoji font is installed.",
"shots": "node internal/readme-shots.mjs # regenerate docs/readme-images/*.png (fetches its own Mapbox token SERVER-SIDE, so the browser CORS block does not apply). Was UNREACHABLE \u2014 present but invoked by no script, which is how the 12 PNGs silently rotted a day behind the UI.",