fix(build): ship NodeNext-resolvable declarations from every package (#5532)

* fix(build): ship NodeNext-resolvable declarations from every package

tsc copies relative specifiers verbatim, so packages whose sources use
bundler-style extensionless imports (core, inspector) published .d.ts files
that a module/moduleResolution nodenext consumer cannot resolve. Core already
rewrote its emitted JavaScript for this, but not its declarations.

Move that rewrite to scripts/rewrite-relative-import-extensions.mjs, make it
cover .d.ts too (adding .js or /index.js, and failing the build when a
declaration import has no declaration), and run it after every tsc emit:
core, engine, sdk, producer, aws-lambda, gcp-cloud-run and inspector.

verify:packed-manifests now typechecks the clean packed consumer with
skipLibCheck false and type-only imports of every typed subpath, so an
unresolvable import inside a published declaration fails the gate and is
attributed to its package.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(ci): allow the moved core rewrite-esm-extensions script deletion

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci: rebuild the edit-accuracy bench when the import-extension rewriter changes

The package builds now run scripts/rewrite-relative-import-extensions.mjs, so main's
edit_accuracy paths filter has to include it (check-edit-accuracy-filter test).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-10-15 05:59:41 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ec97816a92
commit f55b366175
18 changed files with 322 additions and 148 deletions
+1
View File
@@ -262,6 +262,7 @@ jobs:
- "packages/{studio,studio-server,core,parsers,player,sdk,lint,cli,engine,producer}/{package.json,tsconfig.json,tsup.config.ts}"
- "packages/*/package-subpaths.json"
- "scripts/package-subpaths.mjs"
- "scripts/rewrite-relative-import-extensions.mjs"
- "scripts/ci/base-file.sh"
- "scripts/ci/install-workspace-dependencies.sh"
- ".github/actions/prepare-ffmpeg-bin/**"
+2
View File
@@ -129,6 +129,8 @@ RUN timeout 300 bun install --frozen-lockfile \
#
# Keep the ordering dependency-correct: anything the core build reads must be
# copied above it, and packages nothing above depends on stay below.
# Core's build runs the shared post-emit import rewrite from the repo root.
COPY scripts/rewrite-relative-import-extensions.mjs scripts/rewrite-relative-import-extensions.mjs
COPY packages/parsers/ packages/parsers/
COPY packages/lint/ packages/lint/
COPY packages/studio-server/ packages/studio-server/
+3 -1
View File
@@ -140,7 +140,9 @@ RUN npm install --global bun@1.4.2 && \
bun install --frozen-lockfile
# Bring in the source. Core's build generates files its source imports
# (e.g. runtime-inline); the other packages run from source under bun.
# (e.g. runtime-inline); the other packages run from source under bun. The
# build also runs the shared post-emit import rewrite from the repo root.
COPY scripts/rewrite-relative-import-extensions.mjs ./scripts/rewrite-relative-import-extensions.mjs
COPY packages/core ./packages/core
COPY packages/engine ./packages/engine
COPY packages/lint ./packages/lint
+1 -1
View File
@@ -53,7 +53,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/prune-fallow-audit-caches.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/prune-fallow-audit-caches.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
+4 -1
View File
@@ -56,6 +56,9 @@ await Promise.all([
// through node_modules to the sibling package's already-built `dist/`
// types instead of pulling its full source tree into emit (which would
// violate rootDir).
execSync("tsc -p tsconfig.build.json --emitDeclarationOnly", { stdio: "inherit" });
execSync(
"tsc -p tsconfig.build.json --emitDeclarationOnly && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
{ stdio: "inherit" },
);
console.log("[Build] Complete: dist/{index,handler,sdk/index,cdk/index}.js + .d.ts");
+1 -1
View File
@@ -896,7 +896,7 @@
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "bun run build:fonts && bun run build:svg-selector-aliases && bun run build:hyperframes-runtime && bun run build:position-edits-render && bun run build:audio-fx-runtime && tsc && tsx scripts/rewrite-esm-extensions.ts",
"build": "bun run build:fonts && bun run build:svg-selector-aliases && bun run build:hyperframes-runtime && bun run build:position-edits-render && bun run build:audio-fx-runtime && tsc && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
"build:fonts": "node scripts/build-fonts.mjs",
"test": "bun run check:position-edits-render && bun run check:svg-selector-aliases && bun run build:audio-fx-runtime && vitest run",
"test:watch": "vitest",
@@ -1,77 +0,0 @@
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, extname, join, normalize, resolve } from "node:path";
const distDir = resolve(import.meta.dirname, "../dist");
const runtimeExtensions = new Set([".js", ".mjs", ".cjs", ".json", ".wasm", ".node"]);
function listJavaScriptFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
files.push(...listJavaScriptFiles(fullPath));
} else if (entry.endsWith(".js")) {
files.push(fullPath);
}
}
return files;
}
function hasRuntimeExtension(specifier: string): boolean {
return runtimeExtensions.has(extname(specifier));
}
function isRelativeSpecifier(specifier: string): boolean {
return specifier.startsWith("./") || specifier.startsWith("../");
}
function resolveExistingJsSpecifier(fromFile: string, specifier: string): string | undefined {
const absolute = resolve(dirname(fromFile), specifier);
const jsFile = `${absolute}.js`;
const indexFile = join(absolute, "index.js");
if (existsSync(jsFile)) return `${specifier}.js`;
if (existsSync(indexFile)) return `${specifier}/index.js`;
}
function resolveRuntimeSpecifier(fromFile: string, specifier: string): string {
if (!isRelativeSpecifier(specifier)) return specifier;
if (hasRuntimeExtension(specifier)) return specifier;
return resolveExistingJsSpecifier(fromFile, specifier) ?? specifier;
}
function rewriteSpecifiers(filePath: string, source: string): string {
const patterns = [
/(from\s+["'])(\.\.?\/[^"']+)(["'])/g,
/(import\s+["'])(\.\.?\/[^"']+)(["'])/g,
/(import\(\s*["'])(\.\.?\/[^"']+)(["']\s*\))/g,
];
return patterns.reduce(
(nextSource, pattern) =>
nextSource.replace(pattern, (_match, prefix: string, specifier: string, suffix: string) => {
return `${prefix}${resolveRuntimeSpecifier(filePath, specifier)}${suffix}`;
}),
source,
);
}
let changed = 0;
for (const filePath of listJavaScriptFiles(distDir)) {
const source = readFileSync(filePath, "utf8");
const rewritten = rewriteSpecifiers(filePath, source);
if (rewritten !== source) {
writeFileSync(filePath, rewritten);
changed += 1;
}
}
console.log(
JSON.stringify({
event: "core_esm_extensions_rewritten",
distDir: normalize(distDir),
changedFiles: changed,
}),
);
+1 -1
View File
@@ -80,7 +80,7 @@
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "tsc",
"build": "tsc && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
+3 -1
View File
@@ -153,7 +153,9 @@ RUN bun packages/aws-lambda/scripts/probe-webgpu-adapter.ts \
# image continues from the already-proven binary and installed workspaces.
FROM beginframe-contract AS runtime
# Copy source for the packages the render path needs.
# Copy source for the packages the render path needs, plus the shared post-emit
# import rewrite their builds run.
COPY scripts/rewrite-relative-import-extensions.mjs scripts/rewrite-relative-import-extensions.mjs
COPY packages/core/ packages/core/
COPY packages/engine/ packages/engine/
COPY packages/producer/ packages/producer/
+4 -1
View File
@@ -54,6 +54,9 @@ await Promise.all([
// through node_modules to the sibling package's already-built `dist/`
// types instead of pulling its full source tree into emit (which would
// violate rootDir).
execSync("tsc -p tsconfig.build.json --emitDeclarationOnly", { stdio: "inherit" });
execSync(
"tsc -p tsconfig.build.json --emitDeclarationOnly && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
{ stdio: "inherit" },
);
console.log("[Build] Complete: dist/{index,server,sdk/index,terraform}.js + .d.ts");
+3 -34
View File
@@ -1,16 +1,9 @@
// Builds the inspector for Node and for the page. Bun runs the TypeScript sources directly (package.json "bun").
import { execFileSync } from "node:child_process";
import {
copyFileSync,
existsSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { copyFileSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { build } from "esbuild";
import { rewriteRelativeImportExtensions } from "../../scripts/rewrite-relative-import-extensions.mjs";
const at = (path) => new URL(path, import.meta.url).pathname;
const { dependencies } = JSON.parse(readFileSync(at("./package.json"), "utf8"));
@@ -56,28 +49,4 @@ const tsc = createRequire(import.meta.url).resolve("typescript/bin/tsc");
execFileSync(process.execPath, [tsc, "-p", at("./tsconfig.types.json")], { stdio: "inherit" });
// tsc emits declarations for TypeScript only; the element map's JavaScript ships its own.
copyFileSync(at("./src/elementMap.d.ts"), at("./dist/types/inspector/src/elementMap.d.ts"));
addDeclarationExtensions(at("./dist/types"));
// NodeNext consumers reject extensionless relative imports, and tsc keeps the source's bundler-style ones.
function addDeclarationExtensions(dir) {
for (const entry of readdirSync(dir, { recursive: true })) {
const file = join(dir, entry);
if (!file.endsWith(".d.ts")) continue;
const source = readFileSync(file, "utf8");
const rewritten = source.replace(
/((?:from|import\()\s*["'])(\.{1,2}\/[^"']*)(["'])/g,
(whole, open, specifier, close) => {
const target = resolveDeclaration(dirname(file), specifier);
return target ? open + target + close : whole;
},
);
if (rewritten !== source) writeFileSync(file, rewritten);
}
}
function resolveDeclaration(from, specifier) {
if (/\.[cm]?js$/.test(specifier)) return undefined;
if (existsSync(join(from, `${specifier}.d.ts`))) return `${specifier}.js`;
if (existsSync(join(from, specifier, "index.d.ts"))) return `${specifier}/index.js`;
throw new Error(`No declaration for ${specifier} imported from ${from}`);
}
rewriteRelativeImportExtensions(at("./dist/types"));
+2
View File
@@ -10,6 +10,7 @@ import { mkdirSync, rmSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { BUNDLES, WORKERS } from "./buildEntries.mjs";
import { rewriteRelativeImportExtensions } from "../../scripts/rewrite-relative-import-extensions.mjs";
rmSync("dist", { recursive: true, force: true });
mkdirSync("dist", { recursive: true });
@@ -54,5 +55,6 @@ import { execSync } from "child_process";
execSync("tsc --emitDeclarationOnly --declaration --declarationMap false", {
stdio: "inherit",
});
rewriteRelativeImportExtensions("dist");
console.log("[Build] Complete: dist/index.js, dist/public-server.js, *.d.ts");
+1 -1
View File
@@ -77,7 +77,7 @@
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "tsc",
"build": "tsc && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
+4
View File
@@ -76,6 +76,10 @@ export const ALLOWED_DELETIONS = new Map([
"docs/public/catalog/vendor/three.module.min.json",
"bundled into three-modules.json so catalog scripts run under the docs host policy",
],
[
"packages/core/scripts/rewrite-esm-extensions.ts",
"moved to scripts/rewrite-relative-import-extensions.mjs as plain ESM so packages without tsx can use it; now also rewrites .d.ts",
],
[
"packages/studio/src/hooks/gsapTargetCache.ts",
"its only caller was the deleted var-offset move writer; nothing imports isElementGsapTargeted",
@@ -0,0 +1,70 @@
#!/usr/bin/env node
// Every package whose JavaScript or declarations come out of tsc runs this on its output.
// tsc copies relative specifiers verbatim, so the extensionless `./types` that bundler
// resolution accepts in source would ship as-is, and strict Node ESM (a NodeNext consumer, or
// `node` itself for JavaScript) cannot resolve it. This adds `.js` or `/index.js` after emit.
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const RELATIVE_SPECIFIER_PATTERNS = [
/(\bfrom\s*["'])(\.\.?\/[^"']+)(["'])/g,
/(\bimport\s+["'])(\.\.?\/[^"']+)(["'])/g,
/(\bimport\(\s*["'])(\.\.?\/[^"']+)(["']\s*\))/g,
];
const EXPLICIT_EXTENSION = /\.(?:[cm]?js|json|wasm|node)$/;
/**
* Return the specifier Node ESM resolves to the same file, or undefined when nothing matches.
* A declaration names the JavaScript it describes, so it resolves against declarations but gains `.js`.
*/
function resolveRelativeSpecifier(fromFile, specifier, emittedExtension) {
if (EXPLICIT_EXTENSION.test(specifier)) return specifier;
const target = resolve(dirname(fromFile), specifier);
if (existsSync(`${target}${emittedExtension}`)) return `${specifier}.js`;
if (existsSync(join(target, `index${emittedExtension}`))) return `${specifier}/index.js`;
return undefined;
}
/**
* Rewrite one emitted file. Declarations must resolve, since an unresolved one is a broken type
* contract. JavaScript leaves an unresolved specifier alone: it may sit inside a string, and the
* packed-manifest verifier still rejects a real extensionless import.
*/
function rewriteRelativeImports(file, source) {
const declaration = file.endsWith(".d.ts");
return RELATIVE_SPECIFIER_PATTERNS.reduce(
(text, pattern) =>
text.replace(pattern, (whole, open, specifier, close) => {
const resolved = resolveRelativeSpecifier(file, specifier, declaration ? ".d.ts" : ".js");
if (resolved) return open + resolved + close;
if (declaration) throw new Error(`${file} imports ${specifier}, which has no declaration`);
return whole;
}),
source,
);
}
const EMITTED_FILE = /\.(?:js|d\.ts)$/;
function rewriteEmittedFile(file) {
const source = readFileSync(file, "utf8");
const rewritten = rewriteRelativeImports(file, source);
if (rewritten === source) return false;
writeFileSync(file, rewritten);
return true;
}
export function rewriteRelativeImportExtensions(dir) {
return readdirSync(dir, { recursive: true })
.map((entry) => join(dir, entry))
.filter((file) => EMITTED_FILE.test(file))
.filter(rewriteEmittedFile).length;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const dir = resolve(process.argv[2] ?? "dist");
const changedFiles = rewriteRelativeImportExtensions(dir);
console.log(JSON.stringify({ event: "relative_import_extensions_rewritten", dir, changedFiles }));
}
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, it } from "node:test";
import { rewriteRelativeImportExtensions } from "./rewrite-relative-import-extensions.mjs";
function withEmittedFiles(files, callback) {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-rewrite-test-"));
try {
for (const [file, source] of Object.entries(files)) {
mkdirSync(dirname(join(dir, file)), { recursive: true });
writeFileSync(join(dir, file), source);
}
callback(dir, (file) => readFileSync(join(dir, file), "utf8"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("relative import extension rewrite", () => {
it("points declaration imports at the JavaScript they describe", () => {
withEmittedFiles(
{
"index.d.ts": [
'import type { A } from "./types";',
'export * from "./adapters";',
'import "./side";',
'export declare const c: import("./types").A;',
'export type { D } from "./explicit.js";',
"",
].join("\n"),
"types.d.ts": "export type A = string;\n",
"adapters/index.d.ts": "export {};\n",
"b.d.ts": "export declare const b: number;\n",
"nested/c.d.ts": 'export { b } from "../b";\n',
"side.d.ts": "export {};\n",
},
(dir, read) => {
assert.equal(rewriteRelativeImportExtensions(dir), 2);
assert.equal(
read("index.d.ts"),
[
'import type { A } from "./types.js";',
'export * from "./adapters/index.js";',
'import "./side.js";',
'export declare const c: import("./types.js").A;',
'export type { D } from "./explicit.js";',
"",
].join("\n"),
);
assert.equal(read("nested/c.d.ts"), 'export { b } from "../b.js";\n');
},
);
});
it("fails the build when a declaration import has no declaration behind it", () => {
withEmittedFiles({ "index.d.ts": 'export * from "./missing";\n' }, (dir) => {
assert.throws(
() => rewriteRelativeImportExtensions(dir),
/index\.d\.ts imports \.\/missing, which has no declaration/,
);
});
});
it("resolves JavaScript against emitted JavaScript and leaves unmatched text alone", () => {
withEmittedFiles(
{
"index.js": 'export * from "./util";\nconst snippet = \'import x from "./not-emitted"\';\n',
"util.js": "export {};\n",
},
(dir, read) => {
rewriteRelativeImportExtensions(dir);
assert.equal(
read("index.js"),
'export * from "./util.js";\nconst snippet = \'import x from "./not-emitted"\';\n',
);
},
);
});
it("leaves already-correct output untouched", () => {
withEmittedFiles(
{ "index.d.ts": 'export * from "./a.js";\n', "a.d.ts": "export {};\n" },
(dir) => assert.equal(rewriteRelativeImportExtensions(dir), 0),
);
});
});
+65 -29
View File
@@ -335,6 +335,62 @@ export function renderBrowserConsumer(specifiers) {
].join("\n");
}
/** Name every typed export in a type-only import: a bare `import "x"` lets tsc skip unresolved targets. */
export function renderTypeConsumer(specifiers) {
return `${specifiers
.map(
(specifier, index) =>
`import type * as packedTypes${index} from ${JSON.stringify(specifier)};`,
)
.join("\n")}\nexport {};\n`;
}
/** Strict Node ESM, with declarations checked: skipLibCheck hides unresolvable imports inside .d.ts files. */
export const CONSUMER_TSCONFIG = {
compilerOptions: {
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
lib: ["ESNext", "DOM", "DOM.Iterable"],
types: ["node"],
strict: true,
skipLibCheck: false,
noEmit: true,
},
include: ["consumer.ts"],
};
/** Count tsc errors by the installed package they come from, so a failure names its owner. */
export function countTypeErrorsByPackage(output) {
const counts = {};
for (const line of output.split("\n")) {
const match = /^(\S+?)\(\d+,\d+\): error TS\d+/.exec(line);
if (!match) continue;
const owner = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(match[1])?.[1] ?? match[1];
counts[owner] = (counts[owner] ?? 0) + 1;
}
return counts;
}
function typecheckPackedConsumer(fixtureDir) {
try {
execFileSync(process.execPath, [join(fixtureDir, "node_modules", "typescript", "bin", "tsc")], {
cwd: fixtureDir,
encoding: "utf8",
stdio: "pipe",
});
} catch (error) {
const output = `${error.stdout ?? ""}${error.stderr ?? ""}`;
const counts = Object.entries(countTypeErrorsByPackage(output))
.map(([owner, count]) => `${owner}: ${count}`)
.join(", ");
throw new Error(
`Packed declarations fail a NodeNext consumer (module/moduleResolution nodenext, skipLibCheck false). ` +
`Errors by package: ${counts || "unknown"}\n${output.split("\n").slice(0, 20).join("\n")}`,
);
}
}
function writeConsumerFixture(packDir, packedWorkspaces) {
const fixtureDir = join(packDir, "consumer");
mkdirSync(fixtureDir);
@@ -356,6 +412,9 @@ function writeConsumerFixture(packDir, packedWorkspaces) {
readFileSync(join(ROOT, "packages/studio/package.json"), "utf8"),
);
dependencies.vite = studioPackage.devDependencies.vite;
// Studio's declarations name React types; a React consumer brings these itself.
dependencies["@types/react"] = studioPackage.devDependencies["@types/react"];
dependencies["@types/react-dom"] = studioPackage.devDependencies["@types/react-dom"];
writeFileSync(
join(fixtureDir, "package.json"),
JSON.stringify(
@@ -377,30 +436,11 @@ function writeConsumerFixture(packDir, packedWorkspaces) {
);
const contracts = listPackedExportContracts(packedWorkspaces);
const typeImports = contracts
const typedSpecifiers = contracts
.filter(({ typechecked }) => typechecked)
.map(({ specifier }) => `import ${JSON.stringify(specifier)};`)
.join("\n");
writeFileSync(join(fixtureDir, "consumer.ts"), `${typeImports}\n`);
writeFileSync(
join(fixtureDir, "tsconfig.json"),
JSON.stringify(
{
compilerOptions: {
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
lib: ["ES2022", "DOM", "DOM.Iterable"],
strict: true,
skipLibCheck: true,
noEmit: true,
},
include: ["consumer.ts"],
},
null,
2,
),
);
.map(({ specifier }) => specifier);
writeFileSync(join(fixtureDir, "consumer.ts"), renderTypeConsumer(typedSpecifiers));
writeFileSync(join(fixtureDir, "tsconfig.json"), JSON.stringify(CONSUMER_TSCONFIG, null, 2));
const specifiers = contracts.map(({ specifier }) => specifier);
const nodeSpecifiers = contracts
@@ -441,11 +481,7 @@ function verifyPackedConsumer(packDir, packedWorkspaces) {
encoding: "utf8",
stdio: "pipe",
});
execFileSync(process.execPath, [join(fixtureDir, "node_modules", "typescript", "bin", "tsc")], {
cwd: fixtureDir,
encoding: "utf8",
stdio: "pipe",
});
typecheckPackedConsumer(fixtureDir);
const smokeOutput = execFileSync("node", ["consumer-smoke.mjs"], {
cwd: fixtureDir,
encoding: "utf8",
@@ -469,7 +505,7 @@ function verifyPackedConsumer(packDir, packedWorkspaces) {
}
console.log(smokeOutput.trim());
console.log(
"Verified clean packed consumer install, Node execution, TypeScript/Vite resolution, and CLI startup.",
"Verified clean packed consumer install, Node execution, NodeNext declaration check, Vite resolution, and CLI startup.",
);
}
+69
View File
@@ -5,14 +5,19 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, it } from "node:test";
import {
CONSUMER_TSCONFIG,
countTypeErrorsByPackage,
listPackedExportContracts,
listPackageSizeIssues,
listPackedJavaScriptImportIssues,
packageExportSpecifier,
renderBrowserConsumer,
renderTypeConsumer,
verifyCliLicense,
} from "./verify-packed-manifests.mjs";
const TSC = join(import.meta.dirname, "..", "node_modules", "typescript", "bin", "tsc");
describe("packed manifest verifier", () => {
it("passes a package inside its size budget", () => {
const budget = { packed: 1000, unpacked: 4000 };
@@ -224,4 +229,68 @@ describe("packed manifest verifier", () => {
},
);
});
it("names each typed export so tsc must resolve and check its declarations", () => {
assert.equal(
renderTypeConsumer(["@hyperframes/core", "@hyperframes/core/lint"]),
'import type * as packedTypes0 from "@hyperframes/core";\n' +
'import type * as packedTypes1 from "@hyperframes/core/lint";\n' +
"export {};\n",
);
});
it("attributes consumer type errors to the installed package that owns them", () => {
const output = [
"node_modules/@hyperframes/core/dist/adapters/gsap.d.ts(1,35): error TS2835: Relative import paths need explicit file extensions.",
"node_modules/@hyperframes/core/dist/index.d.ts(2,15): error TS2307: Cannot find module './core.types'.",
"node_modules/hyperframes/dist/cli.d.ts(1,1): error TS2307: Cannot find module './x'.",
"consumer.ts(1,30): error TS2307: Cannot find module '@hyperframes/missing'.",
" Did you mean './types.js'?",
].join("\n");
assert.deepEqual(countTypeErrorsByPackage(output), {
"@hyperframes/core": 2,
hyperframes: 1,
"consumer.ts": 1,
});
});
function typecheckConsumerOf(declaration) {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-nodenext-test-"));
try {
const pkgDir = join(dir, "node_modules", "@hyperframes", "example");
mkdirSync(join(pkgDir, "dist"), { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "@hyperframes/example",
type: "module",
exports: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } },
}),
);
writeFileSync(join(pkgDir, "dist", "index.d.ts"), declaration);
writeFileSync(join(pkgDir, "dist", "types.d.ts"), "export type Example = string;\n");
writeFileSync(join(dir, "consumer.ts"), renderTypeConsumer(["@hyperframes/example"]));
// The fixture has no @types/node to load; everything else matches the packed gate.
const compilerOptions = { ...CONSUMER_TSCONFIG.compilerOptions, types: [] };
writeFileSync(
join(dir, "tsconfig.json"),
JSON.stringify({ ...CONSUMER_TSCONFIG, compilerOptions }),
);
execFileSync(process.execPath, [TSC], { cwd: dir, encoding: "utf8", stdio: "pipe" });
return "";
} catch (error) {
return String(error.stdout);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it("fails a NodeNext consumer on an extensionless import inside a published declaration", () => {
assert.equal(CONSUMER_TSCONFIG.compilerOptions.skipLibCheck, false);
assert.match(
typecheckConsumerOf('export type { Example } from "./types";\n'),
/node_modules\/@hyperframes\/example\/dist\/index\.d\.ts\(1,\d+\): error TS283[45]/,
);
assert.equal(typecheckConsumerOf('export type { Example } from "./types.js";\n'), "");
});
});