Skip to content

Commit c91af8e

Browse files
test(lint): load the linked @objectstack/spec dist with Node, not vite's transform (#24048)
Fixes #23900 Clause-②: no ## What `packages/lint`'s vitest now loads the pnpm-linked `@objectstack/spec` dist with Node instead of inlining it through vite's transform: - `packages/lint/vitest.config.ts`: one `test.server.deps.external` entry, `/[\\/]packages[\\/]spec[\\/]dist[\\/]/`, which matches the module's realpath. Vitest's default `external` covers only node_modules, and a workspace link's realpath is `packages/spec/dist/...`, so before this every file whose graph reaches spec inlined about 33 MB of built `.mjs`. The header now says the config carries two settings and why each is package-wide. - `packages/lint/src/vitest-spec-external.test.ts` (new) is the positive control the card asks for. It checks that the spec module the tests import (root and `/data`, resolved from `packages/lint` exactly as the sources resolve it) is the same instance Node's own loader holds. It loads the ESM entry the `exports` map names with `createRequire` (Node 22's require(esm) shares the ESM loader's module map) and asserts export identity. When the dist is inlined, vite evaluates its own copy, so the identity assertions go red. - The two `// test-budget: 30 — whole-package import` lines on `lazy-deps.test.ts` and `runtime-lazy-deps.test.ts` are dropped (see the measurement below). The import resolves through `exports` to `dist/` either way, so this changes only who evaluates the dist, never which bytes are under test. ## Mock census (before the change) `git grep -nE "vi\.(mock|doMock|hoisted|unmock|importActual|importMock|spyOn)\b" -- packages/lint` at `2b274600b1`: **0** `vi.mock` / `vi.doMock` / `vi.hoisted` sites. The only hits are two `vi.spyOn(console, ...)`, which spy on a global and are unaffected. Control leg: the same `vi.mock(` spelling finds 30 files in `packages/cli/src`, so the zero is a real reading. ## Positive control: what it proves and how it goes red Both ablations ran through `scripts/ablation-replace.mjs` on the committed fix `be2f7c34`, with the anchor hit and the blob change proven on disk: | leg | config | `vitest-spec-external.test.ts` | |---|---|---| | fix | realpath pattern | 2/2 pass | | ablation 1 | `external` entry deleted | 2/2 fail, at the identity assertions (lines 48 and 54), after the `typeof` / `toBeDefined` lines pass | | ablation 2 | name-shaped `/@objectstack[\\/]spec/` (the trap `packages/cli/vitest.config.ts` records) | 2/2 fail, same lines | Restore proof: the `vitest.config.ts` blob on disk equals HEAD's (`4ecbe245f449`), and `git diff HEAD` is empty. ## Measurement (paired, same box, under `os-verify-lock.sh`; shared-box seconds) | reading | before (`2b274600b1`) | after | |---|---|---| | package run, lock hold | 84 s (141 files / 6802 tests, green) | 53 s at `be2f7c34`, 54 s at `77f016c5` (142 files / 6804 tests, green; +1 file / +2 cases = the control) | | vitest `transform` / `import` | 23.74 s / 189.81 s | 8.05 s / 96.39 s | | `lazy-deps.test.ts` alone, two runs | 15.01 s, 15.19 s | 6.77 s, 6.79 s | | `runtime-lazy-deps.test.ts` alone, two runs | 14.25 s, 14.63 s | 6.79 s, 6.54 s | **Budget lines dropped, not re-measured.** Both files now run in 6.5 to 6.8 s alone, under the 10 s `FILE_BUDGET_SECONDS` line. The lines' stated reason ("vitest's transform of ... every inlined @objectstack/spec dist bundle") is no longer true. A budget is a CI reading x 1.25, and no CI reading after the change exists yet to re-measure from. If an hourly reading later puts either file over 10 s, that reading is the measurement a re-declaration needs (ruling `6181973363` item 2). ## Acceptance notes - `@objectstack/formula` and `@objectstack/sdui-parser` (lint's other two workspace deps) are still inlined. The card scopes only spec, and they are not measured here. - Cost recorded in the config comment: an externalised module cannot be `vi.mock`ed. A later test that needs to mock spec has to move the entry. - ⛔ No test deleted, no timeout raised, no move to a slower or skipped project, no other package's config, no source change. ## Verification - Head `e0d24934` (patch round): the control now imports `defineStack` from `@objectstack/spec` and `ObjectSchema` from `@objectstack/spec/data` by name. The root namespace import broke the repo's `no-restricted-imports` rule for `@objectstack/spec`. The identity check is unchanged. - ESLint over the four changed files, `node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config ...`: exit 1 on `77f016c5` (one `no-restricted-imports` error at `vitest-spec-external.test.ts:29:8`), exit 0 on `e0d24934`. - Ablation legs re-run on `e0d24934` through `scripts/ablation-replace.mjs`. With the `external` entry deleted, and with the name-shaped `/@objectstack[\\/]spec/`, the control is 2/2 red at `:48:25` and `:54:26` (`Object.is` identity). Restore: the disk blob equals the HEAD blob `4ecbe245f449`, and `git diff HEAD` is empty. - `pnpm --filter @objectstack/lint test` at `e0d24934`: 142/142 files, 6804/6804 tests (`VERDICT command-exit 0`, lock held 54 s). - `pnpm --filter @objectstack/lint typecheck` (including `check:test-typecheck`) at `e0d24934`: `VERDICT command-exit 0`; the debt ledger is unchanged (2 files / 6 errors). - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ...` at `e0d24934`: 51 derived, 50 run with exit 0, 1 NOT-MEASURED (`pnpm check:dual-build-cjs-loads` exited 3 because it needs a full-repo build; CI measures it), 0 unrun. Also ran `node scripts/check-console-intercept-disarm.mjs`: exit 0. - No publish: `@objectstack/lint`'s `files[]` is `dist`, README, CHANGELOG. None of the changed text is in the built `dist/` (0 hits), while a shipped symbol (`validateStackExpressions`) has 8 hits as the positive control. Written by the `os-dev` cloud session `session_019oKRRCxbLy3xARM3CH99sf`, dispatched by `domain:spec` seat 1 (claim `6224227197`). --- _Generated by [Claude Code](https://claude.ai/code/session_019oKRRCxbLy3xARM3CH99sf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 077b75f commit c91af8e

4 files changed

Lines changed: 85 additions & 4 deletions

File tree

‎packages/lint/src/lazy-deps.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2-
// test-budget: 30 — whole-package import: the in-process case imports the whole @objectstack/lint barrel cold, so the file's floor is vitest's transform of that graph and of every inlined @objectstack/spec dist bundle it reaches, and the two built-dist probes each spawn node once; slowest CI reading 18.0 s (hourly run 38267594064) x 1.25, rounded up
32
//
43
// Boot-path contract: importing @objectstack/lint must NOT load its heavy,
54
// gate-only dependencies. The package sits on the kernel boot path, while each

‎packages/lint/src/runtime-lazy-deps.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2-
// test-budget: 30 — whole-package import: the in-process cases import the @objectstack/lint/runtime entry cold, which reaches the whole gate graph, so the file's floor is vitest's transform of it and of every inlined @objectstack/spec dist bundle it reaches, and the three built-dist probes each spawn node once; slowest CI reading 19.2 s (hourly run 38314183560) x 1.25, rounded up
32
//
43
// The kernel boot-path contract for `@objectstack/lint/runtime` (#4463).
54
//
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Positive control for `test.server.deps.external` in ../vitest.config.ts.
4+
//
5+
// That entry hands the linked `@objectstack/spec` dist to Node instead of
6+
// vite's transform. Its pattern is matched against the module's REALPATH, and
7+
// a pattern that matches nothing is silent: the suite stays green and only
8+
// gets slower. So this file asks the one question that tells the two apart —
9+
// is the spec module these sources import the SAME instance Node's own loader
10+
// holds?
11+
//
12+
// - Externalised: vitest's runner loads the dist with a native `import()`, so
13+
// Node's loader holds that module, and a native load of the same file
14+
// returns that very instance — its exports are identical objects.
15+
// - Inlined (the pattern stopped matching): vite evaluates its own copy of the
16+
// dist, and the native load builds a separate one — every export is a
17+
// different object, and the identity assertions below go red.
18+
//
19+
// The native load is `require()` of the ESM file (Node 22's require(esm),
20+
// which shares the ESM loader's module map) through `createRequire`, so vite
21+
// cannot rewrite it; vitest's evaluator gives a `new Function` `import()` no
22+
// dynamic-import callback. The file is the one the package's `exports` map
23+
// names for an ESM import, taken from the linked package root.
24+
import { readFileSync, realpathSync } from 'node:fs';
25+
import { createRequire } from 'node:module';
26+
import { join, resolve } from 'node:path';
27+
import { fileURLToPath } from 'node:url';
28+
import { describe, expect, it } from 'vitest';
29+
import { defineStack } from '@objectstack/spec';
30+
import { ObjectSchema } from '@objectstack/spec/data';
31+
32+
const HERE = fileURLToPath(new URL('.', import.meta.url));
33+
// The package's own node_modules link — the same one these sources resolve.
34+
const SPEC_ROOT = realpathSync(resolve(HERE, '../node_modules/@objectstack/spec'));
35+
36+
const nativeRequire = createRequire(import.meta.url);
37+
38+
function nativeEsmEntry(subpath: '.' | './data'): Record<string, unknown> {
39+
const manifest = JSON.parse(readFileSync(join(SPEC_ROOT, 'package.json'), 'utf8'));
40+
const target: string = manifest.exports[subpath].import.default;
41+
return nativeRequire(join(SPEC_ROOT, target)) as Record<string, unknown>;
42+
}
43+
44+
describe('@objectstack/spec is loaded by Node, not inlined by vite', () => {
45+
it('the root entry is the native module instance', () => {
46+
const native = nativeEsmEntry('.');
47+
expect(typeof defineStack).toBe('function');
48+
expect(defineStack).toBe(native.defineStack);
49+
});
50+
51+
it('a subpath entry is the native module instance', () => {
52+
const native = nativeEsmEntry('./data');
53+
expect(ObjectSchema).toBeDefined();
54+
expect(ObjectSchema).toBe(native.ObjectSchema);
55+
});
56+
});

‎packages/lint/vitest.config.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
// This config exists for exactly one setting; everything else stays on
3+
// This config exists for exactly two settings; everything else stays on
44
// vitest's defaults, deliberately — a key added here re-specifies behaviour
55
// for every test file in the package (packages/cli/vitest.config.ts's header
6-
// records the incident that taught that).
6+
// records the incident that taught that). Both settings are package-wide on
7+
// purpose: each removes a mechanism every file here is exposed to, rather
8+
// than tuning one file.
79
import { defineConfig } from 'vitest/config';
810

911
export default defineConfig({
@@ -15,5 +17,30 @@ export default defineConfig({
1517
// Mechanism + measured costs: examples/app-showcase/vitest.config.ts.
1618
// Enforced repo-wide by scripts/check-console-intercept-disarm.mjs.
1719
disableConsoleIntercept: true,
20+
server: {
21+
deps: {
22+
// Load the linked `@objectstack/spec` dist with Node, not through
23+
// vite's transform. Vitest's default `external` is node_modules-only,
24+
// judged on a module's REALPATH, and a pnpm-linked workspace package's
25+
// realpath is `packages/spec/dist/…` — so without this nearly every
26+
// file here (lint imports spec's root and nine subpaths) inlined about
27+
// 33 MB of built `.mjs`, and the first file to import a lint entry paid
28+
// ~10 s of its cold transform. The import resolves through `exports`
29+
// to `dist/` either way, so this changes only who evaluates that dist,
30+
// never which bytes are under test.
31+
//
32+
// ⚠️ The pattern must match the realpath; a name-shaped one
33+
// (`@objectstack/spec`) matches nothing, silently — see
34+
// packages/cli/vitest.config.ts. The positive control is
35+
// `src/vitest-spec-external.test.ts`, red the moment this stops
36+
// matching.
37+
//
38+
// Cost: an externalised module cannot be `vi.mock`ed. The census taken
39+
// when this landed found zero `vi.mock` / `vi.doMock` / `vi.hoisted`
40+
// sites in this package; a later test that needs to mock spec must
41+
// move this entry, not work around it.
42+
external: [/[\\/]packages[\\/]spec[\\/]dist[\\/]/],
43+
},
44+
},
1845
},
1946
});

0 commit comments

Comments
 (0)