diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 17bbb5c11..3c7e2f796 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -739,11 +739,59 @@ export default defineConfig({ { name: 'vendor-i18n', test: /[\\/]node_modules[\\/](i18next|react-i18next)[\\/]/, priority: 90 }, // Workspace packages — match by realpath, since pnpm may resolve // through node_modules/@object-ui/ symlinks to packages/. + // + // ## Why two tiers and not one (objectui#7399) + // + // These five groups were ALL `priority: 80` with `framework` written + // first, and that tie was NOT benign. Measured on `e307c9896`, the + // emitted `framework` chunk held 166 modules and only 145 of them + // came from `core|react|types` — its own test: + // + // | packages/i18n | 16 mod | 78.7% of the chunk's bytes | + // | packages/core | 67 mod | 9.6% | + // | packages/react | 63 mod | 4.7% | + // | packages/data-objectstack | 5 mod | 4.6% | + // | packages/types | 15 mod | 2.3% | + // + // `framework`'s regex does not match EITHER of the two intruders, and + // `data-adapter` — declared below since objectui#5490 — emitted no + // chunk at all. On a tie the subgraph reached through + // `@object-ui/react` was absorbed by the group listed first, so + // `PER_CHUNK_GZIP_CEILINGS['framework']` was in operation a budget on + // the TRANSLATION CATALOGUE: 523,959 gzipped bytes against a 524,000 + // ceiling, 41 bytes of headroom for the whole repository, and a gate + // whose message ("don't grow core|react|types") pointed away from its + // own cause. Five PRs adding ruled user-facing copy were parked on it. + // + // So the two groups the tie was swallowing are lifted one tier ABOVE + // it, and `i18n` leaves `infrastructure`'s alternation because a + // dedicated group now owns it (a dead alternative in a live regex is + // the next silent drift). Tier 84 sits above the general workspace + // groups and below the vendor tier at 85+; its two members' tests are + // disjoint from each other and from every vendor test, so lifting + // them introduces no new tie. + // + // ⛔ This moves NO MODULE BYTES. The eager closure holds the same + // module set before and after; what changes is which file each + // module is written to. Measured cost of the extra chunk + // boundaries: eager closure 3,255,233 -> 3,256,012 gzipped, +779 B + // (+0.024%), raw +837 B — all of it `import` bookkeeping in the + // chunks that now name two files where they named one. Nothing here + // may be read as headroom that was earned. + // + // ⚠️ Disclosed rather than smoothed over: `data-adapter` now also + // holds 5 modules (8.5 KB raw) from `core`/`types` that are reached + // ONLY through `data-objectstack`. That is the same shared-module + // pull-in this comment is about, one tier down and three orders of + // magnitude smaller. It is rolldown's behaviour, not a choice + // available here: `framework` cannot be lifted above these two + // without re-absorbing the catalogue, which is the whole defect. + { name: 'i18n-locales', test: /[\\/]packages[\\/]i18n[\\/]/, priority: 84 }, + { name: 'data-adapter', test: /[\\/]packages[\\/]data-objectstack[\\/]/, priority: 84 }, { name: 'framework', test: /[\\/]packages[\\/](core|react|types)[\\/]/, priority: 80 }, { name: 'ui-components', test: /[\\/]packages[\\/](components|fields)[\\/]/, priority: 80 }, { name: 'ui-layout', test: /[\\/]packages[\\/]layout[\\/]/, priority: 80 }, - { name: 'data-adapter', test: /[\\/]packages[\\/]data-objectstack[\\/]/, priority: 80 }, - { name: 'infrastructure', test: /[\\/]packages[\\/](auth|permissions|tenant|i18n)[\\/]/, priority: 80 }, + { name: 'infrastructure', test: /[\\/]packages[\\/](auth|permissions|tenant)[\\/]/, priority: 80 }, // Plugins — one chunk per plugin so dynamic imports cleave cleanly. { name: 'plugin-grid', test: /[\\/]packages[\\/]plugin-grid[\\/]/, priority: 70 }, { name: 'plugin-form', test: /[\\/]packages[\\/]plugin-form[\\/]/, priority: 70 }, diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index ad18a6694..15ab20acf 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -389,6 +389,146 @@ describe('per-chunk ceilings', () => { * plus the three per-chunk lines objectui#5490 added), each weighed against its * own measurement in the report. */ +/** + * The composition pin (objectui#7399). + * + * These ceilings are keyed on chunk NAMES, and until this card nothing checked + * that a name still described its contents. It did not: `framework`'s group + * test is `packages/(core|react|types)`, and the emitted `framework` chunk held + * all ten `packages/i18n` locale catalogues — 78.7% of its bytes — because five + * workspace groups shared `priority: 80` and `framework` was written first. So + * `PER_CHUNK_GZIP_CEILINGS['framework']` was in operation a budget on the + * translation catalogue, with 41 bytes of headroom, and its failure message + * named the wrong cause. + * + * ⚠️ This is deliberately a check on the DECIDING INPUT, not on prose. The + * config's own comment said `core|react|types` throughout the defect and was + * true about the regex the whole time; what was false was the assumption that a + * group only takes what its regex matches. The predicate below is the one that + * was actually violated — a TIE between a group whose test matches the + * catalogue and one whose test does not. + * + * The byte-level backstop is the re-baselined ceiling itself: `framework` is + * now pinned at 71,000 over a 61,465 payload, so a regression that puts the + * 446 KB catalogue back would red the gate six times over. That verdict is + * loud but mute about the cause. This one names it. + * + * ⛔ Every case here fails CLOSED. The parse yielding nothing, or a probe id + * matching no group at all, is an ERROR and not a pass — a matcher that matches + * nothing agrees with a correctly-attributed bundle on every assertion below. + */ +describe('chunk attribution (objectui#7399)', () => { + /** A group as `advancedChunks.groups` declares it. */ + type Group = { name: string; priority: number; test: RegExp | null }; + + /** + * Parse the groups out of the console's vite config. + * + * `test` is `null` for a group whose test is an IDENTIFIER rather than a + * regex literal (`vendor-objectstack` reads a computed test, so that the + * `OBJECTSTACK_SPEC_DIST` override cannot change the chunk layout — + * objectui#5388). Those are refused a verdict below rather than guessed at. + */ + function parseGroups(): Group[] { + const source = fs.readFileSync(viteConfigPath, 'utf8'); + const entry = + /\{\s*name:\s*'([^']+)',\s*test:\s*(\/(?:[^/\\\n]|\\.|\[[^\]\n]*\])+\/[a-z]*|[A-Za-z_$][\w$]*)\s*,\s*priority:\s*(\d+)\s*\}/g; + return [...source.matchAll(entry)].map(([, name, test, priority]) => { + const literal = /^\/(.*)\/([a-z]*)$/s.exec(test); + return { + name, + priority: Number(priority), + test: literal ? new RegExp(literal[1], literal[2]) : null, + }; + }); + } + + const groups = parseGroups(); + + /** Rolldown matches group tests against REALPATHS, measured on objectui#7399. */ + const moduleId = (relative: string) => path.join(repoRoot, relative); + + const LOCALE_MODULE = moduleId('packages/i18n/src/locales/zh-CN.ts'); + const DATA_MODULE = moduleId('packages/data-objectstack/src/index.ts'); + const CORE_MODULE = moduleId('packages/core/src/index.ts'); + + /** The groups whose test matches this id, highest priority first. */ + function claimants(id: string): Group[] { + return groups + .filter((g) => g.test?.test(id)) + .sort((a, b) => b.priority - a.priority); + } + + describe('the parse itself — a matcher that matches nothing agrees with everything', () => { + it('finds the whole group table, not a fragment of it', () => { + // ~35 groups are declared. A reformat that breaks this parse must red + // here rather than quietly reduce every case below to a tautology. + expect(groups.length).toBeGreaterThan(20); + expect(groups.map((g) => g.name)).toEqual(expect.arrayContaining([ + 'framework', + 'i18n-locales', + 'data-adapter', + 'ui-components', + 'infrastructure', + ])); + }); + + it('reads a control module to the group that owns it', () => { + expect(claimants(CORE_MODULE)[0]?.name).toBe('framework'); + }); + + it('refuses a verdict on a group whose test it could not read', () => { + // Exactly one group takes a computed test today. A second one appearing + // reds this case, because such a group could claim the probe ids below + // without this parse ever seeing it. + expect(groups.filter((g) => g.test === null).map((g) => g.name)).toEqual([ + 'vendor-objectstack', + ]); + }); + }); + + describe('the defect this pin exists to stop', () => { + it('`framework`s test matches NEITHER intruder — which is why the config read as correct', () => { + const framework = groups.find((g) => g.name === 'framework'); + expect(framework?.test?.test(LOCALE_MODULE)).toBe(false); + expect(framework?.test?.test(DATA_MODULE)).toBe(false); + }); + + it.each([ + ['the locale catalogue', LOCALE_MODULE, 'i18n-locales'], + ['the ObjectStack data adapter', DATA_MODULE, 'data-adapter'], + ])('routes %s to `%s` at a priority `framework` cannot tie', (_what, id, expected) => { + const framework = groups.find((g) => g.name === 'framework'); + expect(framework).toBeDefined(); + + const claiming = claimants(id); + // Fails closed: no claimant is an error, never a silent pass. + expect(claiming.length).toBeGreaterThan(0); + expect(claiming[0].name).toBe(expected); + + // The pin. A TIE is what put the catalogue in `framework`, so equality + // here is a failure exactly like inversion is. + expect(claiming[0].priority).toBeGreaterThan(framework!.priority); + }); + + it('leaves no second claimant at the winner`s priority', () => { + for (const id of [LOCALE_MODULE, DATA_MODULE]) { + const claiming = claimants(id); + const top = claiming[0].priority; + expect(claiming.filter((g) => g.priority === top)).toHaveLength(1); + } + }); + }); + + it('budgets the chunk the catalogue now lands in', () => { + // A re-attribution that moved 446 KB into a chunk with no ceiling would + // pass every case above while weakening the gate: the aggregate is the only + // line left over those bytes, and it is the loosest one. + expect(PER_CHUNK_GZIP_CEILINGS).toHaveProperty('i18n-locales'); + expect(claimants(LOCALE_MODULE)[0].name).toBe('i18n-locales'); + }); +}); + describe('ceiling sensitivity, judged live (objectui#5924)', () => { /** * A v2 report totalling exactly `totalGzipBytes`, carrying the budgeted @@ -645,10 +785,16 @@ describe('main', () => { // the checker weighs BOTH halves, and a report missing the budgeted chunks is // an error — which is the per-chunk half working, not a fixture detail. it('exits 0 and publishes the measurement when within budget', () => { - const { code, outputs } = run(budgeted()); + const fixture = budgeted(); + const { code, outputs } = run(fixture); expect(code).toBe(0); expect(outputs.closure_status).toBe('pass'); - expect(outputs.closure_chunks).toBe('5'); + // DERIVED from the fixture, not retyped. This was the literal `'5'`, which + // counted the budgeted chunks plus `index` and `rest-of-closure` — so + // objectui#7399 adding a fourth per-chunk ceiling reddened an assertion + // about the FIXTURE while the gate under test behaved correctly. The number + // this case is actually about is "the report's chunk count, echoed". + expect(outputs.closure_chunks).toBe(String(fixture.files.length)); expect(outputs.closure_gzip_kb).toBe('3146.8'); }); diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 7299174a8..4c959a188 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -386,6 +386,76 @@ export const REGRESSION_THIS_GATE_MUST_CATCH_BYTES = 89 * 1024; * headroom convention rather than widening it: 9,137 bytes (0.10x), against the * 9,595 (0.11x) the retired pair carried. objectui#6631 still owns the drift. * + * ## Why `framework` moved DOWN, and where its bytes went — objectui#7399 + * + * From 524,000 over 514,863 to 71,000 over 61,465, and a fourth line appears: + * `i18n-locales`, 455,000 over 446,076. Neither number is a payload change. The + * console downloads the same eager closure before and after; it is written to + * two more files. + * + * What this ceiling was actually weighing, measured on `e307c9896` from the + * emitted chunk's own module list — the `framework` chunk held 166 modules and + * only 145 of them came from `core|react|types`, its own test: + * + * | packages/i18n | 16 mod | 78.7% of the chunk's bytes | + * | packages/core | 67 mod | 9.6% | + * | packages/react | 63 mod | 4.7% | + * | packages/data-objectstack | 5 mod | 4.6% | + * | packages/types | 15 mod | 2.3% | + * + * Five workspace groups in `apps/console/vite.config.ts` sat at `priority: 80` + * with `framework` written first, and on that tie the subgraph reached through + * `@object-ui/react` was absorbed by the group listed first — a group whose + * regex matches NEITHER intruder. So this line was, in operation, a budget on + * the TEN LOCALE CATALOGUES: 523,959 measured against 524,000, forty-one bytes + * of headroom for the whole repository, while `data-adapter` — declared since + * objectui#5490 — emitted no chunk at all. + * + * ⛔ The expensive half is not the arithmetic. A gate whose message says "you + * grew `core|react|types`" when the cause is a translation key teaches a FALSE + * RULE, and it was followed: two changes were publicly blamed for bytes they do + * not ship (objectui#7399's own retraction). Naming the chunk for what it holds + * is what makes the constraint legible where it is authored. + * + * Lifting the two swallowed groups one tier above the tie (priority 84) gives + * each ceiling a subject its name states. Measured across the change: + * + * | chunk | before | after | + * | `framework` | 523,959 | 61,465 | 166 modules -> 140, all core/react/types + * | `i18n-locales` | — | 446,076 | 22 modules, 100% packages/i18n + * | `data-adapter` | — | 17,846 | 10 modules, 91.8% data-objectstack + * | aggregate |3,255,233|3,256,012| +779 B, +0.024% + * + * The +779 is the cost of two more chunk boundaries — `import` statements in + * the chunks that now name two files where they named one — and it is stated + * here because it is the ONE thing this change spends. ⛔ It is not headroom to + * borrow against, and {@link MAX_EAGER_CLOSURE_GZIP_BYTES} was deliberately NOT + * touched: the maintainer ruling of 2026-09-03 authorised a re-attribution and + * the per-chunk re-baseline it forces, and nothing else. Its own words: + * + * ⛔ The aggregate ceiling is not touched. A′ is a pure re-attribution — + * the browser downloads exactly the same bytes. + * + * ⚠️ Both new pairs are why the re-baseline is not optional. Left at 524,000 + * over a 61,465 payload, `framework` sits 5.08x above its own measurement and + * {@link evaluateHeadroomSensitivity} returns exit 2 — the blind-gauge verdict, + * correctly. The new pairs keep this line's own headroom convention rather than + * widening it: 9,535 bytes (0.10x) for `framework`, 8,924 (0.10x) for + * `i18n-locales`, against the 9,137 (0.10x) the retired `framework` pair + * carried. + * + * `data-adapter` gets no ceiling. 17,846 bytes is smaller than a dozen + * unbudgeted eager chunks, and this constant is a line per BIG chunk, not a + * line per named group; inventing one for it would be a number with no + * incident behind it. + * + * ⭐ Read the new `i18n-locales` headroom for what it is. 8,924 bytes is about + * sixty translation keys at the measured ~147 gzipped bytes a short key costs + * across ten locales — enough for the five PRs this unparked, and then the + * AGGREGATE line (11,988 bytes of headroom) becomes the binding one. That is the + * correct place for the constraint to live, and it is the argument for taking + * the catalogues out of the eager closure rather than for raising anything. + * * ## Raising one * * Same discipline as {@link MAX_EAGER_CLOSURE_GZIP_BYTES}, and the same two @@ -401,7 +471,8 @@ export const REGRESSION_THIS_GATE_MUST_CATCH_BYTES = 89 * 1024; */ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ 'vendor-objectstack': 967_000, - framework: 524_000, + 'i18n-locales': 455_000, + framework: 71_000, 'ui-components': 399_000, }); @@ -412,13 +483,22 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * three numbers taken on two is the drift objectui#6631 is open about: * * - `vendor-objectstack`, `ui-components` — `2c8474c04` (objectui#5490). - * - `framework` — the merged head of objectui#7173's branch; see "Why - * `framework` moved again" above for the three-build attribution. It - * supersedes `a64e96ca8` (objectui#6759), whose reading the paragraph above - * that one still explains. Safe to state rather than hope, for the reason {@link BASELINE} - * gives about its own commit: the console build's turbo `inputs` cover - * `scripts/vite-*.ts`, not `scripts/check-*.mjs`, so the commit that - * records this figure cannot have changed the figure. + * - `framework`, `i18n-locales` — `e307c9896` plus objectui#7399's own + * re-attribution diff; see "Why `framework` moved DOWN" above. Both were + * read from ONE console build, so they are directly comparable to each + * other and to the 523,959 the same tree measured with the groups still + * tied. This `framework` reading supersedes objectui#7173's, whose + * three-build attribution the paragraph above that one still explains, and + * objectui#6759's `a64e96ca8` before it. + * + * ⚠️ Unlike every other entry here, these two are NOT a reading of an + * unmodified tree: the chunks they name do not exist without the diff that + * recorded them, because that diff is what creates the second one. The + * `scripts/vite-*.ts`-versus-`scripts/check-*.mjs` argument {@link BASELINE} + * makes about its own commit does NOT cover them — `apps/console/vite.config.ts` + * IS a build input, deliberately, and moving it is the change. What keeps + * them honest instead is that the gate re-reads them on every CI build of + * the branch that carries the diff. * * Exported so the ceilings are CHECKED against it instead of merely asserted * in this comment. @@ -482,7 +562,8 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ */ export const PER_CHUNK_BASELINE = Object.freeze({ 'vendor-objectstack': 948_461, - framework: 514_863, + 'i18n-locales': 446_076, + framework: 61_465, 'ui-components': 391_095, });