Skip to content

Commit e583179

Browse files
committed
fix(core): one declaration serves both module formats, so exports give types per condition
Core's declaration pass runs once, for CJS; the ESM .d.ts files are one-line re-exports of the .d.cts ones, so ObjectKernel has one identity whichever resolution mode reaches it. exports now hands each condition its own types, and the two core rows leave TYPED_EXEMPTIONS in check-dual-build-cjs-loads (shrink-only: the entries are now correct). Claude-Session: https://claude.ai/code/session_01VPkxx6ackwXchvtVNidtum Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2e7083 commit e583179

4 files changed

Lines changed: 93 additions & 38 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): one declaration serves both module formats, so the export map hands each condition its own `types`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`@objectstack/core` used to emit two separate declarations of every class, `dist/index.d.ts` and `dist/index.d.cts`, and its export map served the ESM-kind `index.d.ts` to both conditions, so a TypeScript consumer resolving `require` was told the package is ESM (attw `FalseESM` on `.` and `./logger`). Serving the `.d.cts` to `require` was not possible while the two files declared `ObjectKernel` twice: its `private plugins` member makes the two copies incompatible types for any program that reaches core through both resolution modes.
11+
12+
Core's declaration build now runs once, for the CommonJS format. `dist/index.d.cts` and `dist/logger.d.cts` carry the declarations, and `dist/index.d.ts` and `dist/logger.d.ts` are one-line re-exports of them, so both conditions hand out the same classes. `exports` now gives types per condition, `{ import: { types, default }, require: { types, default } }`, on both subpaths. No subpath is added or removed, no JavaScript file changes, and the `dist` file names are unchanged. Nothing for a consumer to do.

‎packages/core/package.json‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,24 @@
99
"sideEffects": false,
1010
"exports": {
1111
".": {
12-
"types": "./dist/index.d.ts",
13-
"import": "./dist/index.js",
14-
"require": "./dist/index.cjs"
12+
"import": {
13+
"types": "./dist/index.d.ts",
14+
"default": "./dist/index.js"
15+
},
16+
"require": {
17+
"types": "./dist/index.d.cts",
18+
"default": "./dist/index.cjs"
19+
}
1520
},
1621
"./logger": {
17-
"types": "./dist/logger.d.ts",
18-
"import": "./dist/logger.js",
19-
"require": "./dist/logger.cjs"
22+
"import": {
23+
"types": "./dist/logger.d.ts",
24+
"default": "./dist/logger.js"
25+
},
26+
"require": {
27+
"types": "./dist/logger.d.cts",
28+
"default": "./dist/logger.cjs"
29+
}
2030
}
2131
},
2232
"scripts": {

‎packages/core/tsup.config.ts‎

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { defineConfig } from 'tsup';
1+
import { writeFile } from 'node:fs/promises';
2+
3+
import { defineConfig, type Options } from 'tsup';
24

35
import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs';
46

@@ -10,13 +12,59 @@ import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'
1012
* so importing the client SDK in a browser bundle does NOT drag in
1113
* Node-only kernel internals (e.g. `process.cpuUsage()`).
1214
*/
13-
export default defineConfig({
14-
entry: ['src/index.ts', 'src/logger.ts'],
15+
const ENTRY_NAMES = ['index', 'logger'] as const;
16+
const emitDts = !process.env.OS_SKIP_DTS;
17+
18+
const shared: Options = {
19+
entry: ENTRY_NAMES.map((name) => `src/${name}.ts`),
1520
splitting: false,
1621
sourcemap: true,
17-
clean: true,
18-
dts: !process.env.OS_SKIP_DTS,
19-
format: ['esm', 'cjs'],
2022
target: 'es2020',
2123
esbuildOptions: dropSourcesContent,
22-
});
24+
};
25+
26+
/**
27+
* ONE declaration per entry, shared by both formats.
28+
*
29+
* tsup's own `dts` emits a declaration per FORMAT — `index.d.ts` beside the ESM
30+
* build and `index.d.cts` beside the CJS one — and those are two separate
31+
* declarations of every class. TypeScript compares a class with a `private`
32+
* member nominally, so `ObjectKernel` (which carries `private plugins` and
33+
* reaches every plugin through `PluginContext.getKernel()`) becomes two
34+
* incompatible types for any compilation that reaches core through both
35+
* resolution modes, the moment the export map serves each format its own
36+
* declaration. Measured: per-condition `types` over the twin declarations
37+
* fail `pnpm --filter @objectstack/plugin-dev build` with TS2345 in
38+
* `src/dev-plugin.ts` (its CommonJS build `await import()`s
39+
* `@objectstack/objectql`, which reaches core the other way).
40+
*
41+
* So the declaration pass runs once, for CJS only, and the ESM `index.d.ts` is
42+
* a one-line re-export of `index.d.cts`: each condition is still handed a
43+
* declaration of its own module kind (attw: no `FalseESM`, no `FalseCJS`),
44+
* but every class in both IS the `.d.cts` one. The re-export runs ESM → CJS on
45+
* purpose: an ES module may import a CommonJS one under every `module`
46+
* setting, while the reverse (`.d.cts` re-exporting `.d.ts`) is TS1479 in a
47+
* consumer's program: measured under `module: node16` on TypeScript 6.0 and
48+
* 5.6, and under `nodenext` too on 5.6.
49+
*
50+
* The JS half's `onSuccess` writes the `.d.ts` files and the declaration half
51+
* never emits one, so the two halves (which tsup runs concurrently) never
52+
* write the same file. `clean` lives on the JS half only: tsup's clean keeps
53+
* declaration files, so it cannot delete what the declaration half writes.
54+
*/
55+
export default defineConfig([
56+
{
57+
...shared,
58+
clean: true,
59+
dts: false,
60+
format: ['esm', 'cjs'],
61+
onSuccess: emitDts
62+
? async () => {
63+
await Promise.all(
64+
ENTRY_NAMES.map((name) => writeFile(`dist/${name}.d.ts`, `export * from './${name}.cjs';\n`)),
65+
);
66+
}
67+
: undefined,
68+
},
69+
...(emitDts ? [{ ...shared, clean: false, dts: { only: true }, format: ['cjs'] } satisfies Options] : []),
70+
]);

‎scripts/check-dual-build-cjs-loads.mjs‎

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -440,23 +440,15 @@ const DUAL_FORMAT_BEHAVIOUR_PROBES = [
440440
* the type axis, and it is a property of the DEPENDENCY, never of the consumer
441441
* that trips over it.
442442
*
443-
* `@objectstack/core` is where the repo actually meets it. `ObjectKernel`
444-
* carries `private plugins` and travels through `PluginContext.getKernel()`
445-
* into every plugin's `init`, so the moment core ships two declarations, any
446-
* compilation that reaches core through BOTH resolution modes sees two
447-
* `ObjectKernel`s. Measured on this branch, whole-repo `pnpm build`:
448-
*
449-
* 28 packages split (core included) RED — @objectstack/verify, 5 × TS2345
450-
* 27 packages split (core held back) GREEN — 71/71 tasks
451-
* 28 + the 37-package ESM mirror RED — @objectstack/plugin-dev, TS2345
452-
*
453-
* The mirror round is the informative one: it moves the failure rather than
454-
* removing it. Splitting `@objectstack/objectql` is what fixes `verify` and
455-
* what breaks `plugin-dev`, because a split dependency resolves core one way
456-
* from its `.d.mts` and the other from its `.d.ts`. So the exemption is not
457-
* "core is hard", it is: **core cannot be split until `ObjectKernel`'s
458-
* identity stops being nominal**, and that is a decision about core's public
459-
* types, not about an exports map.
443+
* `@objectstack/core` is where the repo met it: `ObjectKernel` carries
444+
* `private plugins` and travels through `PluginContext.getKernel()` into every
445+
* plugin's `init`, so twin declarations of core broke any compilation that
446+
* reaches core through BOTH resolution modes (`@objectstack/plugin-dev`'s
447+
* CommonJS build, TS2345). The cure is at the dependency, not in this table:
448+
* core's declaration pass runs once and its other-format declaration is a
449+
* one-line re-export of it (`packages/core/tsup.config.ts`), so both
450+
* conditions hand out the SAME classes. A package that meets the hazard again
451+
* takes that cure first; a row here is for an entry that cannot.
460452
*
461453
* ⛔ An exemption here is NOT a quieter red. It is a declaration that the
462454
* entry's `.d.cts` ships unreachable ON PURPOSE, with the measurement that
@@ -466,14 +458,7 @@ const DUAL_FORMAT_BEHAVIOUR_PROBES = [
466458
*
467459
* @type {Record<string, {reason: string}>}
468460
*/
469-
const TYPED_EXEMPTIONS = Object.freeze({
470-
'@objectstack/core#.': {
471-
reason: 'Splitting core\'s declarations gives `ObjectKernel` (which carries `private plugins`) two nominal identities, and it crosses every plugin boundary via `PluginContext.getKernel()`. Measured: with core split, `pnpm build` fails in @objectstack/verify with 5 × TS2345; with core held back and the other 27 packages split, 71/71 tasks pass. Blocked on a decision about core\'s public types, not on this exports map (#13112).',
472-
},
473-
'@objectstack/core#./logger': {
474-
reason: 'Same declaration set as `@objectstack/core#.` — the subpath is emitted from the same tsup pass and splits the same identities. Held back with its sibling so core has one resolution story rather than two (#13112).',
475-
},
476-
});
461+
const TYPED_EXEMPTIONS = Object.freeze({});
477462

478463
const EXIT_OK = 0;
479464
const EXIT_FINDINGS = 1;

0 commit comments

Comments
 (0)