diff --git a/internal/check-studio-rail.mjs b/internal/check-studio-rail.mjs index d3637a4c3..d87aa0c37 100644 --- a/internal/check-studio-rail.mjs +++ b/internal/check-studio-rail.mjs @@ -25,8 +25,17 @@ import { TREE_NAME } from './_tree-name.mjs' function undeclaredLabels(manifest) { const out = [] for (const b of manifest.bundles || []) { - if (b.stage !== 'production') continue for (const p of b.profiles || []) { + // ⚠ PROFILE STAGE, NOT BUNDLE STAGE — the same trap, a third time. A BUNDLE reads + // `stage: production` when ANY ONE of its profiles is production, so scoping the loop by + // b.stage drags every experimental sibling in with it. Measured: cities-internet is a + // production bundle whose connectivity-list and data-tables are EXPERIMENTAL, and + // gospel-resources likewise for charts / data-tables / resources-list. That was 5 false + // violations out of 7 (regions-plugin caught it, 2026-09-28). + // I had already moved off stageOf (bundle-keyed) once; moving to b.stage was the SAME + // mistake wearing a different field name. The profile's own stage is the only one that + // answers "is this row production". + if (p.stage !== 'production') continue const declared = p.label if (declared === null || declared === undefined || !String(declared).trim()) { out.push(`${b.bundle}/${p.profile} is PRODUCTION but declares no "label" in its .meta.json — the rail falls back to a humanized id, which names nothing`) @@ -42,11 +51,20 @@ function undeclaredLabels(manifest) { // fails everything is as useless as one that fails nothing. if (process.argv.includes('--self-test')) { const fixture = { bundles: [ - { bundle: 'planted-prod', stage: 'production', profiles: [{ profile: 'nameless', label: null }] }, - { bundle: 'planted-exp', stage: 'experimental', profiles: [{ profile: 'nameless', label: null }] }, - { bundle: 'planted-ok', stage: 'production', profiles: [{ profile: 'named', label: 'A Real Name' }] }, + { bundle: 'planted-prod', stage: 'production', profiles: [{ profile: 'nameless', stage: 'production', label: null }] }, + { bundle: 'planted-exp', stage: 'experimental', profiles: [{ profile: 'nameless', stage: 'experimental', label: null }] }, + { bundle: 'planted-ok', stage: 'production', profiles: [{ profile: 'named', stage: 'production', label: 'A Real Name' }] }, // An EMPTY STRING is nameless too — `label: ""` would satisfy a null check and render blank. - { bundle: 'planted-blank', stage: 'production', profiles: [{ profile: 'blank', label: ' ' }] }, + { bundle: 'planted-blank', stage: 'production', profiles: [{ profile: 'blank', stage: 'production', label: ' ' }] }, + // ★ THE CASE THE FIRST SELF-TEST MISSED, and the bug it would have caught. A BUNDLE reads + // production when ANY profile is, so a mixed bundle must flag ONLY its production profile. + // The earlier fixture gave every bundle one profile, so bundle-stage and profile-stage agreed + // and scoping by the wrong one was invisible. A fixture where the two CANNOT disagree cannot + // test which one you read. + { bundle: 'planted-mixed', stage: 'production', profiles: [ + { profile: 'prod-nameless', stage: 'production', label: null }, + { profile: 'exp-nameless', stage: 'experimental', label: null }, + ] }, ] } const hits = undeclaredLabels(fixture) const cases = [ @@ -54,6 +72,8 @@ if (process.argv.includes('--self-test')) { ['experimental + no label PASSES', !hits.some((h) => h.startsWith('planted-exp/'))], ['production + a real label PASSES', !hits.some((h) => h.startsWith('planted-ok/'))], ['production + whitespace-only FAILS', hits.some((h) => h.startsWith('planted-blank/'))], + ['mixed bundle: its PRODUCTION profile FAILS', hits.some((h) => h.startsWith('planted-mixed/prod-nameless'))], + ['mixed bundle: its EXPERIMENTAL profile PASSES', !hits.some((h) => h.startsWith('planted-mixed/exp-nameless'))], ] let ok = 0 for (const [name, pass] of cases) { console.log(` ${pass ? '✓' : '✗'} ${name}`); if (pass) ok++ }