diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58ba66aa04..0ed4b23197 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,9 +79,12 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 + - run: corepack enable + - run: corepack prepare pnpm@10.17.1 --activate - uses: ./.github/actions/prepare-ffmpeg-bin - run: bun install --frozen-lockfile - run: bun run build + - run: bun run verify:packed-manifests lint: name: Lint diff --git a/package.json b/package.json index 420e7831f6..33b4b16ce2 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/validate-release-channel.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", + "test:scripts": "node --import tsx --test scripts/validate-release-channel.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/verify-packed-manifests.test.mjs", "test:skills": "node --test 'skills/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", "generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 1d88c4e0b9..643e67402a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,7 +13,6 @@ "dist/**/*.d.ts.map", "dist/**/*.js.map", "!dist/hyperframe.runtime.mjs", - "!dist/generated/runtime-inline.js", "docs", "schemas", "README.md" diff --git a/scripts/verify-packed-manifests.mjs b/scripts/verify-packed-manifests.mjs index d423ecc1e1..8fa0da0f5f 100644 --- a/scripts/verify-packed-manifests.mjs +++ b/scripts/verify-packed-manifests.mjs @@ -2,13 +2,15 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import { extname, join } from "node:path"; +import { extname, join, posix } from "node:path"; import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; const ROOT = join(import.meta.dirname, ".."); const PACKAGES_DIR = join(ROOT, "packages"); const DEP_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]; const RUNTIME_IMPORT_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".json", ".wasm", ".node"]); +const PACKED_JAVASCRIPT_FILE_PATTERN = /\.(?:js|mjs|cjs)$/; function listWorkspacePackageDirs() { return readdirSync(PACKAGES_DIR) @@ -129,7 +131,9 @@ function hasExplicitRuntimeExtension(specifier) { function listRelativeImportSpecifiers(source) { const patterns = [ /^\s*import\s+["'](\.\.?\/[^"']+)["']/gm, - /^\s*(?:import|export)\s+[^;]*?\s+from\s+["'](\.\.?\/[^"']+)["']/gm, + /^\s*(?:import|export)\b(?:(?!;)[\s\S])*?\s+from\s+["'](\.\.?\/[^"']+)["']/gm, + /\bimport\s*\(\s*["'](\.\.?\/[^"']+)["']\s*\)/gm, + /\brequire\s*\(\s*["'](\.\.?\/[^"']+)["']\s*\)/gm, ]; const specifiers = []; @@ -177,17 +181,28 @@ function verifyPackedEntrypoints(workspace, packedPackage, packedFiles) { } } -function listJavaScriptImportIssues(filename, file) { - const source = readPackedFile(filename, file); - return listRelativeImportSpecifiers(source) - .filter(({ specifier }) => !hasExplicitRuntimeExtension(specifier)) - .map(({ index, specifier }) => `${file}:${lineNumberAt(source, index)} imports ${specifier}`); +function resolvePackedRelativeImport(fromFile, specifier) { + return posix.normalize(posix.join(posix.dirname(fromFile), stripSpecifierQuery(specifier))); } -function listPackedJavaScriptImportIssues(filename, packedFiles) { +export function listPackedJavaScriptImportIssues(filename, packedFiles) { return [...packedFiles] - .filter((file) => file.endsWith(".js")) - .flatMap((file) => listJavaScriptImportIssues(filename, file)); + .filter((file) => PACKED_JAVASCRIPT_FILE_PATTERN.test(file)) + .flatMap((file) => { + const source = readPackedFile(filename, file); + return listRelativeImportSpecifiers(source).flatMap(({ index, specifier }) => { + if (!hasExplicitRuntimeExtension(specifier)) { + return [`${file}:${lineNumberAt(source, index)} imports ${specifier}`]; + } + + const target = resolvePackedRelativeImport(file, specifier); + if (!packedFiles.has(target)) { + return [`${file}:${lineNumberAt(source, index)} imports missing ${specifier}`]; + } + + return []; + }); + }); } function verifyPackedJavaScriptImports(workspace, filename, packedFiles) { @@ -276,4 +291,6 @@ function main() { listWorkspacePackageDirs().forEach(verifyWorkspace); } -main(); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/verify-packed-manifests.test.mjs b/scripts/verify-packed-manifests.test.mjs new file mode 100644 index 0000000000..3c5713a75b --- /dev/null +++ b/scripts/verify-packed-manifests.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { listPackedJavaScriptImportIssues } from "./verify-packed-manifests.mjs"; + +describe("packed manifest verifier", () => { + function withPackedFiles(files, packedFiles, callback) { + const dir = mkdtempSync(join(tmpdir(), "hyperframes-pack-test-")); + try { + const packageDir = join(dir, "package"); + mkdirSync(packageDir, { recursive: true }); + for (const [file, source] of Object.entries(files)) { + mkdirSync(dirname(join(packageDir, file)), { recursive: true }); + writeFileSync(join(packageDir, file), source, "utf8"); + } + + const tarball = join(dir, "package.tgz"); + execFileSync("tar", ["-czf", tarball, "-C", dir, "package"]); + callback(tarball, new Set(packedFiles)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("passes explicit relative JavaScript imports whose target is packed", () => { + withPackedFiles( + { + "dist/index.js": 'import { runtime } from "./generated/runtime-inline.js";\n', + "dist/generated/runtime-inline.js": "export const runtime = 'ok';\n", + }, + ["dist/index.js", "dist/generated/runtime-inline.js"], + (tarball, packedFiles) => { + assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), []); + }, + ); + }); + + it("reports explicit relative JavaScript imports whose target is missing from the tarball", () => { + withPackedFiles( + { + "dist/index.js": 'import { runtime } from "./generated/runtime-inline.js";\n', + }, + ["dist/index.js"], + (tarball, packedFiles) => { + assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), [ + "dist/index.js:1 imports missing ./generated/runtime-inline.js", + ]); + }, + ); + }); + + it("checks export-from, dynamic import, require, and mjs/cjs files", () => { + withPackedFiles( + { + "dist/index.js": 'export * from "./generated/exports.js";\n', + "dist/dynamic.mjs": 'await import("./generated/dynamic.js");\n', + "dist/require.cjs": 'require("./generated/require.js");\n', + }, + ["dist/index.js", "dist/dynamic.mjs", "dist/require.cjs"], + (tarball, packedFiles) => { + assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), [ + "dist/index.js:1 imports missing ./generated/exports.js", + "dist/dynamic.mjs:1 imports missing ./generated/dynamic.js", + "dist/require.cjs:1 imports missing ./generated/require.js", + ]); + }, + ); + }); + + it("reports extensionless relative imports", () => { + withPackedFiles( + { + "dist/index.js": 'export {\n runtime\n} from "./generated/runtime-inline";\n', + }, + ["dist/index.js"], + (tarball, packedFiles) => { + assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), [ + "dist/index.js:1 imports ./generated/runtime-inline", + ]); + }, + ); + }); + + it("reports side-effect imports whose target is missing from the tarball", () => { + withPackedFiles( + { + "index.js": 'import "./missing.js";\n', + }, + ["index.js"], + (tarball, packedFiles) => { + assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), [ + "index.js:1 imports missing ./missing.js", + ]); + }, + ); + }); +});