From 5aad2623a1aaedf7b273d8287600508e4dbaa1a7 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 30 Jul 2026 11:27:12 +0000 Subject: [PATCH] build: extend legacy type resolution to Octagonal Wheels --- scripts/generate-type-resolution-compat.mjs | 241 +++++++++++++++--- src/apps/browser/tsconfig.json | 4 +- src/apps/cli/tsconfig.json | 4 +- src/apps/webapp/tsconfig.json | 4 +- src/apps/webpeer/tsconfig.app.json | 4 +- src/apps/webpeer/tsconfig.node.json | 6 +- test/type-resolution-compat/generator.test.ts | 149 +++++++++-- .../legacy-resolution.test.ts | 108 +++++++- .../tsconfig.compat.json | 4 +- .../warning-delta.test.ts | 20 +- tsconfig.json | 4 +- 11 files changed, 461 insertions(+), 87 deletions(-) diff --git a/scripts/generate-type-resolution-compat.mjs b/scripts/generate-type-resolution-compat.mjs index a61b4db7..a01e506e 100644 --- a/scripts/generate-type-resolution-compat.mjs +++ b/scripts/generate-type-resolution-compat.mjs @@ -1,13 +1,39 @@ import { createRequire } from "node:module"; -import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { + copyFile, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const PACKAGE_NAME = "@vrtmrz/livesync-commonlib"; +const COMMONLIB_PACKAGE_NAME = "@vrtmrz/livesync-commonlib"; +const PACKAGE_DEFINITIONS = [ + { + name: COMMONLIB_PACKAGE_NAME, + versionSource: "exact-dependency", + }, + { + name: "octagonal-wheels", + versionSource: "lockfile", + }, +]; const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const DEFAULT_OUTPUT_ROOT = path.join(PROJECT_ROOT, "dist", "type-resolution-compat"); const DECLARATION_PATTERN = /\.d\.(?:c|m)?ts$/u; const DECLARATION_MAP_PATTERN = /\.d\.(?:c|m)?ts\.map$/u; +const DIRECT_DEFAULT_EXPORT_PATTERN = /\bexport\s+(?:default\b|=)/u; +const DEFAULT_NAMESPACE_EXPORT_PATTERN = /\bexport\s*\*\s*as\s+default\b/u; +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u; +const EXPORT_CLAUSE_PATTERN = /\bexport\s*\{([^}]*)\}/gu; const SUPPRESSION_PATTERN = /@ts-(?:expect-error|ignore|nocheck)|eslint-(?:disable|enable)/u; function fail(message) { @@ -22,6 +48,7 @@ function parseArguments(argv) { const options = { expectedVersion: undefined, outputRoot: DEFAULT_OUTPUT_ROOT, + packageName: undefined, packageRoot: undefined, }; for (let index = 0; index < argv.length; index += 1) { @@ -39,10 +66,20 @@ function parseArguments(argv) { if (value === undefined) fail("--package-root requires a value"); options.packageRoot = path.resolve(value); index += 1; + } else if (argument === "--package-name") { + if (value === undefined) fail("--package-name requires a value"); + options.packageName = value; + index += 1; } else { fail(`Unknown argument: ${argument}`); } } + if ( + options.packageRoot === undefined && + (options.packageName !== undefined || options.expectedVersion !== undefined) + ) { + fail("--package-name and --expected-version require --package-root"); + } return options; } @@ -108,39 +145,101 @@ async function pathExists(filePath) { } } -async function validateOutputRoot(outputRoot, packageRoot) { - const filesystemRoot = path.parse(outputRoot).root; - if ( - outputRoot === filesystemRoot || - outputRoot === PROJECT_ROOT || - outputRoot === packageRoot || - isWithin(outputRoot, PROJECT_ROOT) || - isWithin(outputRoot, packageRoot) - ) { - fail(`Refusing unsafe output directory: ${outputRoot}`); +async function canonicaliseProspectivePath(filePath) { + let existingAncestor = filePath; + const missingSegments = []; + while (!(await pathExists(existingAncestor))) { + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) fail(`Cannot resolve output directory: ${filePath}`); + missingSegments.unshift(path.basename(existingAncestor)); + existingAncestor = parent; } + return path.resolve(await realpath(existingAncestor), ...missingSegments); +} + +async function validateOutputRoot(outputRoot, packageRoots) { if (await pathExists(outputRoot)) { const outputStat = await lstat(outputRoot); if (outputStat.isSymbolicLink() || !outputStat.isDirectory()) { fail(`Output path must be a real directory when it already exists: ${outputRoot}`); } } + const canonicalOutputRoot = await canonicaliseProspectivePath(outputRoot); + const canonicalProjectRoot = await realpath(PROJECT_ROOT); + const canonicalPackageRoots = await Promise.all( + packageRoots.map(async (packageRoot) => await realpath(packageRoot)) + ); + const filesystemRoot = path.parse(canonicalOutputRoot).root; + const conflictsWithPackage = canonicalPackageRoots.some( + (packageRoot) => + canonicalOutputRoot === packageRoot || + isWithin(canonicalOutputRoot, packageRoot) || + isWithin(packageRoot, canonicalOutputRoot) + ); + if ( + canonicalOutputRoot === filesystemRoot || + canonicalOutputRoot === canonicalProjectRoot || + isWithin(canonicalOutputRoot, canonicalProjectRoot) || + conflictsWithPackage + ) { + fail(`Refusing unsafe output directory: ${outputRoot}`); + } } -async function findInstalledPackageRoot() { +function findInstalledPackageRoot(packageName) { const require = createRequire(import.meta.url); - const packageJsonPath = require.resolve(`${PACKAGE_NAME}/package.json`, { paths: [PROJECT_ROOT] }); + const packageJsonPath = require.resolve(`${packageName}/package.json`, { paths: [PROJECT_ROOT] }); return path.dirname(packageJsonPath); } -async function determineExpectedVersion(argumentVersion) { +async function determineExpectedVersion(packageName, versionSource, argumentVersion) { if (argumentVersion !== undefined) return argumentVersion; const projectPackage = await readJson(path.join(PROJECT_ROOT, "package.json"), "project package.json"); - const dependencyVersion = projectPackage.dependencies?.[PACKAGE_NAME]; - if (typeof dependencyVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(dependencyVersion)) { - fail(`${PACKAGE_NAME} must be selected by one exact version in project dependencies`); + const dependencyVersion = projectPackage.dependencies?.[packageName]; + if (typeof dependencyVersion !== "string") { + fail(`${packageName} must be selected in project dependencies`); } - return dependencyVersion; + if (versionSource === "exact-dependency") { + if (!EXACT_VERSION_PATTERN.test(dependencyVersion)) { + fail(`${packageName} must be selected by one exact version in project dependencies`); + } + return dependencyVersion; + } + if (versionSource !== "lockfile") { + fail(`Unsupported version source for ${packageName}: ${String(versionSource)}`); + } + const packageLock = await readJson(path.join(PROJECT_ROOT, "package-lock.json"), "project package-lock.json"); + const lockVersion = packageLock.packages?.[`node_modules/${packageName}`]?.version; + if (typeof lockVersion !== "string" || !EXACT_VERSION_PATTERN.test(lockVersion)) { + fail(`${packageName} must have one exact installed version in project package-lock.json`); + } + return lockVersion; +} + +async function resolvePackageInputs(options) { + if (options.packageRoot !== undefined) { + const packageName = options.packageName ?? COMMONLIB_PACKAGE_NAME; + const definition = PACKAGE_DEFINITIONS.find((candidate) => candidate.name === packageName); + if (definition === undefined) fail(`Unsupported package name: ${packageName}`); + return [ + { + expectedVersion: await determineExpectedVersion( + packageName, + definition.versionSource, + options.expectedVersion + ), + packageName, + packageRoot: options.packageRoot, + }, + ]; + } + return await Promise.all( + PACKAGE_DEFINITIONS.map(async (definition) => ({ + expectedVersion: await determineExpectedVersion(definition.name, definition.versionSource, undefined), + packageName: definition.name, + packageRoot: findInstalledPackageRoot(definition.name), + })) + ); } function declarationRuntimeSpecifier(declarationPath) { @@ -185,9 +284,40 @@ async function collectDeclarationFiles(packageRoot) { return files; } -async function validateAndCollectExports(packageRoot, packageJson) { - if (packageJson.name !== PACKAGE_NAME) { - fail(`Expected package name ${PACKAGE_NAME}, received ${String(packageJson.name)}`); +function hasDefaultExport(source) { + if (DIRECT_DEFAULT_EXPORT_PATTERN.test(source) || DEFAULT_NAMESPACE_EXPORT_PATTERN.test(source)) return true; + for (const match of source.matchAll(EXPORT_CLAUSE_PATTERN)) { + for (const rawSpecifier of match[1].split(",")) { + const specifier = rawSpecifier.trim().replace(/^type\s+/u, ""); + if ( + specifier === "default" || + specifier === '"default"' || + specifier === "'default'" || + /\bas\s+(?:default|"default"|'default')$/u.test(specifier) + ) { + return true; + } + } + } + return false; +} + +async function validateWrapperExports(packageRoot, typedExports) { + const checkedTargets = new Set(); + for (const { exportSubpath, typesTarget } of typedExports) { + if (checkedTargets.has(typesTarget)) continue; + checkedTargets.add(typesTarget); + const targetPath = resolveInsidePackage(packageRoot, typesTarget, `types target for export '${exportSubpath}'`); + const source = await readFile(targetPath, "utf8"); + if (hasDefaultExport(source)) { + fail(`Types target for export '${exportSubpath}' contains an unsupported default export: ${typesTarget}`); + } + } +} + +async function validateAndCollectExports(packageName, packageRoot, packageJson) { + if (packageJson.name !== packageName) { + fail(`Expected package name ${packageName}, received ${String(packageJson.name)}`); } if (packageJson.exports === null || typeof packageJson.exports !== "object" || Array.isArray(packageJson.exports)) { fail("Package exports must be an object"); @@ -303,16 +433,13 @@ async function replaceOutputDirectory(temporaryRoot, outputRoot) { if (backupRoot !== undefined) await rm(backupRoot, { recursive: true }); } -async function generate(options) { - const packageRoot = options.packageRoot ?? (await findInstalledPackageRoot()); - const packageJson = await readJson(path.join(packageRoot, "package.json"), `${PACKAGE_NAME} package.json`); - const expectedVersion = await determineExpectedVersion(options.expectedVersion); +async function preparePackage({ expectedVersion, packageName, packageRoot }) { + const packageJson = await readJson(path.join(packageRoot, "package.json"), `${packageName} package.json`); if (packageJson.version !== expectedVersion) { - fail(`Expected ${PACKAGE_NAME}@${expectedVersion}, received ${String(packageJson.version)}`); + fail(`Expected ${packageName}@${expectedVersion}, received ${String(packageJson.version)}`); } - await validateOutputRoot(options.outputRoot, packageRoot); - const { exportCount, metadataExportCount, typedExports } = await validateAndCollectExports( + packageName, packageRoot, packageJson ); @@ -323,15 +450,56 @@ async function generate(options) { fail(`Types target for export '${exportSubpath}' is outside the copied declaration tree: ${typesTarget}`); } } + await validateWrapperExports(packageRoot, typedExports); + return { + declarationFiles, + exportCount, + metadataExportCount, + packageJson, + packageName, + packageRoot, + typedExports, + }; +} +async function writePreparedPackage(temporaryRoot, preparedPackage) { + const { declarationFiles, packageName, packageRoot, typedExports } = preparedPackage; + const publicPackageRoot = path.join(temporaryRoot, ...packageName.split("/")); + const internalRoot = path.join(publicPackageRoot, "__package__"); + await copyDeclarationTree(packageRoot, internalRoot, declarationFiles); + await writeWrappers(publicPackageRoot, typedExports); +} + +function packageSummary(preparedPackage) { + const { declarationFiles, exportCount, metadataExportCount, packageJson, packageName, typedExports } = + preparedPackage; + return { + declarationCount: declarationFiles.filter((filePath) => DECLARATION_PATTERN.test(filePath)).length, + exportCount, + metadataExportCount, + packageName, + packageVersion: packageJson.version, + typedExportCount: typedExports.length, + }; +} + +async function generate(options) { + const packageInputs = await resolvePackageInputs(options); + await validateOutputRoot( + options.outputRoot, + packageInputs.map(({ packageRoot }) => packageRoot) + ); + const preparedPackages = []; + for (const packageInput of packageInputs) { + preparedPackages.push(await preparePackage(packageInput)); + } const outputParent = path.dirname(options.outputRoot); await mkdir(outputParent, { recursive: true }); const temporaryRoot = await mkdtemp(path.join(outputParent, `.${path.basename(options.outputRoot)}.tmp-`)); try { - const publicPackageRoot = path.join(temporaryRoot, ...PACKAGE_NAME.split("/")); - const internalRoot = path.join(publicPackageRoot, "__package__"); - await copyDeclarationTree(packageRoot, internalRoot, declarationFiles); - await writeWrappers(publicPackageRoot, typedExports); + for (const preparedPackage of preparedPackages) { + await writePreparedPackage(temporaryRoot, preparedPackage); + } await replaceOutputDirectory(temporaryRoot, options.outputRoot); } catch (error) { if (await pathExists(temporaryRoot)) await rm(temporaryRoot, { recursive: true }); @@ -339,13 +507,8 @@ async function generate(options) { } return { - declarationCount: declarationFiles.filter((filePath) => DECLARATION_PATTERN.test(filePath)).length, - exportCount, - metadataExportCount, outputDirectory: options.outputRoot, - packageName: PACKAGE_NAME, - packageVersion: packageJson.version, - typedExportCount: typedExports.length, + packages: preparedPackages.map(packageSummary), }; } diff --git a/src/apps/browser/tsconfig.json b/src/apps/browser/tsconfig.json index 4330c507..61123212 100644 --- a/src/apps/browser/tsconfig.json +++ b/src/apps/browser/tsconfig.json @@ -10,7 +10,9 @@ "paths": { "@/*": ["../../*"], "@vrtmrz/livesync-commonlib": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], - "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] + "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"], + "octagonal-wheels": ["../../../dist/type-resolution-compat/octagonal-wheels/index"], + "octagonal-wheels/*": ["../../../dist/type-resolution-compat/octagonal-wheels/*"] } }, "include": ["**/*.ts", "**/*.svelte"], diff --git a/src/apps/cli/tsconfig.json b/src/apps/cli/tsconfig.json index 747fc31d..1d0efdee 100644 --- a/src/apps/cli/tsconfig.json +++ b/src/apps/cli/tsconfig.json @@ -25,7 +25,9 @@ "paths": { "@/*": ["../../*"], "@vrtmrz/livesync-commonlib": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], - "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] + "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"], + "octagonal-wheels": ["../../../dist/type-resolution-compat/octagonal-wheels/index"], + "octagonal-wheels/*": ["../../../dist/type-resolution-compat/octagonal-wheels/*"] } }, "include": ["*.ts", "**/*.ts", "**/*.tsx"], diff --git a/src/apps/webapp/tsconfig.json b/src/apps/webapp/tsconfig.json index bcbe39eb..dd780a09 100644 --- a/src/apps/webapp/tsconfig.json +++ b/src/apps/webapp/tsconfig.json @@ -25,7 +25,9 @@ "paths": { "@/*": ["../../*"], "@vrtmrz/livesync-commonlib": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], - "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] + "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"], + "octagonal-wheels": ["../../../dist/type-resolution-compat/octagonal-wheels/index"], + "octagonal-wheels/*": ["../../../dist/type-resolution-compat/octagonal-wheels/*"] } }, "include": ["*.ts", "**/*.ts", "**/*.tsx", "**/*.svelte"], diff --git a/src/apps/webpeer/tsconfig.app.json b/src/apps/webpeer/tsconfig.app.json index f74c56dd..2b086d72 100644 --- a/src/apps/webpeer/tsconfig.app.json +++ b/src/apps/webpeer/tsconfig.app.json @@ -20,7 +20,9 @@ "paths": { "@/*": ["../../*"], "@vrtmrz/livesync-commonlib": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], - "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] + "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"], + "octagonal-wheels": ["../../../dist/type-resolution-compat/octagonal-wheels/index"], + "octagonal-wheels/*": ["../../../dist/type-resolution-compat/octagonal-wheels/*"] } }, "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], diff --git a/src/apps/webpeer/tsconfig.node.json b/src/apps/webpeer/tsconfig.node.json index 270a51b1..8369119a 100644 --- a/src/apps/webpeer/tsconfig.node.json +++ b/src/apps/webpeer/tsconfig.node.json @@ -20,7 +20,11 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, "paths": { - "@/*": ["../../*"] + "@/*": ["../../*"], + "@vrtmrz/livesync-commonlib": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], + "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"], + "octagonal-wheels": ["../../../dist/type-resolution-compat/octagonal-wheels/index"], + "octagonal-wheels/*": ["../../../dist/type-resolution-compat/octagonal-wheels/*"] } }, "include": ["vite.config.ts"] diff --git a/test/type-resolution-compat/generator.test.ts b/test/type-resolution-compat/generator.test.ts index 19f21af6..580a46e9 100644 --- a/test/type-resolution-compat/generator.test.ts +++ b/test/type-resolution-compat/generator.test.ts @@ -65,6 +65,16 @@ async function directoryDigest(root: string): Promise { return [...digest].map((value) => value.toString(16).padStart(2, "0")).join(""); } +async function pathExists(path: string): Promise { + try { + await Deno.lstat(path); + return true; + } catch (error) { + if (error instanceof Deno.errors.NotFound) return false; + throw error; + } +} + async function writeSyntheticPackage( packageRoot: string, exports: Record, @@ -112,35 +122,65 @@ function validSyntheticExports(): Record { }; } -Deno.test("generator covers the installed Commonlib export map deterministically", async () => { +Deno.test("generator covers the installed package export maps deterministically", async () => { const repositoryRoot = await Deno.realPath(new URL("../../", import.meta.url)); const temporaryDirectory = await Deno.makeTempDir({ prefix: "livesync-type-generator-" }); const outputRoot = `${temporaryDirectory}/output`; try { - const packageJson = JSON.parse( - await Deno.readTextFile(`${repositoryRoot}/node_modules/@vrtmrz/livesync-commonlib/package.json`) - ) as { exports: Record }; - const exportDefinitions = Object.values(packageJson.exports); - const expectedTypedExports = exportDefinitions.filter( - (definition) => - typeof definition === "object" && - definition !== null && - "types" in definition && - typeof definition.types === "string" - ).length; + const installedPackages = await Promise.all( + ["@vrtmrz/livesync-commonlib", "octagonal-wheels"].map(async (packageName) => { + const packageJson = JSON.parse( + await Deno.readTextFile(`${repositoryRoot}/node_modules/${packageName}/package.json`) + ) as { exports: Record }; + const typedExportCount = Object.values(packageJson.exports).filter( + (definition) => + typeof definition === "object" && + definition !== null && + "types" in definition && + typeof definition.types === "string" + ).length; + return { packageJson, packageName, typedExportCount }; + }) + ); const first = await runGenerator(repositoryRoot, ["--output", outputRoot]); assert(first.success, `first generation failed:\n${first.stderr}`); const firstSummary = JSON.parse(first.stdout) as { - declarationCount: number; - exportCount: number; - metadataExportCount: number; - typedExportCount: number; + packages: { + declarationCount: number; + exportCount: number; + metadataExportCount: number; + packageName: string; + typedExportCount: number; + }[]; }; - assert(firstSummary.exportCount === Object.keys(packageJson.exports).length, "not every export was covered"); - assert(firstSummary.typedExportCount === expectedTypedExports, "typed export coverage is incomplete"); - assert(firstSummary.metadataExportCount === 1, "the package metadata export was not accounted for"); - assert(firstSummary.declarationCount === 244, "the installed Commonlib declaration count changed unexpectedly"); + assert(firstSummary.packages.length === installedPackages.length, "not every package was generated"); + const expectedDeclarationCounts = new Map([ + ["@vrtmrz/livesync-commonlib", 244], + ["octagonal-wheels", 109], + ]); + for (const installedPackage of installedPackages) { + const summary = firstSummary.packages.find( + (candidate) => candidate.packageName === installedPackage.packageName + ); + assert(summary !== undefined, `${installedPackage.packageName} is missing from the generation summary`); + assert( + summary.exportCount === Object.keys(installedPackage.packageJson.exports).length, + `${installedPackage.packageName} export coverage is incomplete` + ); + assert( + summary.typedExportCount === installedPackage.typedExportCount, + `${installedPackage.packageName} typed export coverage is incomplete` + ); + assert( + summary.metadataExportCount === 1, + `${installedPackage.packageName} metadata export was not accounted for` + ); + assert( + summary.declarationCount === expectedDeclarationCounts.get(installedPackage.packageName), + `${installedPackage.packageName} declaration count changed unexpectedly` + ); + } const firstDigest = await directoryDigest(outputRoot); const second = await runGenerator(repositoryRoot, ["--output", outputRoot]); @@ -151,7 +191,11 @@ Deno.test("generator covers the installed Commonlib export map deterministically const publicWrappers = generatedFiles.filter( (filePath) => filePath.endsWith(".d.ts") && !filePath.includes("/__package__/") ); - assert(publicWrappers.length === expectedTypedExports, "generated wrapper count does not match typed exports"); + const expectedWrapperCount = installedPackages.reduce( + (total, installedPackage) => total + installedPackage.typedExportCount, + 0 + ); + assert(publicWrappers.length === expectedWrapperCount, "generated wrapper count does not match typed exports"); for (const filePath of generatedFiles.filter((candidate) => candidate.endsWith(".d.ts"))) { const source = await Deno.readTextFile(`${outputRoot}/${filePath}`); assert( @@ -164,6 +208,23 @@ Deno.test("generator covers the installed Commonlib export map deterministically 'export * from "../../__package__/dist/common/types.js";\n', "representative wrapper does not re-export the copied declaration" ); + const octagonalWheelsWrappers = new Map([ + ["octagonal-wheels/common/types.d.ts", 'export * from "../__package__/dist/common/types.js";\n'], + [ + "octagonal-wheels/databases/SimpleStoreBase.d.ts", + 'export * from "../__package__/dist/databases/SimpleStoreBase.js";\n', + ], + [ + "octagonal-wheels/dataobject/reactive.d.ts", + 'export * from "../__package__/dist/dataobject/reactive.js";\n', + ], + ]); + for (const [relativePath, expectedSource] of octagonalWheelsWrappers) { + assert( + (await Deno.readTextFile(`${outputRoot}/${relativePath}`)) === expectedSource, + `Octagonal Wheels wrapper does not re-export its copied declaration: ${relativePath}` + ); + } const ignored = await new Deno.Command("git", { args: ["check-ignore", "--quiet", "dist/type-resolution-compat"], @@ -190,6 +251,30 @@ Deno.test("generator rejects invalid package boundaries and preserves the previo assert(initial.success, `valid synthetic generation failed:\n${initial.stderr}`); const initialDigest = await directoryDigest(outputRoot); + const nestedOutput = `${packageRoot}/generated`; + const nestedOutputResult = await runGenerator(repositoryRoot, [...commonArguments, "--output", nestedOutput]); + assert(!nestedOutputResult.success, "an output inside the input package was accepted"); + assert(nestedOutputResult.stderr.includes("unsafe output"), "unsafe nested output failure was not explained"); + assert(!(await pathExists(nestedOutput)), "unsafe nested output was created"); + + if (Deno.build.os !== "windows") { + const linkedPackageRoot = `${temporaryDirectory}/linked-package`; + await Deno.symlink(packageRoot, linkedPackageRoot); + const linkedOutput = `${linkedPackageRoot}/generated`; + const linkedOutputResult = await runGenerator(repositoryRoot, [ + ...commonArguments, + "--output", + linkedOutput, + ]); + assert(!linkedOutputResult.success, "a symlinked output inside the input package was accepted"); + assert( + linkedOutputResult.stderr.includes("unsafe output"), + "unsafe symlinked-output failure was not explained" + ); + assert(!(await pathExists(linkedOutput)), "unsafe symlinked output was created"); + } + assert((await directoryDigest(outputRoot)) === initialDigest, "unsafe output validation replaced prior output"); + await writeSyntheticPackage( packageRoot, { @@ -260,6 +345,28 @@ Deno.test("generator rejects invalid package boundaries and preserves the previo assert(!suppression.success, "a declaration suppression directive was copied"); assert(suppression.stderr.includes("suppression directive"), "suppression failure was not explained"); + const unsupportedDefaultExports = [ + "export default interface UnsupportedDefault {}\n", + 'export * as default from "./common/types.js";\n', + 'declare const value: string;\nexport { value as "default" };\n', + ]; + for (const defaultExportSource of unsupportedDefaultExports) { + await writeSyntheticPackage(packageRoot, validSyntheticExports(), { + "dist/common/types.d.ts": "export type FilePath = string;\n", + "dist/index.d.ts": defaultExportSource, + }); + const defaultExport = await runGenerator(repositoryRoot, commonArguments); + assert(!defaultExport.success, "a default export unsupported by the wrapper was accepted"); + assert( + defaultExport.stderr.includes("unsupported default export"), + "default-export failure was not explained" + ); + assert( + (await directoryDigest(outputRoot)) === initialDigest, + "default-export failure replaced prior output" + ); + } + await writeSyntheticPackage( packageRoot, validSyntheticExports(), diff --git a/test/type-resolution-compat/legacy-resolution.test.ts b/test/type-resolution-compat/legacy-resolution.test.ts index 207e3fac..cb863466 100644 --- a/test/type-resolution-compat/legacy-resolution.test.ts +++ b/test/type-resolution-compat/legacy-resolution.test.ts @@ -3,6 +3,12 @@ const COMMONLIB_PATHS = { "@vrtmrz/livesync-commonlib/*": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"], }; +const COMPATIBILITY_PATHS = { + ...COMMONLIB_PATHS, + "octagonal-wheels": ["./dist/type-resolution-compat/octagonal-wheels/index"], + "octagonal-wheels/*": ["./dist/type-resolution-compat/octagonal-wheels/*"], +}; + function assert(condition: unknown, message: string): asserts condition { if (!condition) { throw new Error(message); @@ -25,7 +31,7 @@ async function writeProject( fixturePath: string, options: { moduleResolution: "Bundler" | "Node10"; - paths?: typeof COMMONLIB_PATHS; + paths?: Record; skipLibCheck: boolean; } ): Promise { @@ -34,6 +40,7 @@ async function writeProject( JSON.stringify( { compilerOptions: { + allowImportingTsExtensions: true, baseUrl: repositoryRoot, lib: ["ES2022", "DOM"], module: "ESNext", @@ -110,10 +117,14 @@ Deno.test("compatibility mirror preserves representative bundler type facts", as 'import type { FilePath, ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";', 'import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";', 'import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";', + 'import type { TaggedType } from "octagonal-wheels/common/types";', + 'import type { ReactiveValue } from "octagonal-wheels/dataobject/reactive";', + 'import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";', "", "type IsAny = 0 extends 1 & T ? true : false;", "type ExpectFalse = T;", "type ExpectTrue = T;", + 'type TaggedPath = TaggedType;', "type FilePathIsTyped = ExpectFalse>;", "type FilePathIsString = ExpectTrue;", "type SettingsAreTyped = ExpectFalse>;", @@ -122,6 +133,13 @@ Deno.test("compatibility mirror preserves representative bundler type facts", as 'type ServiceHubRetainsSetting = ExpectTrue<"setting" extends keyof InjectableServiceHub ? true : false>;', "type P2PResultIsTyped = ExpectFalse>;", 'type P2PResultRetainsReplicator = ExpectTrue<"replicator" extends keyof UseP2PReplicatorResult ? true : false>;', + "type TaggedPathIsTyped = ExpectFalse>;", + "type TaggedPathIsString = ExpectTrue;", + "type StringIsNotTaggedPath = ExpectFalse;", + "type SimpleStoreIsTyped = ExpectFalse>>;", + 'type SimpleStoreRetainsGet = ExpectTrue<"get" extends keyof SimpleStore ? true : false>;', + "type ReactiveValueIsTyped = ExpectFalse>>;", + 'type ReactiveValueRetainsValue = ExpectTrue<"value" extends keyof ReactiveValue ? true : false>;', "", "export type Proof = [", " FilePathIsTyped,", @@ -132,13 +150,20 @@ Deno.test("compatibility mirror preserves representative bundler type facts", as " ServiceHubRetainsSetting,", " P2PResultIsTyped,", " P2PResultRetainsReplicator,", + " TaggedPathIsTyped,", + " TaggedPathIsString,", + " StringIsNotTaggedPath,", + " SimpleStoreIsTyped,", + " SimpleStoreRetainsGet,", + " ReactiveValueIsTyped,", + " ReactiveValueRetainsValue,", "];", "", ].join("\n") ); const projects = [ { name: "published package", paths: undefined }, - { name: "compatibility mirror", paths: COMMONLIB_PATHS }, + { name: "compatibility mirror", paths: COMPATIBILITY_PATHS }, ] as const; for (const [index, project] of projects.entries()) { const projectPath = `${temporaryDirectory}/tsconfig-${index}.json`; @@ -155,7 +180,7 @@ Deno.test("compatibility mirror preserves representative bundler type facts", as } }); -Deno.test("legacy Commonlib mirror exposes its unresolved Octagonal Wheels boundary", async () => { +Deno.test("legacy compatibility mirrors preserve Commonlib and Octagonal Wheels type facts", async () => { const repositoryRoot = await Deno.realPath(new URL("../../", import.meta.url)); const temporaryDirectory = await makeTemporaryDirectory(repositoryRoot); const fixturePath = `${temporaryDirectory}/consumer.ts`; @@ -165,25 +190,86 @@ Deno.test("legacy Commonlib mirror exposes its unresolved Octagonal Wheels bound fixturePath, [ 'import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";', + 'import type { TaggedType } from "octagonal-wheels/common/types";', + 'import type { ReactiveValue } from "octagonal-wheels/dataobject/reactive";', + 'import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";', "", + "type IsAny = 0 extends 1 & T ? true : false;", + "type ExpectFalse = T;", "type ExpectTrue = T;", - "export type FilePathIsString = ExpectTrue;", + 'type TaggedPath = TaggedType;', + "type FilePathIsTyped = ExpectFalse>;", + "type FilePathIsString = ExpectTrue;", + "type TaggedPathIsTyped = ExpectFalse>;", + "type TaggedPathIsString = ExpectTrue;", + "type StringIsNotTaggedPath = ExpectFalse;", + "type SimpleStoreIsTyped = ExpectFalse>>;", + 'type SimpleStoreRetainsGet = ExpectTrue<"get" extends keyof SimpleStore ? true : false>;', + "type ReactiveValueIsTyped = ExpectFalse>>;", + 'type ReactiveValueRetainsValue = ExpectTrue<"value" extends keyof ReactiveValue ? true : false>;', + "", + "export type Proof = [", + " FilePathIsTyped,", + " FilePathIsString,", + " TaggedPathIsTyped,", + " TaggedPathIsString,", + " StringIsNotTaggedPath,", + " SimpleStoreIsTyped,", + " SimpleStoreRetainsGet,", + " ReactiveValueIsTyped,", + " ReactiveValueRetainsValue,", + "];", "", ].join("\n") ); await writeProject(projectPath, repositoryRoot, fixturePath, { moduleResolution: "Node10", - paths: COMMONLIB_PATHS, - skipLibCheck: false, + paths: COMPATIBILITY_PATHS, + skipLibCheck: true, }); const result = await runTypeScript(projectPath, repositoryRoot); - const output = commandOutput(result); - assert(!result.success, "legacy resolution unexpectedly preserved every transitive package type"); - assert( - output.includes("Cannot find module 'octagonal-wheels/common/types'"), - `the expected transitive Octagonal Wheels boundary was not reported:\n${output}` + assert(result.success, `legacy compatibility resolution lost package type facts:\n${commandOutput(result)}`); + } finally { + await Deno.remove(temporaryDirectory, { recursive: true }); + } +}); + +Deno.test("legacy compatibility mirror exposes every Octagonal Wheels typed export entry point", async () => { + const repositoryRoot = await Deno.realPath(new URL("../../", import.meta.url)); + const temporaryDirectory = await makeTemporaryDirectory(repositoryRoot); + const fixturePath = `${temporaryDirectory}/consumer.ts`; + const projectPath = `${temporaryDirectory}/tsconfig.json`; + try { + const packageJson = JSON.parse( + await Deno.readTextFile(`${repositoryRoot}/node_modules/octagonal-wheels/package.json`) + ) as { exports: Record }; + const typedExportSubpaths = Object.entries(packageJson.exports) + .filter( + ([, definition]) => + typeof definition === "object" && + definition !== null && + "types" in definition && + typeof definition.types === "string" + ) + .map(([subpath]) => subpath); + const imports = typedExportSubpaths.map((subpath, index) => { + const specifier = subpath === "." ? "octagonal-wheels" : `octagonal-wheels/${subpath.slice(2)}`; + return `import type * as PackageExport${index} from ${JSON.stringify(specifier)};`; + }); + const proofTypes = typedExportSubpaths.map((_, index) => `typeof PackageExport${index}`); + await Deno.writeTextFile( + fixturePath, + [...imports, "", `export type Proof = [${proofTypes.join(", ")}];`, ""].join("\n") ); + await writeProject(projectPath, repositoryRoot, fixturePath, { + moduleResolution: "Node10", + paths: COMPATIBILITY_PATHS, + skipLibCheck: true, + }); + + const result = await runTypeScript(projectPath, repositoryRoot); + assert(result.success, `legacy resolution failed for an Octagonal Wheels export:\n${commandOutput(result)}`); } finally { await Deno.remove(temporaryDirectory, { recursive: true }); } diff --git a/test/type-resolution-compat/tsconfig.compat.json b/test/type-resolution-compat/tsconfig.compat.json index 46852c42..42fa887d 100644 --- a/test/type-resolution-compat/tsconfig.compat.json +++ b/test/type-resolution-compat/tsconfig.compat.json @@ -4,7 +4,9 @@ "paths": { "@/*": ["./src/*"], "@vrtmrz/livesync-commonlib": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], - "@vrtmrz/livesync-commonlib/*": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] + "@vrtmrz/livesync-commonlib/*": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"], + "octagonal-wheels": ["./dist/type-resolution-compat/octagonal-wheels/index"], + "octagonal-wheels/*": ["./dist/type-resolution-compat/octagonal-wheels/*"] } } } diff --git a/test/type-resolution-compat/warning-delta.test.ts b/test/type-resolution-compat/warning-delta.test.ts index 335fb694..24465bec 100644 --- a/test/type-resolution-compat/warning-delta.test.ts +++ b/test/type-resolution-compat/warning-delta.test.ts @@ -29,7 +29,7 @@ async function scan(repositoryRoot: string, config: string): Promise { +Deno.test("compatibility mirrors resolve Commonlib and Octagonal Wheels warning types", async () => { const repositoryRoot = await Deno.realPath(new URL("../../", import.meta.url)); const legacy = await scan(repositoryRoot, "test/type-resolution-compat/eslint.legacy.config.mjs"); const compatible = await scan(repositoryRoot, "test/type-resolution-compat/eslint.compat.config.mjs"); @@ -37,20 +37,22 @@ Deno.test("Commonlib mirror reduces synthetic legacy-resolution warnings", async "FilePath", "InjectableServiceHub", "ObsidianLiveSyncSettings", + "ReactiveValue", + "SimpleStore", "UseP2PReplicatorResult", ]; assert(legacy.length > 0, "the legacy configuration did not reproduce the warning family"); for (const typeName of representativeTypes) { - assert( - legacy.some((warning) => warning.message.includes(`'${typeName}' is an 'error' type`)), - `the legacy configuration did not reproduce the ${typeName} warning` - ); - assert( - !compatible.some((warning) => warning.message.includes(`'${typeName}' is an 'error' type`)), - `the compatibility mirror did not resolve ${typeName}` - ); + const isErrorTypeWarning = (warning: EslintMessage) => + warning.message.includes(typeName) && warning.message.includes("is an 'error' type"); + assert(legacy.some(isErrorTypeWarning), `the legacy configuration did not reproduce the ${typeName} warning`); + assert(!compatible.some(isErrorTypeWarning), `the compatibility mirror did not resolve ${typeName}`); } + assert( + !compatible.some((warning) => warning.message.includes("is an 'error' type")), + "the compatibility mirrors left an unresolved error-type warning" + ); assert( compatible.length < legacy.length, `the compatibility mirror did not reduce warnings: ${legacy.length} before, ${compatible.length} after` diff --git a/tsconfig.json b/tsconfig.json index 5294cf39..06bbc516 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,9 @@ "paths": { "@/*": ["./src/*"], "@vrtmrz/livesync-commonlib": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], - "@vrtmrz/livesync-commonlib/*": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] + "@vrtmrz/livesync-commonlib/*": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"], + "octagonal-wheels": ["./dist/type-resolution-compat/octagonal-wheels/index"], + "octagonal-wheels/*": ["./dist/type-resolution-compat/octagonal-wheels/*"] } }, "include": ["**/*.ts", "test/**/*.test.ts", "**/*.unit.spec.ts", "**/*.svelte"],