diff --git a/scripts/bundle-sync.mjs b/scripts/bundle-sync.mjs index c975d4a5..25b35593 100644 --- a/scripts/bundle-sync.mjs +++ b/scripts/bundle-sync.mjs @@ -16,7 +16,7 @@ import { import { dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - assertBundleManifest, orderedBundlePayload, verifyBundleTree, + assertBundleManifest, assertOwnBundleTopology, orderedBundlePayload, verifyBundleTree, } from './bundle-tree.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -38,6 +38,9 @@ const parseManifest = (path) => { return parsed; }; const sourceManifest = assertBundleManifest(parseManifest(manifestPath), manifestPath); +// #537: this script materializes OUR dist, so the topology of the current +// build is judged here and nowhere in the shared loader-side validator. +assertOwnBundleTopology(sourceManifest, manifestPath); const managedFiles = [MANIFEST_NAME, ...sourceManifest.files.map((file) => file.path)]; const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex'); const contained = (root, name) => { diff --git a/scripts/bundle-tree.mjs b/scripts/bundle-tree.mjs index 098bfcb4..e8017f8b 100644 --- a/scripts/bundle-tree.mjs +++ b/scripts/bundle-tree.mjs @@ -35,6 +35,34 @@ const assertUniqueGraph = (manifest, field, listed, label) => { return graph; }; +/** + * Topology of the CURRENT build, checked only against our own tree (#537). + * + * `assertBundleManifest` above answers «can this manifest be loaded»: paths + * exist, nothing is duplicated, graphs reference listed assets, sizes add up. + * That question is also asked about FOREIGN trees — the performance harness + * runs the candidate's benchmark against a baseline checkout, so the + * candidate's validator reads a manifest built by an older commit. + * + * «Is the panel wired the way we decided today» is a different question, and + * #535 proved the cost of confusing them: the rule «the panel graph does not + * contain the card facade» is true of every build since #535 and false of + * every build before it, so putting it in the shared validator made the + * candidate refuse to LOAD any older baseline. All nine performance profiles + * went red at once, and the release gate withheld an asset from a published + * stable release. Topology belongs here, where only our own dist is judged. + */ +export function assertOwnBundleTopology(manifest, label = BUNDLE_MANIFEST) { + const initialPanel = manifest?.initialPanelFiles; + if (!Array.isArray(initialPanel)) { + throw new Error(`${label}: initialPanelFiles must be an array of bundle paths`); + } + if (initialPanel.includes(CARD_ENTRY)) { + throw new Error(`${label}: initial panel graph must not contain the card facade`); + } + return manifest; +} + /** Validate the additive two-entry manifest contract independently of disk I/O. */ export function assertBundleManifest(manifest, label = BUNDLE_MANIFEST) { if (manifest?.schema !== 1 || typeof manifest.fingerprint !== 'string' @@ -63,13 +91,8 @@ export function assertBundleManifest(manifest, label = BUNDLE_MANIFEST) { if (!initialView.includes(CARD_ENTRY) || initialView.includes(PANEL_ENTRY)) { throw new Error(`${label}: initial View graph must contain only the card stable entry`); } - // #535: the panel reaches the implementation by its content-hashed name, so - // the card's stable facade is no longer part of what the panel loads. That is - // the point: the facade is the one address with no version in it, and the - // panel must not depend on a URL a browser may keep for hours. What still has - // to hold is that the panel reuses the exact card IMPLEMENTATION graph. - if (initialPanel.includes(CARD_ENTRY) || !initialPanel.includes(PANEL_ENTRY)) { - throw new Error(`${label}: initial panel graph must contain its own stable entry only`); + if (!initialPanel.includes(PANEL_ENTRY)) { + throw new Error(`${label}: initial panel graph must contain its own stable entry`); } if (initialView.some((path) => path !== CARD_ENTRY && !initialPanel.includes(path))) { throw new Error(`${label}: initial View implementation is not a subset of initial panel graph`); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 56c18c63..f122ff2a 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -3124,6 +3124,25 @@ const MUTANT_DEFINITIONS = [ replace: ' void panelEntry; // mutant: cached panel keeps its static shared-chunk import', }], }, + { + id: 'manifest-loader-judges-current-topology', + guard: 'node --test --test-name-pattern="#537 loader-side validation" ' + + 'test/bundle-assets.test.mjs', + because: 'the loader-side validator also reads FOREIGN manifests — the performance harness ' + + 'runs the candidate against a baseline checkout — so a rule about how the CURRENT build ' + + 'is wired makes the candidate refuse to load any older baseline. That is what turned all ' + + 'nine performance profiles red at once and withheld an asset from a published stable ' + + 'release (#537)', + patches: [{ + file: 'scripts/bundle-tree.mjs', + find: ' if (!initialPanel.includes(PANEL_ENTRY)) {\n' + + ' throw new Error(`${label}: initial panel graph must contain its own stable entry`);\n' + + ' }', + replace: ' if (initialPanel.includes(CARD_ENTRY) || !initialPanel.includes(PANEL_ENTRY)) {\n' + + ' throw new Error(`${label}: initial panel graph must contain its own stable entry`);\n' + + ' }', + }], + }, { id: 'panel-imports-unversioned-card-entry', guard: 'node --test --test-name-pattern="#486 both stable entries" ' diff --git a/test/bundle-assets.test.mjs b/test/bundle-assets.test.mjs index 6192030c..eeabd461 100644 --- a/test/bundle-assets.test.mjs +++ b/test/bundle-assets.test.mjs @@ -19,8 +19,8 @@ import { lowHeadroomWarning, } from '../scripts/bundle-budget.mjs'; import { - assertBundleManifest, compareBundleTrees, orderedBundlePayload, sha256Bytes, - verifyBundleTree, + assertBundleManifest, assertOwnBundleTopology, compareBundleTrees, + orderedBundlePayload, sha256Bytes, verifyBundleTree, } from '../scripts/bundle-tree.mjs'; import { cssTemplateMinifier, minifyCssText, minifyStaticCssTemplates, @@ -296,20 +296,30 @@ test('#486 manifest graph validator rejects missing panel roots and duplicated c // #535: панель обязана переиспользовать ту же реализацию, а не свою копию. assert.throws( () => assertBundleManifest(duplicateGraph), - /initial panel graph must contain its own stable entry only|not a subset/, + /initial panel graph must contain its own stable entry|not a subset/, ); +}); - // Обратная сторона того же инварианта: фасад карточки в графе панели — - // теперь ошибка, потому что это единственный адрес без версии (#535). - const facadeInPanel = structuredClone(valid); - facadeInPanel.initialPanelFiles = [ +test('#537 loader-side validation accepts a pre-#535 manifest; topology does not', () => { + // Манифест той топологии, что была до #535: фасад карточки входит в граф + // панели. Ровно такой лежит в каждой базе сравнения старше #535, и + // производительный харнесс читает его валидатором КАНДИДАТА — поэтому + // «можно ли это загрузить» обязано отвечать «да». + const legacy = minimalTwoEntryManifest(); + legacy.initialPanelFiles = [ 'houseplan-assets/card-HASH.js', 'houseplan-card.js', 'houseplan-panel.js', ]; - facadeInPanel.initialPanelGzipBytes = 14; + legacy.initialPanelGzipBytes = 14; + assert.equal(assertBundleManifest(legacy), legacy, + 'валидатор загрузки обязан принимать манифест базы прежней топологии'); + + // А «так ли устроена ТЕКУЩАЯ сборка» — отдельный вопрос и отдельная функция. assert.throws( - () => assertBundleManifest(facadeInPanel), - /initial panel graph must contain its own stable entry only/, + () => assertOwnBundleTopology(legacy), + /initial panel graph must not contain the card facade/, ); + const current = minimalTwoEntryManifest(); + assert.equal(assertOwnBundleTopology(current), current); }); test('#486 sync payload orders dependencies before both stable entries', () => {