From b7b2b3b8ce1caa12f0898a88c8525f0a6587bd26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 08:15:10 +0000 Subject: [PATCH] fix(console): feed the lazy-linter counter-probe the injection-aware spec test (#5388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assertLazyLinterStaysLazy` carried its own private `/@objectstack[\\/+]spec/` while the `vendor-objectstack` chunk group three lines away already read the widened test `resolveSpecDistInjection` publishes. Under OBJECTSTACK_SPEC_DIST all 18 spec specifiers become absolute paths in the overriding tree, with no `@objectstack` segment, so the private regex matched zero modules and the counter-probe correctly refused a verdict — taking every injected console build with it. The producer now publishes `specModuleTest` alongside `vendorChunkTest`, widened by one shared rule, and the plugin takes the spec test as a parameter. The linter half keeps its literal regex and gains a counter-probe of its own: its assertion is negative, so a blind LINT goes silently green forever. Part of #5388 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HDA9nN6nXQngoQUAAzRdMb --- ...azy-linter-counter-probe-spec-dist-5388.md | 19 ++ apps/console/vite.config.ts | 81 ++++- .../vite-objectstack-spec-dist.test.ts | 300 +++++++++++++++++- scripts/vite-objectstack-spec-dist.ts | 37 ++- 4 files changed, 427 insertions(+), 10 deletions(-) create mode 100644 .changeset/lazy-linter-counter-probe-spec-dist-5388.md diff --git a/.changeset/lazy-linter-counter-probe-spec-dist-5388.md b/.changeset/lazy-linter-counter-probe-spec-dist-5388.md new file mode 100644 index 0000000000..1a6c18a020 --- /dev/null +++ b/.changeset/lazy-linter-counter-probe-spec-dist-5388.md @@ -0,0 +1,19 @@ +--- +--- + +Build-config only: `apps/console`'s `assert-lazy-linter-stays-lazy` guard now takes its +`@objectstack/spec` module-id test as a parameter and is handed the injection-aware one +that `resolveSpecDistInjection` already publishes — the same value the +`vendor-objectstack` chunk group has always read. Under `OBJECTSTACK_SPEC_DIST` all 18 +spec specifiers resolve to absolute paths in the overriding tree, which carry no +`@objectstack` segment, so the guard's own private regex matched zero modules, its +counter-probe correctly refused a verdict, and every console build made with the override +set died in `generateBundle` (objectui#5388, measured from the framework side in +objectstack#10136). The linter half keeps its literal regex and gains a counter-probe of +its own, because a blind `LINT` fails silently where a blind `SPEC` fails loudly. + +Nothing publishes. `@object-ui/console` is NOT a private package — it is published, part +of the 40-package fixed group, and ships its built `dist/` — but this change touches only +`vite.config.ts` and repo tooling, neither of which is in its `files` list, and the +emitted bundle is byte-for-byte what it was: with the override unset the guard evaluates +the identical regex it did before. diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 1f23dff777..f7cc2d19a0 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -72,10 +72,31 @@ function preloadCriticalChunks(): Plugin { * check with no subject. The counter-probe runs first and demands a KNOWN * eager `@objectstack/spec`, so the linter verdict is only ever read after the * closure walk has proven it can see the very chunk the linter used to hide in. + * + * That makes both module-id tests SUBJECTS of the check, not decoration, and + * neither may be hardcoded here. `OBJECTSTACK_SPEC_DIST` rewrites all 18 + * `@objectstack/spec` specifiers to absolute paths in the overriding tree — + * `/…/objectstack/packages/spec/dist/…/index.mjs`, ids with no + * `@objectstack` segment at all — so a private `/@objectstack[\\/+]spec/` + * matched zero modules, this counter-probe correctly refused a verdict, and it + * took every injected build down with it (objectui#5388; measured from the + * consumer side in objectstack#10136, where it blocked the framework's Console + * Pin Gate). The spec test is therefore a PARAMETER, fed from the same + * `resolveSpecDistInjection` output the `vendor-objectstack` group already + * reads: one producer, both consumers, no second opinion about where the spec + * lives. + * + * `LINT` stays literal — nothing injects `@objectstack/lint` today — but it gets + * its own counter-probe for the same reason, because its failure mode is the + * worse one. The spec test going blind fails LOUD (this build stops). The lint + * test going blind fails SILENT: the assertion below is negative, so zero + * matches reads exactly like a clean bundle and the guard is green forever. + * objectstack#9659 proposes injecting four more `@objectstack/*` packages the + * same way and lint is a plausible member; if it ever joins, this build says so + * instead of quietly ceasing to guard anything. */ -function assertLazyLinterStaysLazy(): Plugin { +function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { const LINT = /@objectstack[\\/+]lint/; - const SPEC = /@objectstack[\\/+]spec/; return { name: 'assert-lazy-linter-stays-lazy', @@ -104,7 +125,7 @@ function assertLazyLinterStaysLazy(): Plugin { .filter((chunk) => Object.keys(chunk.modules).some((id) => test.test(id))) .map((chunk) => chunk.fileName); - const eagerSpec = chunksHolding(SPEC).filter((fileName) => eager.has(fileName)); + const eagerSpec = chunksHolding(specTest).filter((fileName) => eager.has(fileName)); if (eagerSpec.length === 0) { this.error( `[assert-lazy-linter-stays-lazy] counter-probe failed: no eagerly loaded chunk ` + @@ -112,12 +133,39 @@ function assertLazyLinterStaysLazy(): Plugin { `from the app entry, so it must be in the eager closure — its absence means ` + `this walk is reading the graph wrongly, not that the bundle improved. ` + `Fix the walk before trusting the linter assertion below. ` + - `(entry chunks: ${[...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName).join(', ') || 'NONE'}; ` + + `If \`OBJECTSTACK_SPEC_DIST\` is set, check that this test carries the ` + + `override's location: an injected spec resolves outside node_modules and the ` + + `baseline test cannot see it (objectui#5388). ` + + `(spec test: ${specTest}; ` + + `entry chunks: ${[...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName).join(', ') || 'NONE'}; ` + `eager chunks: ${eager.size}/${chunks.size})`, ); } - const eagerLint = chunksHolding(LINT).filter((fileName) => eager.has(fileName)); + // The same refusal-to-guess, for the linter half — and it is needed MORE + // here, not less. The assertion below is a NEGATIVE one, so a `LINT` that + // has stopped matching the emitted ids is indistinguishable from a bundle + // that keeps the linter properly lazy: the guard just goes green and stays + // green. The linter is always in this bundle somewhere (app-shell's + // `capabilityLint.ts` `await import`s it), so zero matches ANYWHERE — + // eager or lazy — is a statement about this regex, never about the graph. + const lintChunks = chunksHolding(LINT); + if (lintChunks.length === 0) { + this.error( + `[assert-lazy-linter-stays-lazy] counter-probe failed: no chunk at all — eager or ` + + `lazy — contains an \`@objectstack/lint\` module, so \`${LINT}\` matches nothing in ` + + `this bundle and the assertion below can no longer fail. Two ways to get here. ` + + `Either the lazy import in app-shell's \`capabilityLint.ts\` is gone, in which case ` + + `retire this plugin deliberately rather than leaving a guard with no subject; or ` + + `the linter's module ids changed shape — e.g. \`@objectstack/lint\` joined the ` + + `\`OBJECTSTACK_SPEC_DIST\`-style injection proposed in objectstack#9659, which ` + + `rewrites specifiers to absolute paths with no \`@objectstack\` segment. In that ` + + `case give this test the injection's location, exactly as the spec test above is ` + + `already parameterised (objectui#5388). (chunks: ${chunks.size})`, + ); + } + + const eagerLint = lintChunks.filter((fileName) => eager.has(fileName)); if (eagerLint.length > 0) { this.error( `[assert-lazy-linter-stays-lazy] \`@objectstack/lint\` is in the EAGER closure ` + @@ -304,6 +352,16 @@ const OPTIMIZE_DEPS_INCLUDE = [ const VENDOR_OBJECTSTACK_TEST = /([\\/]node_modules[\\/]@objectstack[\\/](?!lint[\\/])|[\\/]@objectstack\+(?!lint@))/; +// "This module id IS `@objectstack/spec`" — the subject of the counter-probe in +// `assertLazyLinterStaysLazy` above, kept separate from the group test on +// purpose. `VENDOR_OBJECTSTACK_TEST` is the whole vendor scope minus the linter, +// so an eager `@objectstack/client` would satisfy it without proving the walk +// can see the SPEC chunk the counter-probe exists to find — reusing it there +// would keep the build green by lowering the bar, which is the one outcome +// objectui#5388 must not produce. Both spellings are covered: a plain install +// (`/node_modules/@objectstack/spec/…`) and pnpm's store (`/@objectstack+spec@…`). +const SPEC_MODULE_TEST = /@objectstack[\\/+]spec/; + // Opt-in override of the installed `@objectstack/spec` — the spec twin of // OBJECTSTACK_CLIENT_DIST above, so a framework build can bundle the console // against its OWN spec instead of the last published one (objectui#4854, ruled @@ -320,6 +378,7 @@ const VENDOR_OBJECTSTACK_TEST = // vendor chunk test and the dev server's fs allow-list at their baseline values. const specDistInjection = resolveSpecDistInjection(process.env.OBJECTSTACK_SPEC_DIST, { vendorChunkTest: VENDOR_OBJECTSTACK_TEST, + specModuleTest: SPEC_MODULE_TEST, }); if (specDistInjection) Object.assign(workspaceAliases, specDistInjection.aliases); @@ -346,6 +405,16 @@ const vendorObjectstackTest = specDistInjection ? specDistInjection.vendorChunkTest : VENDOR_OBJECTSTACK_TEST; +// The chunk grouping is not the only consumer of "where does the spec live". +// `assertLazyLinterStaysLazy`'s counter-probe asks the same question about the +// emitted module ids, and it used to answer it from a private regex that the +// injection made unmatchable — so every build with the override set died on a +// counter-probe that was right about what it saw (objectui#5388). It reads the +// override's location here, from the same producer, for the same reason. +const specModuleTest = specDistInjection + ? specDistInjection.specModuleTest + : SPEC_MODULE_TEST; + // https://vitejs.dev/config/ export default defineConfig({ base: basePath, @@ -363,7 +432,7 @@ export default defineConfig({ // Fails the build if the lazily-imported linter is folded back into an // eagerly-loaded chunk. Runs on CI/Vercel too — it costs microseconds and // the regression it catches is invisible in every other signal. - assertLazyLinterStaysLazy(), + assertLazyLinterStaysLazy(specModuleTest), // maplibre-gl loads its worker as a sibling of its own chunk URL — an // edge no bundler can see — so the worker (and the shared module it // imports) must be copied into assets/ or every map page 404s diff --git a/scripts/__tests__/vite-objectstack-spec-dist.test.ts b/scripts/__tests__/vite-objectstack-spec-dist.test.ts index 78491ee0e1..0aa8972b59 100644 --- a/scripts/__tests__/vite-objectstack-spec-dist.test.ts +++ b/scripts/__tests__/vite-objectstack-spec-dist.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { build } from 'vite'; import fs from 'node:fs'; import os from 'node:os'; @@ -81,11 +81,27 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../ const BASE_VENDOR_TEST = /([\\/]node_modules[\\/]@objectstack[\\/](?!lint[\\/])|[\\/]@objectstack\+(?!lint@))/; +/** + * The baseline "this module id is the spec" test, as the console config spells it. + * + * Mirror of `SPEC_MODULE_TEST` in `apps/console/vite.config.ts`. Deliberately + * NOT the same regex as `BASE_VENDOR_TEST`: that one is the whole vendor scope + * minus the linter, and the counter-probe it feeds has to be able to distinguish + * "an eager chunk holds the spec" from "an eager chunk holds some + * `@objectstack` package". objectui#5388 is what happens when the two are + * conflated in the other direction — one consumer reading the injection, the + * other not. + */ +const BASE_SPEC_TEST = /@objectstack[\\/+]spec/; + /** The installed spec package — a real, fully built override target. */ const installedSpecDir = path.dirname(require_.resolve('@objectstack/spec/package.json')); const inject = (raw: string | undefined) => - resolveSpecDistInjection(raw, { vendorChunkTest: BASE_VENDOR_TEST }); + resolveSpecDistInjection(raw, { + vendorChunkTest: BASE_VENDOR_TEST, + specModuleTest: BASE_SPEC_TEST, + }); /** * A fresh evaluation of the console's Vite config, keyed by `query`. @@ -213,6 +229,7 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { const outOfTree = '/framework/packages/spec'; const outOfTreeInjection = resolveSpecDistInjection(installedSpecDir, { vendorChunkTest: BASE_VENDOR_TEST, + specModuleTest: BASE_SPEC_TEST, })!; // The baseline test cannot see an injected package: that is the whole @@ -232,6 +249,34 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { ).toBe(false); }); + it('keeps the injected spec RECOGNISABLE AS THE SPEC, for the counter-probe', () => { + // objectui#5388. The chunk grouping was not the only consumer of "where + // does the spec live" — `assertLazyLinterStaysLazy` asks the same question + // of the emitted module ids, and answering it from an un-widened private + // regex failed every build made with the override set. + const injection = inject(installedSpecDir)!; + const injectedId = `${injection.packageDir}/dist/ui/index.mjs`; + + // Anti-vacuity: the baseline genuinely cannot see an injected package. If + // this ever went true the assertion below would pass without the widening + // doing anything, and the bug would be back with a green test over it. + expect(BASE_SPEC_TEST.test('/framework/packages/spec/dist/ui/index.mjs')).toBe(false); + expect(injection.specModuleTest.test(injectedId)).toBe(true); + + // Widened, never replaced — an injected build still resolves plenty of + // installed packages through node_modules, in both spellings. + expect(injection.specModuleTest.test('/repo/node_modules/@objectstack/spec/dist/index.js')).toBe(true); + expect(injection.specModuleTest.test('/repo/node_modules/.pnpm/@objectstack+spec@17.0.0/x.js')).toBe(true); + + // And it stays a SPEC test, not the vendor group's. The counter-probe's job + // is to prove the walk can see the spec chunk specifically; a test that also + // matched `@objectstack/client` would keep the build green by lowering the + // bar rather than by seeing the spec. + expect(injection.specModuleTest.test('/repo/node_modules/@objectstack/client/dist/index.js')).toBe(false); + expect(injection.vendorChunkTest.test('/repo/node_modules/@objectstack/client/dist/index.js')).toBe(true); + expect(injection.specModuleTest.test('/repo/packages/core/src/index.ts')).toBe(false); + }); + it('accepts a `dist/` or entry-file spelling of the same package', () => { const fromDir = inject(installedSpecDir)!; const fromDist = inject(path.join(installedSpecDir, 'dist'))!; @@ -418,6 +463,257 @@ describe('objectui#4854: the four flagged surfaces in the console config', () => }); }); +/* -------------------------------------------------------------------------- */ +/* objectui#5388 — the counter-probe is the FIFTH surface the override moves. */ +/* -------------------------------------------------------------------------- */ + +/** + * `apps/console/vite.config.ts` registers `assert-lazy-linter-stays-lazy`, whose + * counter-probe demands a known-eager `@objectstack/spec` chunk before it will + * read its own linter verdict (objectui#5323). Under the override every spec + * module id becomes an absolute path in the overriding tree, with no + * `@objectstack` segment — so the plugin's private regex matched nothing, the + * probe refused a verdict, and `scripts/build-console.sh` in the framework could + * not build ANY objectui pin at or after that commit (objectstack#10136). + * + * These cases drive the REAL plugin off the REAL config over a synthetic bundle, + * rather than asserting on the regex the config hands it. That is the difference + * between pinning the wiring and pinning a value: the bug was never a wrong + * regex, it was a correct regex reaching one consumer and not the other. + * + * Reverse verification, direction predicted before running: plain RED, and the + * mutation is the bug itself. Reverting `assertLazyLinterStaysLazy(specModuleTest)` + * to the no-argument form with its private `SPEC` → the two injected cases below + * fail on the counter-probe message, and the two baseline cases stay green — + * which is exactly the asymmetry that let this ship. + */ +interface ProbeChunk { + type: 'chunk'; + fileName: string; + isEntry: boolean; + imports: string[]; + modules: Record; +} + +/** A pnpm-store module id for the linter, the spelling a real bundle carries. */ +const LINT_MODULE_ID = + '/repo/node_modules/.pnpm/@objectstack+lint@17.0.0/node_modules/@objectstack/lint/dist/index.js'; +/** The installed spec, i.e. what an un-injected build emits. */ +const INSTALLED_SPEC_MODULE_ID = '/repo/node_modules/@objectstack/spec/dist/index.mjs'; + +const probeChunk = ( + fileName: string, + modules: string[], + extra: Partial = {} +): ProbeChunk => ({ + type: 'chunk', + fileName, + isEntry: false, + imports: [], + modules: Object.fromEntries(modules.map((id) => [id, {}])), + ...extra, +}); + +/** + * A bundle shaped like the console's: one entry, one statically imported vendor + * chunk holding the spec, and the linter parked behind a dynamic import — which + * the plugin's walk deliberately does not follow. + * + * @param specModuleId the spec id the vendor chunk carries (installed or injected) + * @param lintFileName which chunk holds the linter, or `null` for none at all + */ +function consoleShapedBundle( + specModuleId: string, + lintFileName: 'assets/vendor-objectstack.js' | 'assets/lint-lazy.js' | null +): Record { + const vendorModules = [specModuleId]; + if (lintFileName === 'assets/vendor-objectstack.js') vendorModules.push(LINT_MODULE_ID); + const bundle: Record = { + 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { + isEntry: true, + imports: ['assets/vendor-objectstack.js'], + }), + 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', vendorModules), + }; + if (lintFileName === 'assets/lint-lazy.js') { + bundle['assets/lint-lazy.js'] = probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]); + } + return bundle; +} + +/** + * A minimal but REAL `@objectstack/spec` package living outside `node_modules`. + * + * `installedSpecDir` cannot stand in for an injected package here, and finding + * that out is worth writing down: its own path is + * `…/node_modules/.pnpm/@objectstack+spec@17…/node_modules/@objectstack/spec`, + * which the BASELINE test already matches. A case built on it goes green under + * the un-injected config too — measured, before this fixture existed — so it + * would have pinned nothing at all. + * + * The framework tree the override actually points at + * (`/…/objectstack/packages/spec`, or `/home/runner/work/objectstack/objectstack/ + * packages/spec` on CI) has no `@objectstack` segment anywhere, and that is the + * one property this fixture has to reproduce. It stays minimal on purpose: the + * exports-map derivation is covered above against the real 18-entry map, and + * what these cases need is a legal package at a path of the wrong SHAPE. + */ +function makeOutOfTreeSpecPackage(): string { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'framework-spec-5388-'))); + fs.mkdirSync(path.join(dir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'dist/index.mjs'), 'export const __probe5388 = true;\n'); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify( + { + name: SPEC_PACKAGE_NAME, + version: '0.0.0-probe5388', + type: 'module', + exports: { '.': { import: './dist/index.mjs' } }, + }, + null, + 2 + ) + ); + return dir; +} + +/** Runs the console's own guard over one bundle; returns its error, or `null`. */ +function runLazyLinterProbe(config: any, bundle: Record): string | null { + const plugins = (config.plugins as unknown[]).flat(Infinity) as { + name?: string; + generateBundle?: (this: unknown, options: unknown, bundle: unknown) => void; + }[]; + const plugin = plugins.find((p) => p && p.name === 'assert-lazy-linter-stays-lazy'); + expect(plugin, 'the console config registers assert-lazy-linter-stays-lazy').toBeDefined(); + const context = { + error(message: string): never { + throw new Error(message); + }, + }; + try { + plugin!.generateBundle!.call(context, {}, bundle); + return null; + } catch (error) { + return (error as Error).message; + } +} + +describe('objectui#5388: the lazy-linter counter-probe reads the injection', () => { + let outOfTreeSpecDir: string; + /** The module id an injected build emits for the spec. */ + let injectedSpecModuleId: string; + + beforeAll(() => { + outOfTreeSpecDir = makeOutOfTreeSpecPackage(); + injectedSpecModuleId = `${outOfTreeSpecDir}/dist/index.mjs`; + }); + afterAll(() => { + fs.rmSync(outOfTreeSpecDir, { recursive: true, force: true }); + }); + + /** Loads the console config with the override pointed at the fixture. */ + async function loadInjectedConsoleConfig(query: string): Promise { + process.env.OBJECTSTACK_SPEC_DIST = outOfTreeSpecDir; + try { + return await loadConsoleConfig(query); + } finally { + delete process.env.OBJECTSTACK_SPEC_DIST; + } + } + + it('gives the fixture the one shape that matters: no `@objectstack` segment', () => { + // Anti-vacuity for every case below. If the fixture ever lands somewhere + // the baseline test already matches, the "blind" case goes green for the + // wrong reason and the "sees it" case stops proving the widening did + // anything — which is exactly what happened with `installedSpecDir`. + expect(injectedSpecModuleId).not.toContain('@objectstack'); + expect(BASE_SPEC_TEST.test(injectedSpecModuleId)).toBe(false); + }); + + it('sees the INSTALLED spec when no override is set', async () => { + expect(process.env.OBJECTSTACK_SPEC_DIST ?? '').toBe(''); + const config = await loadConsoleConfig(); + expect( + runLazyLinterProbe(config, consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, 'assets/lint-lazy.js')) + ).toBeNull(); + }); + + it('is BLIND to an injected spec while the config stays un-injected', async () => { + // The bug's mechanism, isolated: same plugin, same bundle shape, only the + // spec's module id moved out of node_modules. Without the override the + // config has no business recognising that path — so this failing is CORRECT + // here, and it is the control that makes the passing case below mean + // something rather than being a probe that stopped looking. + const config = await loadConsoleConfig(); + const message = runLazyLinterProbe( + config, + consoleShapedBundle(injectedSpecModuleId, 'assets/lint-lazy.js') + ); + expect(message).toContain('counter-probe failed'); + expect(message).toContain('no eagerly loaded chunk'); + }); + + it('finds the injected spec once the override IS set — the probe, not skipped', async () => { + const config = await loadInjectedConsoleConfig('?objectstack-spec-dist=5388'); + + // It PASSES on the injected id… + expect( + runLazyLinterProbe(config, consoleShapedBundle(injectedSpecModuleId, 'assets/lint-lazy.js')) + ).toBeNull(); + // …and still refuses a verdict when the eager closure really holds no spec, + // so the fix widened the probe's reach rather than defanging it. + const noSpec: Record = { + 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { + isEntry: true, + }), + 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), + }; + expect(runLazyLinterProbe(config, noSpec)).toContain('counter-probe failed'); + // Nor did widening turn the SPEC test into the vendor group's: an eager + // `@objectstack/client` is not evidence that the walk can see the spec. + const clientOnly: Record = { + 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { + isEntry: true, + imports: ['assets/vendor-objectstack.js'], + }), + 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', [ + '/repo/node_modules/@objectstack/client/dist/index.mjs', + ]), + 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), + }; + expect(runLazyLinterProbe(config, clientOnly)).toContain('counter-probe failed'); + }); + + it('still catches an EAGER linter under the override', async () => { + // The guard's actual job, asserted in the mode that used to never reach it: + // before this fix the counter-probe threw first and the linter verdict was + // never read at all under the override. + const config = await loadInjectedConsoleConfig('?objectstack-spec-dist=5388-eager'); + const message = runLazyLinterProbe( + config, + consoleShapedBundle(injectedSpecModuleId, 'assets/vendor-objectstack.js') + ); + expect(message).toContain('`@objectstack/lint` is in the EAGER closure'); + expect(message).toContain('assets/vendor-objectstack.js'); + }); + + it('refuses a verdict when the LINT test itself has gone blind', async () => { + // The linter half's failure mode is the silent one: the assertion on it is + // negative, so a regex that stopped matching the emitted ids is + // indistinguishable from a clean bundle. objectstack#9659 proposes injecting + // four more `@objectstack/*` packages the same way; if lint joins them this + // must fail loudly rather than go permanently green. + const config = await loadConsoleConfig(); + const message = runLazyLinterProbe( + config, + consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, null) + ); + expect(message).toContain('counter-probe failed'); + expect(message).toContain('no chunk at all'); + }); +}); + describe('objectui#4854: a real Vite build resolves the injected spec', () => { // The transcribed matcher above agrees with Vite's source, but only Vite can // answer whether it preserves the alias table's KEY ORDER through diff --git a/scripts/vite-objectstack-spec-dist.ts b/scripts/vite-objectstack-spec-dist.ts index 6ed729fee4..b206c7101c 100644 --- a/scripts/vite-objectstack-spec-dist.ts +++ b/scripts/vite-objectstack-spec-dist.ts @@ -78,6 +78,25 @@ export interface SpecDistInjection { fsAllow: string[]; /** `advancedChunks` test that keeps the injected spec in the vendor chunk. */ vendorChunkTest: RegExp; + /** + * Module-id test that still RECOGNISES the injected spec as the spec. + * + * Distinct from `vendorChunkTest` on purpose. That one answers "which modules + * belong in the `vendor-objectstack` group" — the whole scope minus the + * linter — and an eager `@objectstack/client` satisfies it. This one answers + * "which modules ARE `@objectstack/spec`", which is what the console's + * build-time counter-probe (`assertLazyLinterStaysLazy`) has to be able to + * find before it trusts its own graph walk. + * + * Publishing it here rather than letting that consumer hardcode its own is the + * whole lesson of objectui#5388: the injection rewrites all 18 spec specifiers + * to absolute paths in the overriding tree — ids with no `@objectstack` + * segment anywhere — so a private `/@objectstack[\\/+]spec/` matched zero + * modules, the counter-probe correctly refused a verdict, and every build made + * with the override set died in `generateBundle` (measured from the consumer + * side in objectstack#10136). Both consumers now read one producer. + */ + specModuleTest: RegExp; } /** Escapes a literal string for embedding in a `RegExp` source. */ @@ -213,10 +232,12 @@ export function readSpecExportTargets(packageDir: string): Map { * @param raw the `OBJECTSTACK_SPEC_DIST` value, unset or empty for none * @param vendorChunkTest the config's baseline `vendor-objectstack` group test, * widened (never replaced) with the override's location + * @param specModuleTest the config's baseline "this module id is the spec" test, + * widened the same way — see `SpecDistInjection` */ export function resolveSpecDistInjection( raw: string | undefined, - { vendorChunkTest }: { vendorChunkTest: RegExp } + { vendorChunkTest, specModuleTest }: { vendorChunkTest: RegExp; specModuleTest: RegExp } ): SpecDistInjection | null { if (!raw || !raw.trim()) return null; @@ -231,10 +252,22 @@ export function resolveSpecDistInjection( aliases[SPEC_PACKAGE_NAME] = targets.get(SPEC_PACKAGE_NAME)!; const posixDir = packageDir.split(path.sep).join('/'); + + // One widening rule, applied to every module-id test the caller hands in: the + // baseline stays whole and the override's location joins it as an extra + // alternative. Widened, never replaced — an injected build still resolves + // plenty of installed `@objectstack/*` through node_modules, and a test that + // only knew the override would stop seeing those. Keeping it a single local + // function is deliberate too: two tests widened by two hand-written copies of + // this expression is how the consumers drift apart in the first place. + const widen = (baseline: RegExp): RegExp => + new RegExp(`${baseline.source}|${escapeRegExp(posixDir)}${SEPARATOR_CLASS}`); + return { packageDir, aliases, fsAllow: [packageDir], - vendorChunkTest: new RegExp(`${vendorChunkTest.source}|${escapeRegExp(posixDir)}${SEPARATOR_CLASS}`), + vendorChunkTest: widen(vendorChunkTest), + specModuleTest: widen(specModuleTest), }; }