The repo is handed to the Doxa team, and a code name from its build history
means nothing to an inheritor. Two places carried it, both regenerating:
- public/.build.lock was TRACKED. internal/build-lock.sh stamps it with
`by=$(whoami)` under flock on every build, so each rebuild committed the
building machine's username. Scrubbing the string would have been useless;
the file is pure runtime state nothing reads but the lock, so it is now
ignored via public/.gitignore instead.
- check-no-absolute-paths.mjs quoted a real home path in its header to
explain the rule. The prose works the same with /home/<user>/.
check:no-codename makes it stick. It scans tracked files only (the set a clone
receives), names every offending file:line in one pass, and refuses to pass
vacuously on a short file list.
Two things it has to get right or it becomes noise. "10x" is ordinary English
for a multiplier and library/scripts/research uses it correctly, so only the
full two-part name matches. And the same letters appear as the unix username
inside /home/ paths, which is a portability defect check:no-abs-paths already
reports better — those are listed, not failed, so the two gates cannot
disagree about one string.
It carries no ALLOW entry for itself. A gate must never be its own witness,
and an exemption is a hole a real leak could hide in, so the pattern is
assembled from fragments at run time and the file scans itself on the same
terms as every other.
Negative-tested: planting the name in README.md fails naming README.md:2,
exit 1; removing it passes. All five spellings (LK10X, LK 10X, lk10x, LK-10X,
lk_10x) planted across five different shipped surfaces are reported together
in one run, exit 1; reverting returns exit 0.
Also quotes check:heartbeat into the CLI doc, which check:quoted-scripts
requires and which landed in package.json without it.
A `position:fixed; inset:0` scrim is only viewport-relative while nothing
establishes a containing block for fixed descendants. VerticalTabsLayout sets
`contain:layout` on .vtl-content, so inside the cap-5 studio every such scrim
covered the CONTENT PANE instead. Measured at a 1280x900 viewport:
.dt-scrim / .modal-scrim / .lt-overlay ... 1024x793 @ (256,107)
captured by div.vtl-content [contain:layout]
256 is the rail width, 107 the header height — left uncovered and CLICKABLE
behind what still looks like a modal. The fix (library/utils/viewportScrim.js)
measures the real origin and subtracts it, rather than removing the shell's
contain, which is load-bearing for a different reason.
This adds the gate that keeps it fixed. A source grep cannot: every one of these
scrims greps as a correct inset:0, because the variable is the ANCESTOR, not the
declaration — and which ancestors exist depends on the HOST, not the component.
check:scrim-viewport:self-test reverts each scrim to plain inset:0 and requires
EVERY case to fail; a gate that cannot fail is worse than no gate, and this one
measures a property that looks fine in a screenshot either way. The self-test
also surfaced that .lt-overlay is captured TWICE — div.lt-root [transform] as
well — collapsing it to 233x30 rather than merely to the pane.
SemanticTreeLegend's .stl-export-backdrop is fixed the same way but marked as
NOT verified through the UI: it sits behind SHOW_EXPORT_BUTTON = false, so the
menu that owns it cannot be opened. Correct-if-enabled, not observed.
Five lanes had 465 insertions across 18 files sitting uncommitted, some for
15 minutes. That is the state that silently lost the shell-scroll fix earlier
today: a verified fix existed only in the working tree, a second agent's edit
to the same file overwrote it, and the reverted version got committed and
described as the fix.
Nothing here is finished and no card is being closed on it. This is a
snapshot so the work survives an overwrite, taken at a moment when every file
had been quiet 15s or more, so no agent was mid-write.
Verified coherent before staging rather than committed blind: template-literal,
mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags,
quoted-scripts and backlog-status all PASS. check:synced-css fails on
PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and
the same counts as before the swarm started, both untouched by any agent, both
already carded.
Two untracked files included because both are load-bearing: internal/
check-facet-one-field.mjs is wired into package.json with a self-test, and
library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend
and PosterDialog -- a shared helper for the modal-scrim fix rather than six
copies of the same workaround.
Two different agents broke the build the same way within ten minutes: a
backtick inside a // comment sitting inside a template literal of generated
browser JS. The backtick closes the literal and the parser reads prose as code,
so it fails hundreds of lines from the edit -- one break reported at :386 with
the cause in a comment above it.
check:template-literal did not catch either, and that is not a bug in it: it
scans backticks inside CSS comments, the shadow-CSS shape. A // comment inside
a generated-JS literal is a different grammar. Proven by controlled negative
test -- injecting a backticked term into a // comment in generate-tree.mjs, a
file that gate explicitly lists, left it reporting PASS while node --check
failed immediately. The rule was right; the implementation covered a narrower
case than the rule stated.
Nothing parsed the 70 .mjs build scripts. check:parse covers inline scripts in
built pages and check:sfc-parse covers .vue, so the scripts the build is made
of were the one unchecked surface.
Uses the parser rather than a wider regex. A backtick is legal inside a string,
a regex, a nested template, or a comment inside a template; a pattern precise
enough to catch the bad case would false-positive on the legal ones, and a gate
that cries wolf gets skipped -- worse than the gap. The parser already knows
all of that.
Parses, never imports: these scripts read files, spawn browsers and hit the
network at import time, and a syntax check must have no side effects.
Runs as build step 2, beside check:template-literal, so a broken script fails
in a second rather than after minutes of compilation.
The failure message points at the real cause rather than the reported line, and
tells the reader to grep for every instance in one pass -- node --check reports
only the first, and each rebuild to surface the next costs minutes, which is
how one of today's breaks was being fixed one at a time.
Negative-tested side by side: exit 1 with "SyntaxError: Unexpected identifier
'TREE'" at generate-tree.mjs:369, while check:template-literal reports PASS on
the same file in the same moment.
Cards were being written with explanatory statuses -- "done — reconciled: …",
"blocked — Steps 1+3 DONE and re-verified…", "ready". The prose is valuable
and several of those strings carry measurements, but it made status
unqueryable: every count used status !== 'done', which is FALSE for
"done — reconciled: …", so finished cards kept counting as open.
Measured across the three phase backlogs: 130 of 395 cards carried a prose
status and 71 of those were already done. Reported open was 176 against a real
105. The P0 figure quoted every tick was 21; it was 14.
That is worse than an off-by-N because a backlog is a decision instrument. It
answers "what is left" and "is the rest human-gated", and inflating the open
count makes a drained board look stalled, which pushes toward manufacturing
work instead of holding honestly. The error also hides: every card reads
correctly to a human, because the prose says exactly what is true. Only the
aggregate is wrong.
Migration preserved every explanation in fields.status_detail -- 130 of 130,
none lost -- and normalised status to the enum. Six cards whose head-word was
not in the map were left alone and resolved by hand against reality rather than
guessed, since a wrong normalisation hides a card instead of counting it.
The gate does not require status_detail. Most cards need no explanation and
demanding one turns a real check into paperwork satisfied with filler.
Negative-tested: restoring one prose status fails by name and prints it.
The Driver named the sequence: Big Picture, UPG-100, Prayer Progress, Gospel
Resources. Tab 1 is the landing surface -- the first thing anyone opening the
product SPA sees, and the opening step of the onboarding ramp. That ordering
was held only by the order of entries in NAV_MODEL and by comments explaining
why, with nothing failing if someone reordered them.
Easy to break silently: NAV_MODEL is also where sections, badges and
narrow-screen flags live, so it gets edited for reasons unrelated to ordering,
and a row moved while adding a badge produces no error and a SPA that still
works. It just opens somewhere else. That file's own comments record Big
Picture once being painted twice in the rail, from a one-row section that
re-declared a row below.
Asserts the RENDERED rail, not the source array. Parsing NAV_MODEL was the
first attempt and returned an empty list -- the entries do not carry the id
shape I assumed, and a regex over a 300-line commented array is guesswork
either way. The rail is what the user sees and what the Driver described, and
a source parse could pass while a rendering bug shows something else.
Order, not membership: checking that four names are present would pass on a
rail that opens on Gospel Resources. Prefix-matched so a row may carry its
subtitle.
Negative-tested: swapping the first two expected entries fails by name and
prints want versus got for every row.
.filter-dropdown was flex:1 1 160px with min-width:160px and max-width:240px
-- a range, not a width -- so flex-grow renegotiated the moment the selected
label appeared. The row grew 960 to 1294px and triggers reached 240px.
Not cosmetic. The selections row with Clear All sits underneath the filters,
so a filter that grows on selection re-flows the row below it and the band
stops being a fixed block. The stable width is what lets the centred stack
hold still.
Independently reproduced before gating rather than taken from the agent
report: 168px across all six triggers, identical after selecting the longest
option in each. The negative test then reproduced the broken widths
[240,240,240,184,171,219] -- the same array the original report cited, which
confirms that measurement was real.
Asserts the observable outcome, not the CSS. A grep for --dx-filter-w would
pass while a missing min-width:0 on the label still pushed the box open: as a
flex item without it the label is floored at min-content and refuses to
ellipsise. Both halves are needed, so the gate measures the width.
Selecting the LONGEST option is load-bearing. A short option fits inside the
old range too, so a probe that clicks the first option passes on a build that
still has the bug.
Two hosts, and an unmeasurable host fails rather than skips -- three defects
this week were invisible standalone and appeared only in the shell.
Negative-tested: restoring the flex range fails both hosts by name, naming the
before and after arrays and the total growth.
In the cap-5 shell the Region donut, country bars and Place tree rendered
entirely grey while the identical build was correct standalone. A cap-4
composite imports the sibling component directly
(dashboard-plus-grid.vue:18), which takes the COMPONENT and skips
research-dashboard/index.js -- the file that actually assembles the bundle:
Pinia, i18n, colour strategies, customElements.define. The composite
re-declares all of it by hand, a manual copy nothing keeps in sync, which is
how the strategies leg was lost. That file already documented this bypass for
i18n and fixed only that one leg.
It is silent by construction: getColorStrategy() falls back to language-family
for an unregistered mode instead of throwing, so the result is a plausible
wrong picture -- grey charts that read as a data problem rather than a wiring
one. Found by reading an image; every number passed.
The gate compares the leaf's registerStrategies keys against the composite's.
A composite registering nothing while its leaf registers nothing is fine: the
rule is about drift between two lists meant to agree. big-picture also bakes a
sibling and registers zero strategies, and that is correct -- neither it nor
people-groups-grid references colorFor, getColorStrategy or mapbox. A first
pass called it a second instance; measuring usage showed it is not one.
Four rounds to get working, each a real bug found by testing the wrong half:
- pre-stripping comments deleted 2,953 of 4,102 characters of
research-dashboard/index.js, including the call being searched for, so the
gate claimed "no composite bakes a sibling that registers strategies" --
a confident claim about the repo derived from parsing nothing. Not stripping
is also correct: the call is located by requiring "(", so a mention in prose
cannot match.
- indexOf('registerStrategies') found the IMPORT line first and brace-matched
its own braces.
- a non-greedy brace regex stopped at the first inner "}" and the key pattern
missed quoted path keys.
Every fix was verified against dashboard-plus-grid, which survives stripping,
and never against research-dashboard, which does not. A comparison needs both
halves asserted before any fix is believed.
Negative-tested: dropping doxa-region from the composite fails by name and
prints both lists.
A shared component declares the same selectors twice: once in a scoped <style>
block for when it is imported as a Vue child, and once in a useShadowStyles
literal for when it is mounted inside a shadow root, where scoped CSS does not
reach. Both are load-bearing and the standing rule is that they stay
identical. Nothing enforced it.
library/components/PeopleGroupDetail.vue disagreed with itself on eleven
properties, and the shadow copy is a coherent DARK theme under a light-only
rule: .detail-header background white vs #16213e, .image-container #f5f5f5 vs
#2d2d4e, and .people-group-name color #333 vs #fff -- white text, legible only
on the dark background, so fixing the background alone would make the name
invisible. Six importers; the dark hex reached four shipped bundles.
It was invisible because it is a two-host bug: the component looks correct in
whichever host you tested. Fourth of that shape this week.
Three findings while building this, each of which had the gate reporting
success about something it never compared:
- a loose /<style[^>]*>/ matched the words "<style scoped>" inside a JS
comment 43 characters before useShadowStyles( in SemanticTreeLegend.vue, then
ran to the next real </style> and swallowed the whole shadow literal into the
<style> text. Both maps read the same bytes, so that file could not fail on
any of its 51 apparent shared selectors. Anchored to line start, since an SFC
block starts at column 0. That also raised PeopleGroupDetail 8 -> 11.
- assigning m[sel] = props let the last declaration win, discarding every
property that appeared only in a base rule when a media query redeclared the
selector. Rules are merged now.
- comparing raw text reported comma-spacing as divergence: 5 findings for
doxa-simple-map of which 2 were formatting. Compared as property maps, so the
rule is about meaning.
Negative-tested by change rather than by exit code, which with pre-existing
failures proves nothing: fixing .detail-header takes PeopleGroupDetail 11 -> 10
and restoring it returns 11.
Standalone rather than in build:unlocked, because it fails on live defects
today.
The dashboard rendered "99% % UNREACHED" and "1% ADOPTED %". kpiText returns
k.value unchanged for kind:'pct' and that value already carries its percent
sign, while the labels in dashboard.config.js carried a second one.
Each half is correct alone. A label reading '% unreached' is a reasonable
label; a formatter returning a ready-formatted percentage is a reasonable
formatter. Only the composition is wrong, and neither file mentions the other,
which is why it was found by reading the rendered screenshot rather than by
reading either source.
check:kpi-unit asserts no kind:'pct' label contains a '%'. The value owns the
unit because it is produced by the formatter that knows the number; the label
says what is being measured.
Parses the config as text rather than importing it -- the research-dashboard
module graph reaches library/colors/_registry.js, which uses import.meta.glob
and throws under plain node. Text parsing keeps it hermetic enough for
build:unlocked. Comments are stripped first, because the config now documents
this defect right above the fixed line and a naive scan fails on the
explanation of the fix.
It also fails when it finds no pct entries at all, rather than printing a
green PASS: if the config is refactored so the parse stops matching, silence
would read as success forever.
Negative-tested: restoring either label fails by name with exit 1.
The dashboard aggregations module used to hold two private palettes the map
had never seen, and picked chart colours with
CATEGORICAL[i % CATEGORICAL.length]. Two failures came out of that: near-miss
regions (Africa #e74c3c in a chart, #ff2e2e on the map) and religion coloured
by array index. No religion key existed in the private table, so every
religion fell through to its position in a list sorted BY COUNT -- and counts
move when you cross-filter, so a religion changed colour as you filtered.
Islam and Hinduism swapped, Buddhism and Christianity swapped.
That is already fixed: both private palettes deleted, every hex now read from
the same strategy modules the map paints with. But the fix is a deletion plus
a convention, and nothing enforces a convention. The next person adding a
facet writes a lookup table because it is the obvious thing to do, and the
divergence returns with no error and no crash -- just two surfaces disagreeing
about what blue means.
Reads the BUILT bundles rather than the source because the registry discovers
strategies with import.meta.glob, which plain node cannot evaluate. That makes
it a test of the glob too: if the registry ever silently discovers nothing,
the bundles lose the palette and this fails, where a source-only check would
still pass.
Comments are stripped before scanning, because both consumer files describe
this defect at length in their headers and a naive scan matches the
documentation of the fix -- the same self-reference that made two gates report
on their own prose this week.
Negative-tested three ways: a reintroduced private palette, an [i % len]
lookup, and corrupting four of ten religion hexes in a built bundle, which
fails as a partial palette and names the missing hexes.
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.
bun run appends arguments to the command string, so a script ending in an
inline # comment swallows them. Proven side by side:
"node argtest.mjs" bun run _t1 --self-test -> ["--self-test"]
"node argtest.mjs # a comment" bun run _t2 --self-test -> []
No error, no warning: the script runs in its default mode and exits 0.
Four scripts advertised a flag that could never arrive, and two of them are
--self-test. A --self-test IS the proof that a gate can fail, so running
check:facet-cascade --self-test printed the ordinary PASS and exited 0 --
which reads as "the negative test passed" when the negative test never ran.
The gate itself was fine: invoking node directly reverted the cascade and
failed 13 assertions correctly, including the real symptom (switching to
another language family leaves the map empty because the orphan still
filters). Only the invocation was broken, so the mechanism that certifies a
gate can fail was a gate certifying itself.
Each flag now gets a dedicated entry that hardcodes it, which also makes the
mode discoverable in bun run output instead of documented only in prose
nobody greps.
check:script-flags fails any script whose comment advertises a flag the
command cannot receive. Negative-tested: adding such a comment back fails by
name with exit 1.
serve used --bind 0.0.0.0, and every browser gate starts it. A live audit of
this box found nineteen publicly bound http.server processes on 4820-4970 plus
one on 8899, all rooted at public/, with curl to the box's public IP returning
200. Nobody noticed, because a listening socket produces no output and no
failure: the gate it served passed and the process outlived it.
The standing rule "keep the box closed" lived in prose. The behaviour lived in
a flag. Prose cannot constrain a flag, so the rule was never violated by a
decision -- only by a default nobody re-read.
Loopback is sufficient for the whole suite: verify-browser-all,
check:a11y-button-names, check:nav-reachable and smoke:share-all already
default to 127.0.0.1. The wide bind bought nothing.
check:no-public-binds fails any script with an explicit all-interface bind, a
bare vite --host, or an http.server with no --bind at all (its default is also
all interfaces, just less visible in review). It reads the repo rather than the
machine's live sockets, because a socket check would pass or fail on whatever
happens to be running and could not be fixed by editing anything here.
Negative-tested: restoring --bind 0.0.0.0 fails by name with exit 1.
The menu is position:fixed with viewport coordinates. That resolves against the
viewport only while nothing establishes a containing block for fixed
descendants. VerticalTabsLayout -- the cap-5 shell that hosts every plugin as a
tab -- sets contain:layout paint on .vtl-content, inside .vtl-main{left:256px}.
Measured at 1600x900, same component, two hosts:
standalone research-dashboard ...... dLeft=0 dTop=4 six of six
inside plugin-studio dashboard tab . dLeft=256 dTop=111 every one
256 is the rail width and 111 the header height. Reported as the menu landing
"in a random spot further down to the right"; it was never random.
An earlier probe measured six triggers at four viewport widths and passed, so
the card was closed while the bug was still there. Every number was accurate.
They were answers about a surface the user never looks at -- widening across
more widths could not have found it, because the variable was the host.
Fix measures the containing block instead of assuming it: a fixed probe at 0,0
inserted in the menu's own parent reports the origin the menu resolves against.
When nothing captures, that origin is 0,0 and the standalone numbers are
unchanged bit-for-bit.
Not fixed by dropping contain from the layout: it is load-bearing there,
stopping a rail collapse from reflowing a heavy mounted map.
check:fixed-anchoring measures both hosts and treats an unmeasured host as a
failure. Negative-tested: reverting the subtraction fails the studio host with
dLeft=256 while standalone still passes -- it fails exactly where the old probe
passed.
Also cards a second symptom of the same cause, not yet fixed: fixed-position
modal scrims written inset:0 stretch to the content pane instead of the
viewport, leaving the rail clickable behind an open modal.
A card was closed on an agent measurement while the bug was still visible to
the Driver, and the card had no field in which that disagreement could live.
The board said done and the product said broken, and nothing in the data knew.
Adds fields.verification (agent|human|both) plus verification_kind
(visual|state) and a conflict flag, back-filled across 179 closed cards, and
a gate that refuses an anonymous close.
The gate does not require human sign-off. Gating the board on human review was
tried and it stalled. What it forbids is losing the distinction, so the
question "which of these did a human actually look at?" stays answerable.
The visual/state split encodes the asymmetry the Driver named: human
verification is more reliable on visual bugs, agent verification on non-visual
ones. A headless probe reads geometry, not perception -- it can measure a menu
at dTop=4 and still be blind to it feeling wrong. So a visual card closed on
agent evidence is a hypothesis; a state card closed on agent evidence is a
result. The counts are reported separately.
Negative-tested three ways: stripping a tag, claiming human verification with
no quote, and marking a conflict card done each exit 1 by name.
The export carried its provenance comment above the header, and every spreadsheet
importer treats line one as the header — so the scope sentence became the column names
and the real header became the first data row. A single-column line above a sixty-eight
column table is what "glitched" looks like on screen.
Three other explanations were plausible and all three are wrong, measured before
anything changed: the file is comma-separated, not tab-separated, so the tabs in the
report were terminal rendering rather than bytes; the escaper is genuinely RFC-4180, and
embedded commas, quotes and newlines all survive a strict parser round trip; and
injection-prefixed cells were already neutralised. Only the position was wrong, and
chasing the other three would have produced churn and no fix.
The provenance line moves to the end rather than away. It records which filters produced
the file, which is the difference between a dataset and an anecdote, and a trailing
single-column row lands as one populated cell instead of destroying the column mapping.
The field counter that read line two moved with it — left alone it would have counted
the first data row, usually the same number, so it would have looked right and been
wrong.
A new check parses the export the way a spreadsheet would, which no existing gate did:
forty-six checks and not one read the file. It extracts the shipped escaper rather than
restating the rule, so it cannot agree with itself while the product disagrees.
Negative-tested by putting the comment back on top.
A bundle can be compiled and present on disk while its profiles never reach the
manifest, and nothing noticed: the leaf floor counts index.html recursively, so one
bundle losing every leaf while another keeps extras stays above the floor; the
embed-target check iterates what the manifest lists, so an omission is invisible to it;
and a bundle count passes too, because one bundle can be swapped for another and the
totals still match. Counting is not covering. The only way to see an omission is to
compare the manifest against the scanner rather than against itself.
The first version of this check cried wolf on its first real run. It reported key-cities
as missing — discovered with four profiles, absent from the manifest, and still absent
after a full rebuild, which made it look persistent and real. It is neither: the build
config gates that bundle by name and generate-tree filters on it, so the bundle is
legitimately discovered and legitimately unpublished. The scanner answers what exists;
the config answers what ships. Asserting the first against the manifest without
consulting the second asserts an invariant that was never true.
That failure mattered more than the bug it was chasing. A gate that reports a deliberate
configuration as a defect gets switched off, and a switched-off gate protects nothing —
so the check now reads the same config the build reads, and names the gated bundles in
its passing output so the exclusion stays visible rather than becoming folklore.
A manifest entry the scanner does not return is reported as a note rather than a
failure: stale build output heals on the next full build, while a missing plugin is
silently absent from the studio for everyone reviewing the product.
Negative-tested: removing any published bundle from the manifest fails with exit 1
naming it; restoring passes; the gated bundle never trips it.
One commit shipped the requested change and a regression together: a base prefix applied
to an already-rooted URL 404'd all 29 studio tabs, and finding it took 32 minutes. The
root-cause analysis attributes the largest single share of that to there being no gate
that exercises the dev server at all — of 46 gate-ish scripts and 13 browser gates, none
did. The built manifest hands relative refs and the dev manifest hands rooted ones, so a
base-join bug is invisible to every build-time gate and fatal to every developer.
The check finishes in about four seconds against an already-running dev server, which is
the point: the full sweep costs about seventy-five, so it only ever gets run at the end
of a change, which is exactly how a regression rides along unnoticed. It activates one
tab rather than twenty-nine because a single mount already exercises the whole resolver
path — manifest fetch, script resolve, module load, define, mount. If the resolver is
wrong, the first tab fails like all of them.
It asserts the five things that have actually broken: that the root serves the studio and
not a staging page, that the element both mounts and registers, that the tab list is not
empty, that no request failed, and that the activated pane holds real content — because
an element can register and attach while its content never arrives, which is what a
missing API base looks like.
An unreachable dev server exits 2 rather than 1. Conflating "no server" with "broken
product" is how a gate earns a reputation for flakiness and gets switched off.
Negative-tested: removing the tree base makes it fail on the empty tab list, and an
unreachable URL exits 2; restoring returns to green.
Prefixing a base onto an already-rooted URL 404'd all 29 studio tabs, and no existing
gate could have seen it: the built manifest hands relative refs, so concatenation is
correct there and the assumption is never exercised. Only the dev server hands rooted
refs, so the entire build-time suite passes green while every developer's studio is
broken. A product that has to work on ten laptops cannot be verified by the build alone.
The check discovers every shell that defines a resolve(ref) rather than naming them,
because the two capability-5 shells carried byte-identical implementations and one
latent assumption therefore produced two separate outages that had to be found
separately. A third shell copied from either is now caught on its first run.
It evaluates the shipped function against a stubbed host instead of re-implementing the
rule, so the gate cannot quietly agree with itself while the product disagrees.
Negative-tested: removing the rooted-ref guard fails 4 of 8 cases and reproduces the
exact double-slash path from the outage; restoring it returns to green.
A backtick in live shadow CSS breaks the build exactly as one in a CSS comment
does, and check:template-literal only reads the comments. Proven both directions
last tick: the planted defect left that gate at pass while the build died with a
Babel stack trace pointing far from the edit.
The card concluded this was not achievable, on good evidence — three lookahead
heuristics were tried and reverted, scoring 98 and then 136 false positives on a
healthy tree, because the real terminator of a template literal is also a
backtick and no window of surrounding characters separates the two. The evidence
was right and the inference one step too strong. No heuristic works is not
nothing works, and the card named the answer in its last line: needs a tokenizer.
A parser does not guess where a literal ends; ending it is what parsing means.
Measured against the same tree the heuristics failed on: zero false positives
across 83 files. Splitting the SFC first means it sees exactly the script text
the build compiles, and using the parser the build reports errors from means its
verdict matches what actually breaks rather than approximating it.
Negative-tested with the exact case the other gate misses: a backtick planted in
live CSS passes check:template-literal and fails this one, naming the file and
the line that was edited rather than a stack trace somewhere else. That line
number is the point — the build was always the backstop, just an unhelpful one.
I excluded smoke:share from the aggregate two ticks ago and the reason was wrong.
Every clause of it was true — the script takes required arguments, and its
gallery mode does need the vite dev server — but the conclusion did not follow. I
had tried exactly two invocations, bare and --plugin-gallery, and never --url,
which the script's own header documents first. It runs fine against the static
tree: 8 of 8 pass, each Share button reopening the correct custom element with a
live canvas.
That is the same shape I logged as an anti-pattern last tick, now found in my own
work: a not-applicable verdict is more durable than a wrong bug report, because
it closes the question. It cost the suite its highest-consequence surface for two
ticks. A share URL is the one artefact a partner pastes into an email, and it
fails silently.
It runs as smoke:share-all, carrying its own eight --url flags and --browser
chromium, since Firefox headless has no WebGL on this box and the default engine
stops. The eight leaves are the set derived by grepping for ShareButton, not a
guess. The simple-map case is the discriminating one: it shares to a different
leaf than the one opened, so the deep link is really being followed rather than
an SPA fallback rendering something plausible.
Two shell traps on the way. A VAR=x prefix does not apply to $VAR on the same
line, because the shell expands the argument before the assignment scopes to the
command, so the URLs came out with an empty base; deferring through sh -c fixes
it. And a glob in a block comment closed the comment early.
It showed an empty pane reading "Choose an element from the list." The README's
headline image for the highest capability level demonstrated nothing, and every
shot gate passed it, because freshness, distinctness and element counts are all
satisfied by an empty pane. The shot now selects the first rail row before
capturing, so the picture shows what the studio is for: a nested SPA rendering
inside the shell that lists every element by capability. The file went from 167KB
to just over a megabyte, in line with its sibling.
Found by reading the image, the same way the mobile error card was found
yesterday. Reading one picture has now caught two defects that eleven gates
passed.
Add check:shot-fonts. The studio labels each row with a capability emoji, and
this machine has no emoji-capable font, so all 28 rendered as tofu boxes in the
committed PNG. The markup is right and the product is fine in a real browser —
the shot machine could not draw it — so the fix is not to strip the emoji. The
gate asks fontconfig whether this system can render the glyphs the UI actually
uses, and names the font install rather than the markup.
It is deliberately not in build:unlocked: it reports an environment condition
that is currently true here, and a gate that always fails in the build makes the
build useless. Its first version also flagged the star this repo uses as a
comment marker, which every text font renders; the range is now narrowed to the
pictographic planes.
All seven browser gates were orphans: each had a script, and not one was invoked
by anything else. They were reachable only by someone who already knew the name
to type, which is exactly the person who does not need the list. This repo has
been bitten by this before — the Gate-1 acceptance test existed, worked, and was
invoked by nothing, so it could never report anything. An orphan gate is worse
than a missing one because it looks like coverage.
The data guards already solved this; verify-all.mjs says it plainly — a guard
nobody runs is documentation. That file has no reference to playwright or to the
built tree. It is the data half. This is the browser half.
Every gate listed here was run before it was listed, following the rule the
earlier audit set: listing a program you have not executed puts unvalidated
entries in the hand-off surface. All six pass, and the aggregate now runs them
end to end: capabilities, embeds, map paint, the Gate-1 chain, accessible names,
and phone navigation.
smoke:share is excluded on purpose and says so. It takes required arguments and
its gallery mode reads a registry only the vite dev server serves, so listing it
would make the suite fail for something that is not a defect.
Preconditions are asserted up front and exit 2, so a missing server reads as a
setup problem rather than six mystery failures.
Yesterday the cap-5 shell shipped unusable on a phone: the rail was an off-canvas
drawer, the toggle lived inside a header the shell does not render, and the only
other control sat inside the drawer itself at x:-244. Every tab but the one that
happened to open was unreachable, with no error and a page that painted fine.
That survived because it is an intersection bug. Header-off is correct, and an
off-canvas mobile drawer is correct; the defect exists only where they overlap.
So this checks the intersection directly — each cap-5 shell as it ships, at a
phone viewport — and asks whether any on-screen control opens the rail.
It then presses that control and requires the rail to arrive. Presence is not
function: a present-but-inert opener is the same dead end wearing a disguise, and
asserting only presence is how the original bug survived a card marked verified.
This is not covered by the accessible-name gate. Neither property implies the
other: the off-canvas button had a perfectly good name while being unreachable,
and reachability is geometry, not markup, so no source grep can answer it.
Negative-tested by re-creating the real bug — removing the floating opener makes
it fail naming that shell, while the header-bearing studio still passes.
A cap-5 shell nests its children as iframes, and window.MAP_TOKEN does not cross
an iframe boundary — each frame is its own window. A nested map cannot inherit
the parent's token, so it works only because the child leaf carries its own baked
copy. Proven in a browser: all thirteen nested frames in the cap-5 shell hold a
real token, including a map two levels deep. That was a one-off run; this makes
it standing.
The existing smokes cannot catch this. Both inject window.MAP_TOKEN with
addInitScript before the page runs, which is right for what they test but means
the test supplies the thing under test. A user opening a shipped leaf has nothing
injecting a token for them.
Also asserts the reverse: no token may be compiled into a .js bundle. Bundles are
cached, SRI-pinned and reused across hosts, so a token baked into one cannot be
rotated without a rebuild and leaks into every embed.
Reads the built manifest rather than walking directories, so it checks the set
that actually ships. Counted 43 leaves, matching an independent filesystem count.
Negative-tested both directions: stripping a leaf's token and planting one in a
bundle each fail with the artifact named.
The desktop composite pairs the research dashboard with the grid. This is its
mobile sibling: the monolithic 7-tab research map on top, the same grid beneath,
stacked to scroll. Which one a shell shows is an aspect-ratio choice made at the
SPA level, so neither plugin knows about the other and both stay embeddable.
The grid's own search and filters are ON here. The desktop composite turns them
off because the dashboard header owns those controls and drives the grid through
props; the research map has no such header, so switching them off would leave the
grid with no way to be searched at all.
Composes its children by tag and bundle URL rather than importing their .vue
files. Importing the research map directly would bypass its bundle entry, which
is where its three bundle-private colour strategies, its profile registry, its
Pinia and i18n pair and the RTL text plugin are installed — and losing those is a
runtime defect in a green build, because a thrown setup() removes the subtree
with an empty error.
Add check:composite-srcs, which resolves every nested src against the built tree
and asserts every nested tag is registered. A wrong relative depth is a 404, and
the stack layout swallows a failed section on purpose so one broken child cannot
take the whole stack down — which means the cost of that resilience is a silently
empty band. Negative-tested both ways: breaking the depth and renaming a tag each
fail with the file and path named; restoring returns to pass.
Makes the cheap half of last tick's P0 automatic. A folder rename had
rewritten three npm package names, so bun install failed in a fresh clone
while everything here passed -- the real packages were already in
node_modules from before the rename, making the breakage invisible to every
build and every gate.
Checks two things, offline, in milliseconds: the declared name resolves under
node_modules/, AND the installed package's own name field equals the declared
key. The first alone catches the capacitor case; the second catches a name
that resolves to a DIFFERENT package, which a path-only check would miss.
Together they detect the corrupted-name class without a network round-trip,
which is what lets it run on every build rather than in a nightly job nobody
watches.
It does not replace the clone test. A gate can only see what is IN the repo;
only a clone sees what the repo fails to do on a clean machine.
Negative-tested four ways: recreating the EXACT P0 (the three
@deployment/capacitor/* names) FAILS naming all three; a name that resolves
to a package whose own name differs FAILS; an absent node_modules SKIPs
honestly rather than passing or failing; an empty dependency list FAILS via
the vacuous guard. Wired into build:unlocked and confirmed running inside a
full build (17 declared, 17 verified, exit 0).
Tested the north-star path as an inheritor would, by cloning the repo from
Forgejo and running the documented first command. It FAILED:
error: GET https://registry.npmjs.org/@deployment%2fcapacitor%2fcli - 405
error: @deployment/capacitor/cli@7 failed to resolve
The rename of capacitor/ to deployment/capacitor/ did a blind string replace
that rewrote the NPM PACKAGE NAMES too. @capacitor/android, @capacitor/cli
and @capacitor/core became @deployment/capacitor/*, which do not exist on
npm. Restored the real names, pinned to ^7.6.9 -- the version actually
installed -- and fixed the two places that repeated the wrong name in prose.
This is the inheritor-only failure shape, in a form the new absolute-path
gate cannot see. The local tree works perfectly because node_modules/
@capacitor/* was installed BEFORE the rename, so every build and every gate
passes here. The breakage lived only in the DECLARED names, visible solely to
someone installing from scratch.
Verified by doing it: a fresh clone now runs bun install to exit 0 (206
packages, all three @capacitor/* resolved) and bun run build to exit 0,
producing 18 bundles / 27 profiles -- identical to the working tree.
The repo is clean -- git grep over every tracked file returns zero /home/
paths. That is a real hand-off property: a clone works on any machine, which
is what "the path Doxa inherits" requires. But nothing enforced it. Proven by
experiment: a /home/lk10x/... line planted in internal/settled-tree-gate.mjs
passed check:public-hygiene, check:parse AND check:quoted-scripts. The tree
was clean by accident, not by construction.
This is the worst failure shape for a hand-off. An absolute path works
perfectly for whoever wrote it and breaks only on the inheritor's machine, so
no test run by its author can catch it. The sibling ideation tree already
paid that cost: two docs cite a workspace root that no longer exists, so a
reader following them silently finds nothing.
Not a flag on check:public-hygiene, which already detects these patterns but
walks a SERVED tree where node_modules never exists -- pointed at the repo
root it produced 1,427 findings, nearly all from node_modules/.vite. Its
scope is right for its job. This scans `git ls-files`, the set a clone
actually receives, which excludes node_modules and build output with no
ignore list to maintain.
Negative-tested three ways: the exact planted path that beat three other
gates now FAILS with file:line; a /Users/ path in a doc FAILS; a checkout
with too few tracked files FAILS rather than passing vacuously. Wired into
build:unlocked and confirmed running inside a full build (783 files, exit 0).
It then caught its own documentation -- my reference.md row quoted the
literal patterns as examples. Rewrote the example rather than allow-listing
the file, which would have blinded the gate to a real path in the repo's
largest doc.
Last tick fixed 15 of 26 profiles that shipped with an empty description.
Without a gate that fix has a half-life: the north star is "develop a plugin
and see it live", so new plugins arrive often and each one is a fresh chance
to regress this silently. The repo already validates @element, but only
against a MALFORMED value — an ABSENT @description fails quietly, the
generator writes an empty string, every gate passes, and the defect only
surfaces to a human who opens the studio and reads.
Reads the BUILT manifest, not the .vue sources, on purpose. Re-deriving the
description here would mean reimplementing generate-tree's TAG_RE — which is
exactly what hid two of the fifteen last tick, when a hand-written copy of
that regex reported success while the real rule extracted nothing. Check the
artifact the consumer reads, never a reimplementation of how it was produced.
MIN_CHARS is calibrated against the real corpus, not guessed: shortest
genuine description 62 chars, median 81, longest 192, so the 25-char floor
sits clear of every legitimate entry while still catching a stub.
Negative-tested four ways, including end to end: deleting a @description tag
from a .vue source FAILS THE WHOLE BUILD (exit 1) naming the profile and the
file to edit; restoring it returns the build to green. Also fails on a short
stub and on a near-empty manifest (the vacuous-pass guard), rather than
passing for lack of anything to check.
Driver 2026-09-21: 'a folder called deployment which will store the capacitor
folder, wordpress folder and tauri folder', and 'a folder called documentation
to store the contributing folder so docs are in one place'.
docs/ + contributing/ + CONTRIBUTING.md -> documentation/ (three doc homes into
one). capacitor/ + wordpress/ + src-tauri/ -> deployment/.
The public/ -> dist/ rename the Driver asked about is NOT here: after seeing the
measured blast radius (855 tracked files, 54 source refs, and public/ is the
served web-root four consumers point at) the Driver withdrew it - 'keep the
public folder the same'.
Path references were rewritten mechanically (258 across 87 files), but three
classes needed hand fixes because a prefix regex cannot see them:
- join(ROOT,'docs',...) in 10 internal scripts - a quoted SEGMENT, not a prefix
- tauri frontendDist '../public' -> '../../public', since src-tauri is now one
level deeper; check:tauri caught this exactly as the card predicted
- the WordPress contract test's dirname(__DIR__,2) -> 3, same reason
Also reverted one over-reach: the rewrite renamed a FINDING-CATEGORY label
'docs' in public-hygiene-check.mjs, which is a rule name in its own header, not
a path.
Verified: full build ALL GUARDS PASS, SRI consistent; check:tauri, check:mobile
(cap sync + webDir OK), check:wp-embed, quoted-scripts, capability-citations,
capability-exemplars, library-index, public-hygiene, parse and drop all pass.
check:catalog-identity hashes each live bundle against the CATALOG'S OWN
manifest - deliberately, per its header. That proves the served tree is
internally consistent; it can never prove it is current. A catalog three
publishes behind is perfectly self-consistent and passes clean.
Measured today: after baking the Mapbox token, catalog-identity reported '17 of
17 identity-proven - PASS' while every live leaf still served BAKED_TOKEN = ''
and every map on the platform was broken.
The two gates answer different questions and neither subsumes the other:
identity is the live catalog consistent WITH ITSELF? (tamper/corruption)
currency is the live catalog the SAME BUILD as ours? (staleness)
It checks BOTH bundles and leaf pages, because a bundle-only version of this
gate also passed: baking the token changed 37 leaf HTML pages and ZERO .js
bundles. generate-tree owns the leaves, vite owns the bundles, and a publish can
change either independently.
Current reading: bundles 17 current, leaves 17 STALE.
Negative-tested: unreachable catalog SKIPs (offline must not fail a build), a
manifest yielding 0 integrity values FAILs rather than passing vacuously, and a
missing local build SKIPs.
tauri build produced target/release/doxa-maps-desktop (65 MB ELF) and a 58 MB
.deb (Package: doxa-maps, Depends: libwebkit2gtk-4.1-0 libgtk-3-0). tauri info
reports the environment fully green and correctly detects frontendDist
../public, Vue.js, Vite.
The web tree is compiled INTO the binary rather than shipped as files, which is
why the .deb holds only 6 entries - confirmed by 19 distinct embedded
doxa-maps/... asset paths in the ELF and by size (a logic-free shell is 65 MB
only because the 132 MB public/ tree is compressed inside it).
bundle.targets narrowed from all to deb: rpmbuild and linuxdeploy are absent
here, so all would fail two of three formats at the END of a ~6-minute release
build.
The CLI is a devDependency (@tauri-apps/cli, prebuilt binary) rather than
cargo install tauri-cli, which compiles from source.
Status stays BUILT, NOT SHOT. The GUI launch is unverified because this box is
headless - the binary panics in tao with 'Failed to initialize GTK', the
absence of a display rather than a defect, and no Xvfb is installed.
Method 5 was the last aspirational entry in the 5-deploy-method model: 0
tauri.conf.json, 0 Cargo.toml, 0 .rs. It is now scaffolded against the v2
config schema, mirroring the Capacitor pattern that already works.
frontendDist points at the SAME public/ tree Capacitor and the CDN serve, so
one build still feeds every consumer - now four instead of three. The crate
carries no application logic on purpose: the product is the web tree, this is
a window around it.
check:tauri is the analogue of cap:verify and exists for the same reason
capacitor.config.json warns about in capitals: a wrong frontendDist or a window
url that does not exist launches a BLANK WHITE WINDOW, not an error. It runs
with no Rust toolchain and no Tauri CLI, so CI can run it. Negative-tested five
ways - wrong frontendDist, missing window url, identifier drift from the
Capacitor appId, empty windows array, missing crate file - each exits 1, and
the config was restored byte-identical after every test.
Status stays BUILT, NOT SHOT. rustc parses main.rs (it reaches line 14 and
fails only on the uninstalled tauri crate, a dependency error not a syntax
one), but producing a binary needs the Tauri CLI plus system webview libs.
Also recorded the token/CORS caveat up front: a Tauri webview serves from
tauri://localhost, so the token endpoint must send CORS for that origin -
the same surprise Capacitor documents for https://localhost.
Last tick's fix covered the shared map-control base. Sweeping the rendered DOM of all 17
bundles found three more unnamed buttons — the same "Collapse legend" control in three
map bundles, again from one shared component. Its only content is an svg caret, so with
title alone its accessible name was empty.
It is a disclosure control, so it now reports aria-expanded as well. Verified by toggling
it: the label flips Collapse/Expand and aria-expanded flips true/false.
The gate measures the rendered DOM through shadow roots, because a source grep cannot
answer this question. There are 132 <button> hits here and most have visible text, which
IS a valid accessible name; aria-label is often on a following line. Only the DOM knows
which buttons ended up nameless. It counts aria-label, a resolved aria-labelledby, or
visible text, and deliberately does not count title, which is the defect it was written
for. Hidden buttons are skipped since assistive tech cannot reach them either.
The sweep went 319/322 before the legend fix to 322/322 after, which is the evidence it
detects real defects rather than passing vacuously.
Card 1 asks that one public Forgejo repo be the live catalog source and that the merge
lose nothing. Both halves were marked done-verified on 2026-09-19, two publishes ago, so
this re-measures rather than trusting the field: the catalog is still a zero-diff match
for the freshly-built tree, all 17 live bundles hash to their own manifest integrity, and
the live public manifest is byte-identical to the catalog's.
An HTTP status cannot make that claim. /app/doxa-maps/manifest.json returns 200 and is a
2,872-byte HTML sign-in page, so a status check reads the login wall as healthy. Every
profile already carries an integrity hash the build computed for an unrelated reason, so
comparing it against the live bytes is a cryptographic identity proof with no new source
of truth to keep in sync.
Network-dependent, so not in build:unlocked and not in CI. It distinguishes "could not
reach" from "reached and wrong" and only the latter fails, because a gate that fails on a
network blip gets switched off.
Negative-tested four ways: the login wall fails with the reason, an unreachable host
skips at exit 0, the catalog-comparison mode confirms the sha256 match, and the baseline
proves 17 of 17.
"Which bundle is the capability-N example?" has been answered wrongly in three separate
artifacts, each fixed in isolation while the others kept the bug: verify-5-capabilities
cited doxa-vision-card as cap-1, the README made the identical claim, and the
/plugin-designer skill still cites both that and unified-research-dashboard, a bundle
that does not exist.
The skill cannot be gated from this repo. It lives in the session config directory, in
no git repo, so a checker pointed at it would pass here and skip silently everywhere
else — the gate-that-cannot-fail shape. So instead of policing the skill, give the repo
an authoritative generated answer that prose can cite and a gate can verify.
The table derives from manifest.json, which the build computed from each folder's real
shape, and reference.md now points at it with the reason: a folder's apparent simplicity
does not decide its tier. doxa-vision-card looks like the lightest thing in the repo and
is capability 2, because it ships an instances file.
check:capability-exemplars regenerates and content-compares rather than checking mtimes,
because a full build rewrites manifest.json every time and a gate that cries wolf after
every build gets switched off.
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.
The badge read "27/26 zones" — a count exceeding its own total — in two shipped README
screenshots. 26 was the leaf count before slice 09b-6 added the Frontier-Asia uncovered
rows; the literal was never updated, so it drifted the moment the data grew. The
denominator is now derived from perZone.size, which tracks the zone list automatically
and cannot disagree again. Measured from clusters.json: 13 regional + 12 asia + 2
uncovered + 1 deaf = 28. The corrected badge also surfaces 1 genuinely unresolved zone
that the stale denominator was masking.
legend-tree.js asserted "33 leaves" and "7 Frontier-Asia uncovered" in a comment; the
shipped data carries 28 and 2. Corrected to point at the arrays instead of a count.
README showed "List — page 1" and "List — page 2" as the same picture. The shot loaded
one URL twice and tried to reach a page 2 by scrolling and clicking Next; neither exists,
the list has no pagination, and every attempt sat in try/catch so the miss was silent.
The PNGs differed by 0.16% render noise, so no checksum would have caught them. Mean-
while poster-wall — a real manifest-listed profile — had no screenshot at all. Shoot the
real third thing rather than fake a fourth.
check:shot-distinctness gates this, blocking in CI. It keys on url+viewport, so the
deploy2 mobile shot of the same SPA stays legitimate rather than a false positive.
README drew the two cap-5 products apart in prose and even warned that captioning a
studio shot as the product SPA is a documented mistake — then filled its plugin-studio
slot with cap5-doxa-maps-spa.png. Both cap-5 figures showed the same app and no
plugin-studio screenshot existed anywhere in the repo.
A slot filled with the wrong real image looks exactly like a correct one; nothing 404s.
So the fix is to shoot the missing artifact, not re-caption around the gap. Both cap-5
slots now use generated, freshness-gated images.
Also adds check:shot-fidelity. The shot run reported 13x OK while every emoji rendered
as a tofu box: this machine has no emoji font. Measured rather than assumed — the DOM
carries 27 correct codepoints, so the markup is right and the defect belongs to the shot
machine. Reports by default because installing a font is a Driver call; --strict blocks
once one is present.
README-SECTION-3 reproduces the scripts block verbatim and carries its own plea to
re-transcribe after every package.json edit. It drifted anyway, twice: cap:sync still
showed the bunx form that does not exist here, and build:unlocked was missing
check:public-hygiene and check:label-joins.
The second is the dangerous shape. An omission in a quoted list has no wrong character
to notice, so a reader auditing what the build verifies concludes the secret scan is not
in the chain. The warning was already there and did not work, because re-transcribing by
hand is what gets skipped when the edit felt unrelated.
Blocking in CI: it diffs two files already in the checkout, so a failure is always real.
Negative-tested both ways, including a matcher that matches too little failing rather
than passing vacuously.