Skip to content

Commit 876aa27

Browse files
fix(core): one declaration serves both module formats, so exports give types per condition (#26279)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9W2h5P9XVs1x44jgK8SstGW)_ Fixes #26205 Clause-②: no Metadata-protocol: none `@objectstack/core` was the one published package still handing a `require` consumer its ESM-kind declaration (attw `FalseESM` on `.` and `./logger`). Serving the `.d.cts` per condition, as #26198 did for 38 other packages, broke consumers, because `dist/index.d.ts` and `dist/index.d.cts` were two separate declarations of the same classes and `ObjectKernel`'s `private plugins` makes the two copies incompatible types. ## What changed - **`packages/core/tsup.config.ts`**: the declaration pass runs once, for ESM only (a `dts: { only: true }` half). The JS half (ESM + CJS, unchanged options) writes `dist/index.d.cts` and `dist/logger.d.cts` in its `onSuccess` as one line each, `export * from './index.js';` / `'./logger.js'`. The declaration half never emits a `.d.cts`, so the two halves tsup runs concurrently never write the same file, and `clean` stays on the JS half only (tsup's clean keeps declaration files). - **`packages/core/package.json`**: both subpaths become `{ import: { types, default }, require: { types, default } }`, the shape #26198 used. No subpath added or removed, no JavaScript target moved; the top-level `main`/`types` are unchanged. - **`scripts/check-dual-build-cjs-loads.mjs`**: the two `@objectstack/core` rows leave `TYPED_EXEMPTIONS`. The table is shrink-only and the scan reports a row whose entry now resolves a CommonJS declaration as stale, so they go in the same PR; the header paragraph that described core's block now says where the cure lives. This file was added to the claim's file surface by the seat before the edit. - **`.changeset/26205-core-one-declaration-per-condition.md`**: `@objectstack/core` patch. ## Why this direction and not the reverse Tried first the other way round: declarations in the `.d.cts`, `index.d.ts` re-exporting `./index.cjs` (ESM may import CJS in every TypeScript mode). That made core's own imports of `@objectstack/spec` resolve through spec's `require` condition, and spec's own twin declarations then failed `@objectstack/driver-memory`'s build: `TS2345` at `src/memory-driver.ts:1469`, `ComposedReadableIds` not assignable to itself. Keeping the declarations in the ESM `.d.ts` means everything core's types import resolves exactly as it did when both conditions read the `.d.ts`. The cost of this direction is TS1479 inside core's `.d.cts` for a CommonJS consumer on `module: node16` (or on TypeScript 5.6 under `nodenext`). A consumer CJS file plus ESM file importing core in both resolution modes and assigning `ObjectKernel` across them, compiled against this branch's build and against `origin/main`'s: | TypeScript · module | `origin/main`, `skipLibCheck: true` | this PR, `skipLibCheck: true` | this PR, `skipLibCheck: false` | |:---|:---|:---|:---| | 6.0.3 · nodenext | 0 errors | 0 errors | 0 errors | | 6.0.3 · node16 | 2 × TS1479 in the consumer's own file | 0 errors | 2 × TS1479 in core's `.d.cts` | | 5.6.1-rc · node16 | 2 × TS1479 in the consumer's own file | 0 errors | 2 × TS1479 in core's `.d.cts` | | 5.6.1-rc · nodenext | 2 × TS1479 in the consumer's own file | 0 errors | 2 × TS1479 in core's `.d.cts` | So the diagnostic that `origin/main` raises at the consumer's import, where no option silences it, moves into the package, where `skipLibCheck` does. The cross-mode `ObjectKernel` assignments type-check in every cell. (The 5.6 runs also print errors from `ai`/`@ai-sdk` and `@objectstack/spec` declarations with `skipLibCheck: false`; those are unchanged by this PR and not counted above.) ## Acceptance - **Premise reproduced on `origin/main` (`f2e70836`)**: with only the export map split and the declaration build unchanged, `pnpm --filter @objectstack/plugin-dev build` exits 1 with `src/dev-plugin.ts(523,32): error TS2345: Argument of type 'ObjectQLPlugin' is not assignable to parameter of type 'Plugin'.`; unsplit, exit 0. - **attw** (`@arethetypeswrong/cli` 0.18.5) on the `pnpm pack` tarball of core: before 3 problems (`FalseESM` × 2, `NoResolution` on `./logger` under `node10`), after 1 (`NoResolution` on `./logger` under `node10`). **FalseESM: 2 → 0.** The remaining `node10` row is the known no-`exports`-support class, left out of scope as in #26198. - **`pnpm --filter @objectstack/plugin-dev build`**: exit 0 at `01ec8f2e` (inside the force build below). - **dist file names**: `packages/core/dist` lists the same 12 file names before and after (plus its two `.build-input-hash*` stamps), and the packed tarball the same 18 paths, `diff` clean. What changed is content only: `index.d.cts` 579,364 → 28 bytes and `logger.d.cts` 3,963 → 29 bytes (now re-exports); `index.d.ts` and `logger.d.ts` are the same declarations as before. ## Verification All at head `01ec8f2e`, which merges `origin/main` at `0453fabe` (that brought in #26203's tsconfig base change to core and plugin-dev): - `turbo run build --force --filter='./packages/**'`: **75 of 75 tasks successful**, including `@objectstack/verify`, `@objectstack/plugin-dev` and `@objectstack/driver-memory`, the three builds that met this hazard before. - `pnpm --filter @objectstack/core typecheck` exit 0; `pnpm --filter @objectstack/core test`: 121 files, 2,778 tests passed. - `dispatch-gates --commands` derived 93 families for this diff; every one exits 0, and `--ran` reconciles at **93 derived, 93 run, 0 NOT-MEASURED** (exit codes recorded). `check-plugin-teardown-shape.mjs --self-test` first refused on the shallow clone (exit 3) and passed after `git fetch --unshallow`. Named for this diff: `check:dual-build-cjs-loads` (self-test 93 cases; scan: 112 require entry points across 69 packages, 112 require conditions resolve a CommonJS-flavoured `types` that exists), `check:dts-closure`, `check:lean-entry-closure`, `check:published-files`, `check-dts-emitted.mjs --self-test`, `check-empty-changeset.mjs`, `check-adr-0087-registration.mjs`. - Repo-wide scans (`pnpm lint` first) and the type-check lanes are CI's. ## Acceptance notes - No path under `packages/plugins/plugin-dev/**`, `ui/**` or any governed surface is touched. --- _Generated by [Claude Code](https://claude.ai/code/session_01VPkxx6ackwXchvtVNidtum)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 69aa630 commit 876aa27

4 files changed

Lines changed: 101 additions & 38 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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 ESM format. `dist/index.d.ts` and `dist/logger.d.ts` carry the declarations, as before, and `dist/index.d.cts` and `dist/logger.d.cts` 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.
13+
14+
A CommonJS consumer compiling with `module: node16` (or, measured on TypeScript 5.6, under `nodenext` too) used to get TS1479 at its own `import ... from '@objectstack/core'`. That diagnostic now sits inside core's `.d.cts`, where `skipLibCheck` silences it. Nothing else 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: 61 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,65 @@ 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 ESM only, and the CommonJS
42+
* `index.d.cts` is a one-line re-export of `index.d.ts`: each condition is
43+
* still handed a declaration of its own module kind (attw: no `FalseESM`), but
44+
* every class in both IS the `.d.ts` one, and what that declaration imports
45+
* from `@objectstack/spec` resolves exactly as it did when both conditions
46+
* read the `.d.ts`. ⛔ Not the other way round: with the declarations in the
47+
* `.d.cts` and the `.d.ts` re-exporting it, core's own imports of spec resolve
48+
* through spec's `require` condition, and spec's twin declarations then fail
49+
* `@objectstack/driver-memory`'s build (TS2345, `ComposedReadableIds` against
50+
* itself). The `.d.cts` → `.d.ts` re-export is TS1479 inside this package's
51+
* file for a consumer on `module: node16` (or on TypeScript 5.6 under
52+
* `nodenext`), which `skipLibCheck` silences; the single `types` entry this
53+
* replaces raised that same TS1479 at the consumer's own import, where no
54+
* option silences it.
55+
*
56+
* The JS half's `onSuccess` writes the `.d.cts` files and the declaration half
57+
* never emits one, so the two halves (which tsup runs concurrently) never
58+
* write the same file. `clean` lives on the JS half only: tsup's clean keeps
59+
* declaration files, so it cannot delete what the declaration half writes.
60+
*/
61+
export default defineConfig([
62+
{
63+
...shared,
64+
clean: true,
65+
dts: false,
66+
format: ['esm', 'cjs'],
67+
onSuccess: emitDts
68+
? async () => {
69+
await Promise.all(
70+
ENTRY_NAMES.map((name) => writeFile(`dist/${name}.d.cts`, `export * from './${name}.js';\n`)),
71+
);
72+
}
73+
: undefined,
74+
},
75+
...(emitDts ? [{ ...shared, clean: false, dts: { only: true }, format: ['esm'] } satisfies Options] : []),
76+
]);

‎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)