diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs index 14e11fae5..3cac8e1f0 100644 --- a/internal/check-hardcoded-english.mjs +++ b/internal/check-hardcoded-english.mjs @@ -44,6 +44,7 @@ import { pathToFileURL } from 'node:url' import { execSync } from 'node:child_process' import { parse } from '@vue/compiler-sfc' import * as babel from '@babel/parser' +import { productionForGating, stageReason } from './lib/effective-stage.mjs' // ── what counts as user-visible ───────────────────────────────────────────────────────────────── const VISIBLE_ATTRS = new Set(['title', 'placeholder', 'aria-label', 'alt', 'label']) @@ -408,13 +409,19 @@ export function scanSfc(src, file) { // ── production plugins: the scope this gate defends ───────────────────────────────────────────── // Read from the built manifest so the list cannot drift from what actually ships. +// ★ EFFECTIVE stage, not declared stage. A production leaf composes other bundles at runtime, so a +// reader on a production page sees code from bundles that declare themselves experimental — and +// scoping by the declared stage held that code to the experimental bar. The rule and the graph live +// in lib/effective-stage.mjs, shared with check:frozen-labels so the two gates cannot disagree about +// what "production" means. function productionPluginDirs(root = '.') { const mf = join(root, 'public/dx-maps/manifest.json') if (!existsSync(mf)) return [] const m = JSON.parse(readFileSync(mf, 'utf8')) - const names = m.bundles.filter((b) => b.stage === 'production').map((b) => b.bundle) - return names.map((n) => join(root, 'plugin-designs', n)).filter((d) => existsSync(d)) + MANIFEST = m + return productionForGating(m).map((n) => join(root, 'plugin-designs', n)).filter((d) => existsSync(d)) } +let MANIFEST = null const sfcsIn = (dir) => { const out = [] const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { @@ -485,6 +492,17 @@ const msg = t('engineMissing') const t2 = useOptionalT('regions') const ok2 = t2('Countries')` +// ★ EFFECTIVE STAGE: a production leaf pulls experimental bundles into the production bar. +const STAGE_FIXTURE = { + bundles: [ + { bundle: 'leaf-prod', stage: 'production', composes: ['mid-exp', 'pair/with-profile'] }, + { bundle: 'mid-exp', stage: 'experimental', composes: ['deep-exp'] }, + { bundle: 'deep-exp', stage: 'experimental' }, + { bundle: 'pair', stage: 'experimental' }, + { bundle: 'lonely-exp', stage: 'experimental' }, + ], +} + function selfTest() { let fails = 0 const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ } @@ -562,6 +580,12 @@ function selfTest() { const xw = crossAreaKeys(XAREA_WRAPPED, 'planted-xarea-wrapped.vue') ok(xw.length === 1 && /pplr\.messages\.mapUnavailable/.test(xw[0].text), 'SEES it through a WRAPPER factory with no literal namespace (usePplrT() → useOptionalT(\'pplr\'))') + const eff = productionForGating(STAGE_FIXTURE) + ok(eff.includes('mid-exp'), 'an experimental bundle COMPOSED by a production leaf is production-for-gating') + ok(eff.includes('deep-exp'), 'and TRANSITIVELY — composition nests, so one pass is not enough') + ok(eff.includes('pair'), "a composes entry of the form 'bundle/profile' resolves to its BUNDLE") + ok(!eff.includes('lonely-exp'), 'CONTROL: an experimental bundle nobody composes stays out of scope') + ok(stageReason(STAGE_FIXTURE, 'mid-exp').startsWith('composed by'), 'and the report can say WHY it is in scope') console.log(fails ? `\nSELF-TEST: ${fails} FAIL(s)` : '\nSELF-TEST: the gate can see planted leaks and stays quiet on correct code') return fails } @@ -697,7 +721,13 @@ const leaks = findings.filter((f) => !f.deliberate && !f.suspect) // WHICH BYTES, so two correct runs are indistinguishable in a report. The stamp is therefore not // only for the writer who forgets to name a sha — it is what lets a READER tell two runs apart at // all. When counts disagree, compare the tree line before anything else. -console.log(`\n ${dirs.length} production plugin(s), ${files} SFC(s) scanned · tree ${treeStamp()}`) +const composed = MANIFEST ? dirs.map((d) => d.split('/').pop()).filter((n) => stageReason(MANIFEST, n).startsWith('composed')) : [] +console.log(`\n ${dirs.length} production-for-gating plugin(s), ${files} SFC(s) scanned · tree ${treeStamp()}`) +if (composed.length) { + // Named, because "why is this experimental bundle in a production scan?" is the first question a + // reader will have, and the answer is a fact about the composition graph rather than a judgement. + console.log(` including ${composed.length} reached only by composition: ${composed.map((n) => `${n} (${stageReason(MANIFEST, n)})`).join('; ')}`) +} for (const f of leaks) console.log(` ${f.kind.padEnd(16)} ${f.file.replace(/^.*plugin-designs\//, '')} ${JSON.stringify(f.text).slice(0, 90)}`) if (deliberate.length) { // Listed so nobody wonders where they went, but NOT counted: dir="ltr" is the plugin's own marker diff --git a/internal/lib/effective-stage.mjs b/internal/lib/effective-stage.mjs new file mode 100644 index 000000000..e313e5d01 --- /dev/null +++ b/internal/lib/effective-stage.mjs @@ -0,0 +1,72 @@ +// EFFECTIVE stage: what bar a bundle should be held to, rather than what stage it declares. +// +// ★ THE GAP THIS CLOSES (sr, 2026-09-28). A gate that scopes by a bundle's OWN stage misses what a +// production reader actually sees. research-map-plus-grid is production and composes +// doxa-research-map and people-groups-grid, both declared experimental; dashboard-plus-grid is +// production and composes research-dashboard and people-groups-grid. So the code on a production +// page was being held to the experimental bar — not because anyone decided that, but because the +// scope was computed from the wrong end of the composition. +// +// THE RULE: a bundle is production-for-gating if it declares production, OR if any bundle that is +// itself production-for-gating composes it. Transitive, because composition nests. +// +// ★ THE GRAPH'S EDGES HAVE TWO SHAPES and taking one and not the other silently halves it: +// `composes` holds both bare bundle names ('doxa-research-map') and bundle/profile pairs +// ('regions-dashboard/regions-list'). The bundle is the part before the slash in either case. +// +// Shared by check:hardcoded-english and check:frozen-labels so the two cannot disagree about what +// "production" means — a disagreement that would show up as one gate passing a file the other fails, +// with neither obviously wrong. + +/** The bundle a `composes` entry refers to: 'a/b' → 'a', 'a' → 'a'. */ +export const composedBundle = (entry) => String(entry || '').split('/')[0] + +/** + * @param {{bundles?: Array<{bundle?: string, stage?: string, composes?: string[]}>}} manifest + * @returns {Map} effective stage per bundle name + */ +export function effectiveStages(manifest) { + const bundles = (manifest && manifest.bundles) || [] + const byName = new Map() + for (const b of bundles) if (b && b.bundle) byName.set(b.bundle, b) + + const eff = new Map() + for (const b of bundles) if (b && b.bundle) eff.set(b.bundle, b.stage === 'production' ? 'production' : 'experimental') + + // Walk down from every production root, marking what it composes. Repeat until nothing changes: + // a production bundle may compose one that composes another, and a single pass would stop at the + // first level. The visited set makes a cycle terminate rather than hang. + let changed = true + while (changed) { + changed = false + for (const [name, stage] of [...eff]) { + if (stage !== 'production') continue + const b = byName.get(name) + for (const entry of (b && b.composes) || []) { + const child = composedBundle(entry) + if (!eff.has(child)) continue // composes something not in the manifest + if (eff.get(child) === 'production') continue + eff.set(child, 'production') + changed = true + } + } + } + return eff +} + +/** Bundle names a gate should treat as production. */ +export function productionForGating(manifest) { + return [...effectiveStages(manifest)].filter(([, s]) => s === 'production').map(([n]) => n).sort() +} + +/** Why a bundle is in scope — for a report that must explain itself. */ +export function stageReason(manifest, name) { + const bundles = (manifest && manifest.bundles) || [] + const self = bundles.find((b) => b && b.bundle === name) + if (self && self.stage === 'production') return 'declares production' + const parents = bundles + .filter((b) => (b.composes || []).some((e) => composedBundle(e) === name)) + .filter((b) => effectiveStages(manifest).get(b.bundle) === 'production') + .map((b) => b.bundle) + return parents.length ? `composed by ${parents.join(', ')}` : 'experimental' +}