diff --git a/.gitignore b/.gitignore index f883e1a3..afc3bfe3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ data.json cov_profile/** coverage +/dist/type-resolution-compat/ src/apps/cli/dist/* src/apps/webapp/playwright-report/ src/apps/webapp/test-results/ diff --git a/package.json b/package.json index c18b4835..da6db863 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "buildVite": "npx dotenv-cli -e .env -- vite build --mode production", "buildViteOriginal": "npx dotenv-cli -e .env -- vite build --mode original", "buildDev": "node esbuild.config.mjs dev", + "generate:type-resolution-compat": "node scripts/generate-type-resolution-compat.mjs", "lint": "eslint --cache --cache-strategy content --concurrency off src", "lint:community": "eslint --config eslint.community.config.mjs --concurrency off src", "lint:community:tools": "eslint --config eslint.community.config.mjs --concurrency off --max-warnings 0 _tools", @@ -21,6 +22,7 @@ "pretty": "npm run prettyNoWrite -- --write --log-level error", "prettyCheck": "npm run prettyNoWrite -- --check", "prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ", + "postinstall": "npm run generate:type-resolution-compat", "precheck:compatibility": "npm run build", "check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15", "check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run lint:community:tools && npm run svelte-check && npm run check:compatibility", diff --git a/scripts/generate-type-resolution-compat.mjs b/scripts/generate-type-resolution-compat.mjs new file mode 100644 index 00000000..a61b4db7 --- /dev/null +++ b/scripts/generate-type-resolution-compat.mjs @@ -0,0 +1,358 @@ +import { createRequire } from "node:module"; +import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, 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 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 SUPPRESSION_PATTERN = /@ts-(?:expect-error|ignore|nocheck)|eslint-(?:disable|enable)/u; + +function fail(message) { + throw new Error(message); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function parseArguments(argv) { + const options = { + expectedVersion: undefined, + outputRoot: DEFAULT_OUTPUT_ROOT, + packageRoot: undefined, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + const value = argv[index + 1]; + if (argument === "--expected-version") { + if (value === undefined) fail("--expected-version requires a value"); + options.expectedVersion = value; + index += 1; + } else if (argument === "--output") { + if (value === undefined) fail("--output requires a value"); + options.outputRoot = path.resolve(value); + index += 1; + } else if (argument === "--package-root") { + if (value === undefined) fail("--package-root requires a value"); + options.packageRoot = path.resolve(value); + index += 1; + } else { + fail(`Unknown argument: ${argument}`); + } + } + return options; +} + +async function readJson(filePath, description) { + let source; + try { + source = await readFile(filePath, "utf8"); + } catch (error) { + fail(`Cannot read ${description} at ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + } + try { + return JSON.parse(source); + } catch (error) { + fail(`Cannot parse ${description} at ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function isWithin(parentPath, candidatePath) { + const relative = path.relative(parentPath, candidatePath); + return ( + relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) + ); +} + +function validatePackageRelativePath(value, description) { + if (typeof value !== "string" || !value.startsWith("./") || value.includes("\\")) { + fail(`${description} must be a package-relative path beginning with './': ${String(value)}`); + } + const relativePath = value.slice(2); + if (relativePath.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) { + fail(`${description} contains path traversal or an empty path segment: ${value}`); + } + const normalised = path.posix.normalize(relativePath); + if ( + relativePath.length === 0 || + normalised === "." || + normalised === ".." || + normalised.startsWith("../") || + path.posix.isAbsolute(normalised) + ) { + fail(`${description} contains path traversal or an empty path: ${value}`); + } + return normalised; +} + +function resolveInsidePackage(packageRoot, packageRelativePath, description) { + const resolved = path.resolve(packageRoot, ...packageRelativePath.split("/")); + if (!isWithin(packageRoot, resolved)) { + fail(`${description} escapes the package root: ${packageRelativePath}`); + } + return resolved; +} + +async function pathExists(filePath) { + try { + await lstat(filePath); + return true; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return false; + } + throw error; + } +} + +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}`); + } + 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}`); + } + } +} + +async function findInstalledPackageRoot() { + const require = createRequire(import.meta.url); + const packageJsonPath = require.resolve(`${PACKAGE_NAME}/package.json`, { paths: [PROJECT_ROOT] }); + return path.dirname(packageJsonPath); +} + +async function determineExpectedVersion(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`); + } + return dependencyVersion; +} + +function declarationRuntimeSpecifier(declarationPath) { + if (declarationPath.endsWith(".d.ts")) return `${declarationPath.slice(0, -5)}.js`; + if (declarationPath.endsWith(".d.mts")) return `${declarationPath.slice(0, -6)}.mjs`; + if (declarationPath.endsWith(".d.cts")) return `${declarationPath.slice(0, -6)}.cjs`; + fail(`Unsupported declaration extension: ${declarationPath}`); +} + +function publicWrapperPath(exportSubpath) { + if (exportSubpath === ".") return "index.d.ts"; + const relativeSubpath = validatePackageRelativePath(exportSubpath, `export subpath '${exportSubpath}'`); + if (relativeSubpath === "package.json") return undefined; + if (relativeSubpath === "__package__" || relativeSubpath.startsWith("__package__/")) { + fail(`Export subpath uses the reserved internal directory: ${exportSubpath}`); + } + return `${relativeSubpath}.d.ts`; +} + +async function collectDeclarationFiles(packageRoot) { + const files = []; + async function walk(relativeDirectory) { + const absoluteDirectory = resolveInsidePackage(packageRoot, relativeDirectory || ".", "declaration directory"); + const entries = await readdir(absoluteDirectory, { withFileTypes: true }); + entries.sort((left, right) => compareText(left.name, right.name)); + for (const entry of entries) { + const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) { + fail(`Declaration tree contains a symbolic link: ${relativePath}`); + } + if (entry.isDirectory()) { + if (relativePath !== "node_modules") await walk(relativePath); + } else if ( + entry.isFile() && + (DECLARATION_PATTERN.test(relativePath) || DECLARATION_MAP_PATTERN.test(relativePath)) + ) { + files.push(relativePath); + } + } + } + await walk(""); + return files; +} + +async function validateAndCollectExports(packageRoot, packageJson) { + if (packageJson.name !== PACKAGE_NAME) { + fail(`Expected package name ${PACKAGE_NAME}, received ${String(packageJson.name)}`); + } + if (packageJson.exports === null || typeof packageJson.exports !== "object" || Array.isArray(packageJson.exports)) { + fail("Package exports must be an object"); + } + + const destinations = new Map(); + const typedExports = []; + let metadataExportCount = 0; + const entries = Object.entries(packageJson.exports).sort(([left], [right]) => compareText(left, right)); + for (const [exportSubpath, exportDefinition] of entries) { + const wrapperPath = publicWrapperPath(exportSubpath); + if (wrapperPath === undefined) { + if (exportSubpath !== "./package.json" || exportDefinition !== "./package.json") { + fail(`Unsupported untyped package export: ${exportSubpath}`); + } + const metadataPath = resolveInsidePackage(packageRoot, "package.json", "package metadata export"); + const metadataStat = await stat(metadataPath); + if (!metadataStat.isFile()) fail("Package metadata export does not point to a file"); + metadataExportCount += 1; + continue; + } + if ( + exportDefinition === null || + typeof exportDefinition !== "object" || + Array.isArray(exportDefinition) || + typeof exportDefinition.types !== "string" + ) { + fail(`Export '${exportSubpath}' does not define one string types target`); + } + const typesTarget = validatePackageRelativePath( + exportDefinition.types, + `types target for export '${exportSubpath}'` + ); + if (!DECLARATION_PATTERN.test(typesTarget)) { + fail(`Types target for export '${exportSubpath}' is not a declaration file: ${exportDefinition.types}`); + } + const targetPath = resolveInsidePackage(packageRoot, typesTarget, `types target for export '${exportSubpath}'`); + let targetStat; + try { + targetStat = await stat(targetPath); + } catch (error) { + fail( + `Types target for export '${exportSubpath}' does not exist: ${typesTarget} (${ + error instanceof Error ? error.message : String(error) + })` + ); + } + if (!targetStat.isFile()) fail(`Types target for export '${exportSubpath}' is not a file: ${typesTarget}`); + const destinationKey = wrapperPath.toLowerCase(); + const existingDestination = destinations.get(destinationKey); + if (existingDestination !== undefined) { + fail( + `Exports '${existingDestination.exportSubpath}' and '${exportSubpath}' map to the same wrapper: ` + + `${existingDestination.wrapperPath} / ${wrapperPath}` + ); + } + destinations.set(destinationKey, { exportSubpath, wrapperPath }); + typedExports.push({ exportSubpath, typesTarget, wrapperPath }); + } + if (metadataExportCount > 1) fail("Package contains duplicate metadata exports"); + return { exportCount: entries.length, metadataExportCount, typedExports }; +} + +async function copyDeclarationTree(packageRoot, internalRoot, declarationFiles) { + await mkdir(internalRoot, { recursive: true }); + await copyFile(path.join(packageRoot, "package.json"), path.join(internalRoot, "package.json")); + for (const relativePath of declarationFiles) { + const sourcePath = resolveInsidePackage(packageRoot, relativePath, "declaration source"); + if (DECLARATION_PATTERN.test(relativePath)) { + const source = await readFile(sourcePath, "utf8"); + if (SUPPRESSION_PATTERN.test(source)) { + fail(`Declaration contains a suppression directive: ${relativePath}`); + } + } + const destinationPath = resolveInsidePackage(internalRoot, relativePath, "declaration destination"); + await mkdir(path.dirname(destinationPath), { recursive: true }); + await copyFile(sourcePath, destinationPath); + } +} + +async function writeWrappers(publicPackageRoot, typedExports) { + for (const { typesTarget, wrapperPath } of typedExports) { + const absoluteWrapperPath = resolveInsidePackage(publicPackageRoot, wrapperPath, "wrapper destination"); + const internalDeclaration = path.posix.join("__package__", typesTarget); + let relativeTarget = path.posix.relative(path.posix.dirname(wrapperPath), internalDeclaration); + relativeTarget = declarationRuntimeSpecifier(relativeTarget); + if (!relativeTarget.startsWith(".")) relativeTarget = `./${relativeTarget}`; + await mkdir(path.dirname(absoluteWrapperPath), { recursive: true }); + await writeFile(absoluteWrapperPath, `export * from ${JSON.stringify(relativeTarget)};\n`, "utf8"); + } +} + +async function nextBackupPath(outputRoot) { + for (let suffix = 0; suffix < 100; suffix += 1) { + const candidate = `${outputRoot}.backup-${process.pid}-${suffix}`; + if (!(await pathExists(candidate))) return candidate; + } + fail(`Cannot reserve a backup path for ${outputRoot}`); +} + +async function replaceOutputDirectory(temporaryRoot, outputRoot) { + let backupRoot; + if (await pathExists(outputRoot)) { + backupRoot = await nextBackupPath(outputRoot); + await rename(outputRoot, backupRoot); + } + try { + await rename(temporaryRoot, outputRoot); + } catch (error) { + if (backupRoot !== undefined) await rename(backupRoot, outputRoot); + throw error; + } + 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); + if (packageJson.version !== expectedVersion) { + fail(`Expected ${PACKAGE_NAME}@${expectedVersion}, received ${String(packageJson.version)}`); + } + await validateOutputRoot(options.outputRoot, packageRoot); + + const { exportCount, metadataExportCount, typedExports } = await validateAndCollectExports( + packageRoot, + packageJson + ); + const declarationFiles = await collectDeclarationFiles(packageRoot); + const declarationSet = new Set(declarationFiles); + for (const { exportSubpath, typesTarget } of typedExports) { + if (!declarationSet.has(typesTarget)) { + fail(`Types target for export '${exportSubpath}' is outside the copied declaration tree: ${typesTarget}`); + } + } + + 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); + await replaceOutputDirectory(temporaryRoot, options.outputRoot); + } catch (error) { + if (await pathExists(temporaryRoot)) await rm(temporaryRoot, { recursive: true }); + throw error; + } + + return { + declarationCount: declarationFiles.filter((filePath) => DECLARATION_PATTERN.test(filePath)).length, + exportCount, + metadataExportCount, + outputDirectory: options.outputRoot, + packageName: PACKAGE_NAME, + packageVersion: packageJson.version, + typedExportCount: typedExports.length, + }; +} + +try { + const result = await generate(parseArguments(process.argv.slice(2))); + process.stdout.write(`${JSON.stringify(result)}\n`); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/src/apps/browser/tsconfig.json b/src/apps/browser/tsconfig.json index 896bf6eb..4330c507 100644 --- a/src/apps/browser/tsconfig.json +++ b/src/apps/browser/tsconfig.json @@ -8,7 +8,9 @@ "isolatedModules": true, "noEmit": true, "paths": { - "@/*": ["../../*"] + "@/*": ["../../*"], + "@vrtmrz/livesync-commonlib": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], + "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] } }, "include": ["**/*.ts", "**/*.svelte"], diff --git a/src/apps/cli/tsconfig.json b/src/apps/cli/tsconfig.json index 0d9fbb57..747fc31d 100644 --- a/src/apps/cli/tsconfig.json +++ b/src/apps/cli/tsconfig.json @@ -23,7 +23,9 @@ // "rootDir": "../../../", /* Path mapping */ "paths": { - "@/*": ["../../*"] + "@/*": ["../../*"], + "@vrtmrz/livesync-commonlib": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], + "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] } }, "include": ["*.ts", "**/*.ts", "**/*.tsx"], diff --git a/src/apps/webapp/tsconfig.json b/src/apps/webapp/tsconfig.json index ab83008a..bcbe39eb 100644 --- a/src/apps/webapp/tsconfig.json +++ b/src/apps/webapp/tsconfig.json @@ -23,7 +23,9 @@ /* Path mapping */ // "baseUrl": ".", "paths": { - "@/*": ["../../*"] + "@/*": ["../../*"], + "@vrtmrz/livesync-commonlib": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], + "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] } }, "include": ["*.ts", "**/*.ts", "**/*.tsx", "**/*.svelte"], diff --git a/src/apps/webpeer/tsconfig.app.json b/src/apps/webpeer/tsconfig.app.json index ce8dd172..f74c56dd 100644 --- a/src/apps/webpeer/tsconfig.app.json +++ b/src/apps/webpeer/tsconfig.app.json @@ -18,7 +18,9 @@ "allowImportingTsExtensions": true, "moduleDetection": "force", "paths": { - "@/*": ["../../*"] + "@/*": ["../../*"], + "@vrtmrz/livesync-commonlib": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], + "@vrtmrz/livesync-commonlib/*": ["../../../dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] } }, "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], diff --git a/test/type-resolution-compat/eslint.compat.config.mjs b/test/type-resolution-compat/eslint.compat.config.mjs new file mode 100644 index 00000000..82c3c4ce --- /dev/null +++ b/test/type-resolution-compat/eslint.compat.config.mjs @@ -0,0 +1,3 @@ +import { createSyntheticCommunityReviewConfig } from "./eslint.synthetic-config.mjs"; + +export default createSyntheticCommunityReviewConfig("./test/type-resolution-compat/tsconfig.compat.json"); diff --git a/test/type-resolution-compat/eslint.legacy.config.mjs b/test/type-resolution-compat/eslint.legacy.config.mjs new file mode 100644 index 00000000..88a95228 --- /dev/null +++ b/test/type-resolution-compat/eslint.legacy.config.mjs @@ -0,0 +1,3 @@ +import { createSyntheticCommunityReviewConfig } from "./eslint.synthetic-config.mjs"; + +export default createSyntheticCommunityReviewConfig("./test/type-resolution-compat/tsconfig.legacy.json"); diff --git a/test/type-resolution-compat/eslint.synthetic-config.mjs b/test/type-resolution-compat/eslint.synthetic-config.mjs new file mode 100644 index 00000000..cb5650c4 --- /dev/null +++ b/test/type-resolution-compat/eslint.synthetic-config.mjs @@ -0,0 +1,33 @@ +import typescriptEslint from "typescript-eslint"; + +const repositoryRoot = new URL("../../", import.meta.url).pathname; + +export function createSyntheticCommunityReviewConfig(project) { + return [ + { + ignores: [ + "**/*.unit.spec.ts", + "**/*.test.ts", + "**/test/**", + "src/apps/_test/**", + "src/apps/cli/testdeno/**", + ], + }, + { + files: ["src/**/*.ts"], + languageOptions: { + parser: typescriptEslint.parser, + parserOptions: { + project, + tsconfigRootDir: repositoryRoot, + }, + }, + plugins: { + "@typescript-eslint": typescriptEslint.plugin, + }, + rules: { + "@typescript-eslint/no-redundant-type-constituents": "warn", + }, + }, + ]; +} diff --git a/test/type-resolution-compat/generator.test.ts b/test/type-resolution-compat/generator.test.ts new file mode 100644 index 00000000..19f21af6 --- /dev/null +++ b/test/type-resolution-compat/generator.test.ts @@ -0,0 +1,294 @@ +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function decode(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +async function runGenerator( + repositoryRoot: string, + arguments_: string[] +): Promise<{ success: boolean; stdout: string; stderr: string }> { + const result = await new Deno.Command("node", { + args: ["scripts/generate-type-resolution-compat.mjs", ...arguments_], + cwd: repositoryRoot, + stdout: "piped", + stderr: "piped", + }).output(); + return { + success: result.success, + stdout: decode(result.stdout), + stderr: decode(result.stderr), + }; +} + +async function listFiles(root: string, relativeDirectory = ""): Promise { + const directory = relativeDirectory === "" ? root : `${root}/${relativeDirectory}`; + const entries = [...(await Array.fromAsync(Deno.readDir(directory)))].sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0 + ); + const files: string[] = []; + for (const entry of entries) { + const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`; + if (entry.isDirectory) { + files.push(...(await listFiles(root, relativePath))); + } else if (entry.isFile) { + files.push(relativePath); + } else { + throw new Error(`unexpected generated entry: ${relativePath}`); + } + } + return files; +} + +async function directoryDigest(root: string): Promise { + const encoder = new TextEncoder(); + const files = await listFiles(root); + const chunks: Uint8Array[] = []; + let totalLength = 0; + for (const relativePath of files) { + const name = encoder.encode(`${relativePath}\0`); + const contents = await Deno.readFile(`${root}/${relativePath}`); + chunks.push(name, contents); + totalLength += name.length + contents.length; + } + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", combined)); + return [...digest].map((value) => value.toString(16).padStart(2, "0")).join(""); +} + +async function writeSyntheticPackage( + packageRoot: string, + exports: Record, + declarations: Record, + overrides: Record = {} +): Promise { + await Deno.mkdir(packageRoot, { recursive: true }); + await Deno.writeTextFile( + `${packageRoot}/package.json`, + JSON.stringify( + { + name: "@vrtmrz/livesync-commonlib", + version: "0.1.0", + type: "module", + exports, + ...overrides, + }, + null, + 2 + ) + ); + for (const [relativePath, source] of Object.entries(declarations)) { + const components = relativePath.split("/"); + components.pop(); + if (components.length > 0) { + await Deno.mkdir(`${packageRoot}/${components.join("/")}`, { recursive: true }); + } + await Deno.writeTextFile(`${packageRoot}/${relativePath}`, source); + } +} + +function validSyntheticExports(): Record { + return { + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + default: "./dist/index.js", + }, + "./compat/common/types": { + types: "./dist/common/types.d.ts", + import: "./dist/common/types.js", + default: "./dist/common/types.js", + }, + "./package.json": "./package.json", + }; +} + +Deno.test("generator covers the installed Commonlib export map 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 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; + }; + 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"); + + const firstDigest = await directoryDigest(outputRoot); + const second = await runGenerator(repositoryRoot, ["--output", outputRoot]); + assert(second.success, `second generation failed:\n${second.stderr}`); + assert((await directoryDigest(outputRoot)) === firstDigest, "two generations produced different content"); + + const generatedFiles = await listFiles(outputRoot); + const publicWrappers = generatedFiles.filter( + (filePath) => filePath.endsWith(".d.ts") && !filePath.includes("/__package__/") + ); + assert(publicWrappers.length === expectedTypedExports, "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( + !/@ts-(?:expect-error|ignore|nocheck)|eslint-(?:disable|enable)/u.test(source), + `generated declaration contains a suppression directive: ${filePath}` + ); + } + assert( + (await Deno.readTextFile(`${outputRoot}/@vrtmrz/livesync-commonlib/compat/common/types.d.ts`)) === + 'export * from "../../__package__/dist/common/types.js";\n', + "representative wrapper does not re-export the copied declaration" + ); + + const ignored = await new Deno.Command("git", { + args: ["check-ignore", "--quiet", "dist/type-resolution-compat"], + cwd: repositoryRoot, + }).output(); + assert(ignored.success, "the generated repository output is not ignored by Git"); + } finally { + await Deno.remove(temporaryDirectory, { recursive: true }); + } +}); + +Deno.test("generator rejects invalid package boundaries and preserves the previous output", async () => { + const repositoryRoot = await Deno.realPath(new URL("../../", import.meta.url)); + const temporaryDirectory = await Deno.makeTempDir({ prefix: "livesync-type-generator-invalid-" }); + const packageRoot = `${temporaryDirectory}/package`; + const outputRoot = `${temporaryDirectory}/output`; + const commonArguments = ["--package-root", packageRoot, "--output", outputRoot, "--expected-version", "0.1.0"]; + try { + await writeSyntheticPackage(packageRoot, validSyntheticExports(), { + "dist/common/types.d.ts": "export type FilePath = string;\n", + "dist/index.d.ts": 'export type { FilePath } from "./common/types.js";\n', + }); + const initial = await runGenerator(repositoryRoot, commonArguments); + assert(initial.success, `valid synthetic generation failed:\n${initial.stderr}`); + const initialDigest = await directoryDigest(outputRoot); + + await writeSyntheticPackage( + packageRoot, + { + ".": { + types: "./dist/missing.d.ts", + import: "./dist/index.js", + default: "./dist/index.js", + }, + "./package.json": "./package.json", + }, + {} + ); + const missingTarget = await runGenerator(repositoryRoot, commonArguments); + assert(!missingTarget.success, "a missing types target was accepted"); + assert(missingTarget.stderr.includes("does not exist"), "missing-target failure was not explained"); + assert((await directoryDigest(outputRoot)) === initialDigest, "failed generation replaced the previous output"); + + await writeSyntheticPackage( + packageRoot, + { + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + default: "./dist/index.js", + }, + "./compat/../escape": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + default: "./dist/index.js", + }, + "./package.json": "./package.json", + }, + { "dist/index.d.ts": "export interface Safe {}\n" } + ); + const traversal = await runGenerator(repositoryRoot, commonArguments); + assert(!traversal.success, "an export path traversal was accepted"); + assert(traversal.stderr.includes("path traversal"), "path-traversal failure was not explained"); + + await writeSyntheticPackage( + packageRoot, + { + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + default: "./dist/index.js", + }, + "./index": { + types: "./dist/other.d.ts", + import: "./dist/other.js", + default: "./dist/other.js", + }, + "./package.json": "./package.json", + }, + { + "dist/index.d.ts": "export interface First {}\n", + "dist/other.d.ts": "export interface Second {}\n", + } + ); + const duplicate = await runGenerator(repositoryRoot, commonArguments); + assert(!duplicate.success, "duplicate wrapper destinations were accepted"); + assert(duplicate.stderr.includes("same wrapper"), "duplicate-destination failure was not explained"); + + await writeSyntheticPackage(packageRoot, validSyntheticExports(), { + "dist/common/types.d.ts": "// @ts-ignore\nexport type FilePath = string;\n", + "dist/index.d.ts": 'export type { FilePath } from "./common/types.js";\n', + }); + const suppression = await runGenerator(repositoryRoot, commonArguments); + assert(!suppression.success, "a declaration suppression directive was copied"); + assert(suppression.stderr.includes("suppression directive"), "suppression failure was not explained"); + + await writeSyntheticPackage( + packageRoot, + validSyntheticExports(), + { + "dist/common/types.d.ts": "export type FilePath = string;\n", + "dist/index.d.ts": 'export type { FilePath } from "./common/types.js";\n', + }, + { name: "@vrtmrz/not-livesync-commonlib" } + ); + const wrongName = await runGenerator(repositoryRoot, commonArguments); + assert(!wrongName.success, "an unexpected package name was accepted"); + assert(wrongName.stderr.includes("Expected package name"), "package-name failure was not explained"); + + await writeSyntheticPackage( + packageRoot, + validSyntheticExports(), + { + "dist/common/types.d.ts": "export type FilePath = string;\n", + "dist/index.d.ts": 'export type { FilePath } from "./common/types.js";\n', + }, + { version: "0.1.1" } + ); + const wrongVersion = await runGenerator(repositoryRoot, commonArguments); + assert(!wrongVersion.success, "an unexpected package version was accepted"); + assert( + wrongVersion.stderr.includes("Expected @vrtmrz/livesync-commonlib@0.1.0"), + "version failure was not explained" + ); + } finally { + await Deno.remove(temporaryDirectory, { recursive: true }); + } +}); diff --git a/test/type-resolution-compat/legacy-resolution.test.ts b/test/type-resolution-compat/legacy-resolution.test.ts new file mode 100644 index 00000000..207e3fac --- /dev/null +++ b/test/type-resolution-compat/legacy-resolution.test.ts @@ -0,0 +1,190 @@ +const COMMONLIB_PATHS = { + "@vrtmrz/livesync-commonlib": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], + "@vrtmrz/livesync-commonlib/*": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"], +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +async function runTypeScript(projectPath: string, repositoryRoot: string): Promise { + const executable = `${repositoryRoot}/node_modules/.bin/tsc${Deno.build.os === "windows" ? ".cmd" : ""}`; + return await new Deno.Command(executable, { + args: ["--pretty", "false", "--project", projectPath], + cwd: repositoryRoot, + stdout: "piped", + stderr: "piped", + }).output(); +} + +async function writeProject( + projectPath: string, + repositoryRoot: string, + fixturePath: string, + options: { + moduleResolution: "Bundler" | "Node10"; + paths?: typeof COMMONLIB_PATHS; + skipLibCheck: boolean; + } +): Promise { + await Deno.writeTextFile( + projectPath, + JSON.stringify( + { + compilerOptions: { + baseUrl: repositoryRoot, + lib: ["ES2022", "DOM"], + module: "ESNext", + moduleResolution: options.moduleResolution, + noEmit: true, + ...(options.paths === undefined ? {} : { paths: options.paths }), + skipLibCheck: options.skipLibCheck, + strict: true, + target: "ES2022", + types: [], + }, + files: [fixturePath], + }, + null, + 2 + ) + ); +} + +async function makeTemporaryDirectory(repositoryRoot: string): Promise { + return await Deno.makeTempDir({ + dir: repositoryRoot, + prefix: ".livesync-type-resolution-", + }); +} + +function commandOutput(result: Deno.CommandOutput): string { + return new TextDecoder().decode(result.stdout) + new TextDecoder().decode(result.stderr); +} + +Deno.test("legacy resolver reaches representative Commonlib entry points", 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 { + await Deno.writeTextFile( + fixturePath, + [ + '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";', + "", + "export type Proof = [", + " FilePath,", + " ObsidianLiveSyncSettings,", + " InjectableServiceHub,", + " UseP2PReplicatorResult,", + "];", + "", + ].join("\n") + ); + await writeProject(projectPath, repositoryRoot, fixturePath, { + moduleResolution: "Node10", + paths: COMMONLIB_PATHS, + skipLibCheck: true, + }); + + const result = await runTypeScript(projectPath, repositoryRoot); + assert(result.success, `legacy Commonlib entry-point resolution failed:\n${commandOutput(result)}`); + } finally { + await Deno.remove(temporaryDirectory, { recursive: true }); + } +}); + +Deno.test("compatibility mirror preserves representative bundler type facts", async () => { + const repositoryRoot = await Deno.realPath(new URL("../../", import.meta.url)); + const temporaryDirectory = await makeTemporaryDirectory(repositoryRoot); + const fixturePath = `${temporaryDirectory}/consumer.ts`; + try { + await Deno.writeTextFile( + fixturePath, + [ + '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";', + "", + "type IsAny = 0 extends 1 & T ? true : false;", + "type ExpectFalse = T;", + "type ExpectTrue = T;", + "type FilePathIsTyped = ExpectFalse>;", + "type FilePathIsString = ExpectTrue;", + "type SettingsAreTyped = ExpectFalse>;", + 'type SettingsRetainRemoteType = ExpectTrue<"remoteType" extends keyof ObsidianLiveSyncSettings ? true : false>;', + "type ServiceHubIsTyped = ExpectFalse>;", + 'type ServiceHubRetainsSetting = ExpectTrue<"setting" extends keyof InjectableServiceHub ? true : false>;', + "type P2PResultIsTyped = ExpectFalse>;", + 'type P2PResultRetainsReplicator = ExpectTrue<"replicator" extends keyof UseP2PReplicatorResult ? true : false>;', + "", + "export type Proof = [", + " FilePathIsTyped,", + " FilePathIsString,", + " SettingsAreTyped,", + " SettingsRetainRemoteType,", + " ServiceHubIsTyped,", + " ServiceHubRetainsSetting,", + " P2PResultIsTyped,", + " P2PResultRetainsReplicator,", + "];", + "", + ].join("\n") + ); + const projects = [ + { name: "published package", paths: undefined }, + { name: "compatibility mirror", paths: COMMONLIB_PATHS }, + ] as const; + for (const [index, project] of projects.entries()) { + const projectPath = `${temporaryDirectory}/tsconfig-${index}.json`; + await writeProject(projectPath, repositoryRoot, fixturePath, { + moduleResolution: "Bundler", + paths: project.paths, + skipLibCheck: true, + }); + const result = await runTypeScript(projectPath, repositoryRoot); + assert(result.success, `${project.name} bundler resolution failed:\n${commandOutput(result)}`); + } + } finally { + await Deno.remove(temporaryDirectory, { recursive: true }); + } +}); + +Deno.test("legacy Commonlib mirror exposes its unresolved Octagonal Wheels boundary", 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 { + await Deno.writeTextFile( + fixturePath, + [ + 'import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";', + "", + "type ExpectTrue = T;", + "export type FilePathIsString = ExpectTrue;", + "", + ].join("\n") + ); + await writeProject(projectPath, repositoryRoot, fixturePath, { + moduleResolution: "Node10", + paths: COMMONLIB_PATHS, + skipLibCheck: false, + }); + + 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}` + ); + } 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 new file mode 100644 index 00000000..46852c42 --- /dev/null +++ b/test/type-resolution-compat/tsconfig.compat.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.legacy.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "@vrtmrz/livesync-commonlib": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], + "@vrtmrz/livesync-commonlib/*": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] + } + } +} diff --git a/test/type-resolution-compat/tsconfig.legacy.json b/test/type-resolution-compat/tsconfig.legacy.json new file mode 100644 index 00000000..7a733552 --- /dev/null +++ b/test/type-resolution-compat/tsconfig.legacy.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../..", + "moduleResolution": "Node10", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["../../src/**/*.ts"], + "exclude": [ + "../../src/**/*.unit.spec.ts", + "../../src/**/*.test.ts", + "../../src/**/_test/**", + "../../src/apps/cli/testdeno/**" + ] +} diff --git a/test/type-resolution-compat/warning-delta.test.ts b/test/type-resolution-compat/warning-delta.test.ts new file mode 100644 index 00000000..335fb694 --- /dev/null +++ b/test/type-resolution-compat/warning-delta.test.ts @@ -0,0 +1,58 @@ +type EslintMessage = { + ruleId: string | null; + message: string; +}; + +type EslintReport = { + filePath: string; + messages: EslintMessage[]; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +async function scan(repositoryRoot: string, config: string): Promise { + const result = await new Deno.Command(`${repositoryRoot}/node_modules/.bin/eslint`, { + args: ["--config", config, "--concurrency", "off", "--format", "json", "src"], + cwd: repositoryRoot, + stdout: "piped", + stderr: "piped", + }).output(); + const stderr = new TextDecoder().decode(result.stderr); + assert(result.success, `synthetic Community Review scan failed:\n${stderr}`); + const reports = JSON.parse(new TextDecoder().decode(result.stdout)) as EslintReport[]; + return reports.flatMap((report) => + report.messages.filter((message) => message.ruleId === "@typescript-eslint/no-redundant-type-constituents") + ); +} + +Deno.test("Commonlib mirror reduces synthetic legacy-resolution warnings", 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"); + const representativeTypes = [ + "FilePath", + "InjectableServiceHub", + "ObsidianLiveSyncSettings", + "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}` + ); + } + 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 4e159699..5294cf39 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,9 @@ "strictBindCallApply": true, "strictFunctionTypes": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@vrtmrz/livesync-commonlib": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/index"], + "@vrtmrz/livesync-commonlib/*": ["./dist/type-resolution-compat/@vrtmrz/livesync-commonlib/*"] } }, "include": ["**/*.ts", "test/**/*.test.ts", "**/*.unit.spec.ts", "**/*.svelte"], @@ -31,6 +33,7 @@ "**/_test/**", "test/browser-apps", "test/styles/*.deno.ts", + "test/type-resolution-compat", "utilsdeno" ] }