check:frozen-labels in gates:post: a label resolved once never re-resolves on a locale switch
Written by the UPG-100 plugin owner. It parses templates and scripts and flags a translator result stored at module or setup scope, or tStatic inside a computed. A production profile or library/ fails; an experimental profile is reported. It found the search header's eight labels, fixed in the commit before this, so it arrives green.
This commit is contained in:
parent
ce82467dae
commit
dbbcd5bc45
2 changed files with 238 additions and 1 deletions
234
internal/check-frozen-labels.mjs
Normal file
234
internal/check-frozen-labels.mjs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-frozen-labels — a label resolved ONCE that never re-resolves when the locale changes.
|
||||
*
|
||||
* THE DEFECT, and it is created BY FIXING ANOTHER ONE. `const TABS = [{ label: t('…') }]` at
|
||||
* module/setup scope evaluates once. ProfileLoader drives appI18n.global.locale at RUNTIME, so a
|
||||
* mid-session language switch leaves that label in the boot language while everything around it
|
||||
* changes. A computed re-reads on access; a const does not. This is a DIFFERENT defect from
|
||||
* hardcoded English with the SAME symptom: hardcoded English never consults the catalogue,
|
||||
* a frozen label consults it once and caches. check:hardcoded-english finds the first and cannot
|
||||
* see the second — and binding a literal to t() in the wrong scope converts one into the other.
|
||||
*
|
||||
* tStatic() freezes the same way for a different reason: it reads getRuntimeLocale() AT CALL TIME
|
||||
* (library/i18n/index.js), so RE-CALLING it picks up a change; storing its result once does not.
|
||||
*
|
||||
* ★ PARSED, NOT REGEXED. A regex cannot tell `const X = t('a')` from
|
||||
* `const X = computed(() => t('a'))` across a multi-line array. Templates go through
|
||||
* @vue/compiler-sfc, scripts through @babel/parser; the test is on the AST.
|
||||
*
|
||||
* ★ SCOPED BY PROFILE STAGE, NOT BUNDLE STAGE. bundle.stage reads 'production' if ANY profile is,
|
||||
* and FOUR bundles are mixed (cities-internet, gospel-resources, my-upg-100-list, regions-dashboard
|
||||
* — measured 2026-09-28). A .vue file IS a profile, so the file's own profile.stage is the honest
|
||||
* key. Scoping by bundle.stage would FAIL on experimental files in a production bundle.
|
||||
* production profile or library/ -> FAIL (exit 1)
|
||||
* experimental profile -> REPORTED, never fails
|
||||
*
|
||||
* ⚠ CANNOT TELL WHETHER A FROZEN LABEL IS RENDERED. A frozen const in dead code is not a
|
||||
* user-visible defect; refs= is a reference count, a proxy, not proof it reaches a screen.
|
||||
*
|
||||
* Deliberately NOT flagged (all re-evaluate, or are pinned on purpose): t() inside a
|
||||
* function/arrow/method body, inside computed()/watch()/watchEffect(), in a template expression,
|
||||
* and tStatic(path, explicitLocale).
|
||||
*
|
||||
* NEGATIVE-TESTED: --self-test plants 4 bad shapes it must catch and 6 good ones it must not.
|
||||
* Exit 0 clean · 1 a production/library finding · 2 parse error.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
||||
import { join, relative, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc'
|
||||
import * as babel from '@babel/parser'
|
||||
|
||||
const TRANSLATORS = new Set(['t', 'tStatic', '$t'])
|
||||
// A call is "reactive" if any ANCESTOR is one of these: the value is recomputed later.
|
||||
const REACTIVE_WRAPPERS = new Set(['computed', 'watch', 'watchEffect', 'watchPostEffect', 'defineExpose'])
|
||||
|
||||
function parseScript(code) {
|
||||
return babel.parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript', 'topLevelAwait', 'importMeta'],
|
||||
errorRecovery: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** Walk the AST, tracking whether we are inside a function body or a reactive wrapper. */
|
||||
function findFrozen(ast, aliases) {
|
||||
const out = []
|
||||
const visit = (node, parents) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
if (Array.isArray(node)) { for (const n of node) visit(n, parents); return }
|
||||
if (!node.type) return
|
||||
|
||||
if (node.type === 'CallExpression') {
|
||||
const callee = node.callee
|
||||
const name = callee?.type === 'Identifier' ? callee.name
|
||||
: callee?.type === 'MemberExpression' && callee.property?.type === 'Identifier' ? callee.property.name
|
||||
: null
|
||||
if (name && (TRANSLATORS.has(name) || aliases.has(name))) {
|
||||
// tStatic(path, EXPLICIT_LOCALE) — the caller pinned it deliberately.
|
||||
const pinned = name === 'tStatic' && node.arguments.length > 1
|
||||
// ★ tStatic IS NEVER LIVE, WHEREVER IT SITS (exp, 2026-09-28). It resolves the locale via
|
||||
// getRuntimeLocale(), which reads a PLAIN window property — not a Vue ref — so a computed()
|
||||
// wrapping it has NO reactive dependency and never invalidates. computed(() => tStatic(x))
|
||||
// is strictly WORSE than the naked call: it looks like the fix, so nobody looks again.
|
||||
// Only a reactive translator (vue-i18n t / useOptionalT) counts as live inside a wrapper.
|
||||
// ⚠ BUT A PLAIN FUNCTION BODY STILL RE-RUNS. tStatic inside a function/arrow that a caller
|
||||
// INVOKES re-reads the global each call (useOptionalT:74 returns exactly such an arrow), so
|
||||
// only a REACTIVE WRAPPER is the lie: computed/watch cache the result and never invalidate.
|
||||
const inReactiveWrapper = parents.some((p) =>
|
||||
p.type === 'CallExpression' && p.callee?.type === 'Identifier' && REACTIVE_WRAPPERS.has(p.callee.name))
|
||||
const neverLive = name === 'tStatic' && !pinned && inReactiveWrapper
|
||||
// Inside any function body or reactive wrapper => re-evaluated later.
|
||||
const shielded = parents.some((p) =>
|
||||
p.type === 'FunctionDeclaration' || p.type === 'FunctionExpression' ||
|
||||
p.type === 'ArrowFunctionExpression' || p.type === 'ObjectMethod' ||
|
||||
p.type === 'ClassMethod' ||
|
||||
(p.type === 'CallExpression' && p.callee?.type === 'Identifier' && REACTIVE_WRAPPERS.has(p.callee.name)))
|
||||
if ((!shielded || neverLive) && !pinned) {
|
||||
// Name the binding it freezes into, when there is one.
|
||||
const decl = parents.find((p) => p.type === 'VariableDeclarator')
|
||||
out.push({
|
||||
line: node.loc?.start.line ?? 0,
|
||||
binding: decl?.id?.type === 'Identifier' ? decl.id.name : '(expression)',
|
||||
call: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const next = [...parents, node]
|
||||
for (const k of Object.keys(node)) {
|
||||
if (k === 'loc' || k === 'range' || k === 'leadingComments' || k === 'trailingComments') continue
|
||||
visit(node[k], next)
|
||||
}
|
||||
}
|
||||
visit(ast.program ?? ast, [])
|
||||
return out
|
||||
}
|
||||
|
||||
/** `const t2 = t` / `const { t } = useI18n()` — alias discovery, so a rename cannot hide a call. */
|
||||
function findAliases(code) {
|
||||
const set = new Set()
|
||||
for (const m of code.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*(t|tStatic|useOptionalT\([^)]*\))\s*[;\n]/g)) set.add(m[1])
|
||||
for (const m of code.matchAll(/const\s*\{\s*([^}]*)\}\s*=\s*useI18n\(\)/g)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const [orig, alias] = part.split(':').map((s) => s.trim())
|
||||
if (orig === 't') set.add(alias || 't')
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
export function scanSource(code) {
|
||||
const aliases = findAliases(code)
|
||||
let ast
|
||||
try { ast = parseScript(code) } catch { return [] }
|
||||
return findFrozen(ast, aliases)
|
||||
}
|
||||
|
||||
export function scanVue(src) {
|
||||
const { descriptor } = parseSfc(src)
|
||||
const block = descriptor.scriptSetup || descriptor.script
|
||||
if (!block) return []
|
||||
const offset = src.slice(0, block.loc.start.offset).split('\n').length - 1
|
||||
return scanSource(block.content).map((f) => ({ ...f, line: f.line + offset }))
|
||||
}
|
||||
|
||||
// ── SELF-TEST ───────────────────────────────────────────────────────────────
|
||||
const CASES = [
|
||||
['BAD const array of t() labels', () => scanSource("const { t } = useI18n()\nconst TABS = [{ label: t('a.b') }]").length, 1],
|
||||
['BAD t() straight into a const', () => scanSource("const { t } = useI18n()\nconst title = t('a.b')").length, 1],
|
||||
['BAD tStatic() into a const', () => scanSource("const title = tStatic('a.b')").length, 1],
|
||||
['BAD through an ALIAS of t', () => scanSource("const { t } = useI18n()\nconst t2 = t\nconst x = t2('a.b')").length, 1],
|
||||
['GOOD computed(() => t())', () => scanSource("const { t } = useI18n()\nconst TABS = computed(() => [{ label: t('a.b') }])").length, 0],
|
||||
['GOOD inside a function body', () => scanSource("const { t } = useI18n()\nfunction f() { return t('a.b') }").length, 0],
|
||||
['GOOD inside an arrow', () => scanSource("const { t } = useI18n()\nconst f = () => t('a.b')").length, 0],
|
||||
['GOOD tStatic with explicit locale', () => scanSource("const x = tStatic('a.b', 'en')").length, 0],
|
||||
['GOOD watch(() => t())', () => scanSource("const { t } = useI18n()\nwatch(src, () => t('a.b'))").length, 0],
|
||||
// ★ exp's false negative: a computed cannot make tStatic live.
|
||||
['BAD computed(() => tStatic()) is STILL frozen', () => scanSource("const X = computed(() => tStatic('a.b'))").length, 1],
|
||||
// A function BODY re-reads the global on every call (useOptionalT:74 returns such an arrow),
|
||||
// so it is NOT frozen. Only a reactive wrapper caches it. I had this case backwards at first.
|
||||
['GOOD tStatic in a function body re-runs', () => scanSource("function f(){ return tStatic('a.b') }").length, 0],
|
||||
['GOOD computed(() => t()) IS live', () => scanSource("const { t } = useI18n()\nconst X = computed(() => t('a.b'))").length, 0],
|
||||
['GOOD computed(() => tStatic(x, loc)) is pinned', () => scanSource("const X = computed(() => tStatic('a.b', 'en'))").length, 0],
|
||||
['GOOD no translator at all', () => scanSource("const TABS = [{ label: 'Region' }]").length, 0],
|
||||
]
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
let ok = 0
|
||||
for (const [name, run, want] of CASES) {
|
||||
let got
|
||||
try { got = run() } catch (e) { got = `ERR ${e.message}` }
|
||||
const pass = got === want
|
||||
console.log(` ${pass ? '✓' : '✗'} ${name} -> ${got} (want ${want})`)
|
||||
if (pass) ok++
|
||||
}
|
||||
console.log(ok === CASES.length
|
||||
? `FREEZE-CENSUS SELF-TEST: PASS — ${ok}/${CASES.length}, every rule proven able to fail`
|
||||
: `FREEZE-CENSUS SELF-TEST: FAIL — ${ok}/${CASES.length}`)
|
||||
process.exit(ok === CASES.length ? 0 : 1)
|
||||
}
|
||||
|
||||
// ── RUN ─────────────────────────────────────────────────────────────────────
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const TREE = join(ROOT, `public/${TREE_NAME}`)
|
||||
|
||||
// profile.stage per FILE. A .vue under plugin-designs/<bundle>/<profile>.vue IS that profile.
|
||||
// Anything under library/ is shared by every bundle, so it is held to the production bar.
|
||||
function stageIndex() {
|
||||
const mf = join(TREE, 'manifest.json')
|
||||
if (!existsSync(mf)) return null
|
||||
const m = JSON.parse(readFileSync(mf, 'utf8'))
|
||||
const byFile = new Map()
|
||||
for (const b of m.bundles || []) for (const p of b.profiles || []) {
|
||||
byFile.set(join('plugin-designs', b.bundle, p.profile + '.vue'), p.stage || b.stage || 'experimental')
|
||||
}
|
||||
return byFile
|
||||
}
|
||||
|
||||
const walk = (dir, acc = []) => {
|
||||
if (!existsSync(dir)) return acc
|
||||
for (const e of readdirSync(dir)) {
|
||||
if (e === 'node_modules' || e === '.git' || e === 'public') continue
|
||||
const p = join(dir, e)
|
||||
if (statSync(p).isDirectory()) walk(p, acc)
|
||||
else if (/\.(vue|js|mjs)$/.test(e)) acc.push(p)
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
const stages = stageIndex()
|
||||
if (!stages) {
|
||||
console.log(`FROZEN-LABELS: SKIP — no built tree at public/${TREE_NAME}. Run a build first.`)
|
||||
console.log(' Reported as a SKIP, not a pass: a gate that reports success when it never ran is worse than no gate.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const files = ['library', 'plugin-designs'].flatMap((r) => walk(join(ROOT, r)))
|
||||
const failures = [], reported = []
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8')
|
||||
let hits
|
||||
try { hits = f.endsWith('.vue') ? scanVue(src) : scanSource(src) }
|
||||
catch (e) { console.error(`FROZEN-LABELS: parse error in ${relative(ROOT, f)} — ${e.message}`); process.exit(2) }
|
||||
if (!hits.length) continue
|
||||
const rel = relative(ROOT, f)
|
||||
// library/ is held to the production bar; a plugin file takes its own PROFILE stage.
|
||||
const stage = rel.startsWith('library/') ? 'production' : (stages.get(rel) || 'experimental')
|
||||
for (const h of hits) {
|
||||
const refs = h.binding === '(expression)' ? -1 : (src.match(new RegExp(`\\b${h.binding}\\b`, 'g')) || []).length - 1
|
||||
const row = `${rel}:${h.line} ${h.binding} (${h.call}) refs=${refs < 0 ? '?' : refs}`
|
||||
;(stage === 'production' ? failures : reported).push(row)
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of reported) console.log(` · ${r} [experimental — reported, not failing]`)
|
||||
for (const r of failures) console.log(` ✗ ${r} [PRODUCTION]`)
|
||||
if (failures.length) {
|
||||
console.log(`FROZEN-LABELS: FAIL — ${failures.length} label(s) in a PRODUCTION profile or library/ resolve once and never re-resolve on a locale change. Wrap in computed().`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`FROZEN-LABELS: PASS — 0 in production/library${reported.length ? `, ${reported.length} reported in experimental` : ''}.`)
|
||||
process.exit(0)
|
||||
|
|
@ -87,6 +87,8 @@
|
|||
"check:fits-screen:self-test:d": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=d",
|
||||
"check:fits-screen:self-test:e": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=e",
|
||||
"check:fixed-anchoring": "node internal/check-fixed-anchoring.mjs",
|
||||
"check:frozen-labels": "node internal/check-frozen-labels.mjs",
|
||||
"check:frozen-labels:self-test": "node internal/check-frozen-labels.mjs --self-test",
|
||||
"check:gallery-embed": "node internal/check-research-gallery-embed.mjs",
|
||||
"check:gallery-embed:iframe": "node internal/check-research-gallery-embed.mjs --mode iframe",
|
||||
"check:gallery-embed:js": "node internal/check-research-gallery-embed.mjs --mode js",
|
||||
|
|
@ -260,7 +262,7 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
|
|
@ -361,6 +363,7 @@
|
|||
"check:fits-screen:self-test:c": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:fits-screen:self-test:d": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:fits-screen:self-test:e": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:frozen-labels": "a label resolved ONCE that never re-resolves on a mid-session locale switch: t() or tStatic() stored at module/setup scope, or tStatic() inside a computed (it reads a plain global the computed cannot track). Parsed, not regexed. Scoped by PROFILE stage: a production profile or library/ FAILS, an experimental profile is REPORTED only. In gates:post. The :self-test plants 4 bad shapes and 6 good ones (14 assertions).",
|
||||
"check:gallery-embed": "the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes, bare <script>+<doxa-maps-spa> and <iframe>, with the layout inside the host box and every curated tab painting a map. The summary line prints the tab count PER MODE, and a mode that checked zero tabs is a FAIL. One mode only: check:gallery-embed:js or check:gallery-embed:iframe. Starts its own servers on OS-assigned ports; needs only a built tree. Exit 2 = SKIP (no built tree / no playwright).",
|
||||
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on the live test host): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
|
||||
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue