mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-30 23:37:08 +00:00
build: add legacy type-resolution experiment
This commit is contained in:
@@ -28,6 +28,7 @@ data.json
|
|||||||
cov_profile/**
|
cov_profile/**
|
||||||
|
|
||||||
coverage
|
coverage
|
||||||
|
/dist/type-resolution-compat/
|
||||||
src/apps/cli/dist/*
|
src/apps/cli/dist/*
|
||||||
src/apps/webapp/playwright-report/
|
src/apps/webapp/playwright-report/
|
||||||
src/apps/webapp/test-results/
|
src/apps/webapp/test-results/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"buildVite": "npx dotenv-cli -e .env -- vite build --mode production",
|
"buildVite": "npx dotenv-cli -e .env -- vite build --mode production",
|
||||||
"buildViteOriginal": "npx dotenv-cli -e .env -- vite build --mode original",
|
"buildViteOriginal": "npx dotenv-cli -e .env -- vite build --mode original",
|
||||||
"buildDev": "node esbuild.config.mjs dev",
|
"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": "eslint --cache --cache-strategy content --concurrency off src",
|
||||||
"lint:community": "eslint --config eslint.community.config.mjs --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",
|
"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",
|
"pretty": "npm run prettyNoWrite -- --write --log-level error",
|
||||||
"prettyCheck": "npm run prettyNoWrite -- --check",
|
"prettyCheck": "npm run prettyNoWrite -- --check",
|
||||||
"prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
|
"prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
|
||||||
|
"postinstall": "npm run generate:type-resolution-compat",
|
||||||
"precheck:compatibility": "npm run build",
|
"precheck:compatibility": "npm run build",
|
||||||
"check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15",
|
"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",
|
"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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"paths": {
|
"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"],
|
"include": ["**/*.ts", "**/*.svelte"],
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
// "rootDir": "../../../",
|
// "rootDir": "../../../",
|
||||||
/* Path mapping */
|
/* Path mapping */
|
||||||
"paths": {
|
"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"],
|
"include": ["*.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
/* Path mapping */
|
/* Path mapping */
|
||||||
// "baseUrl": ".",
|
// "baseUrl": ".",
|
||||||
"paths": {
|
"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"],
|
"include": ["*.ts", "**/*.ts", "**/*.tsx", "**/*.svelte"],
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"paths": {
|
"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"],
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createSyntheticCommunityReviewConfig } from "./eslint.synthetic-config.mjs";
|
||||||
|
|
||||||
|
export default createSyntheticCommunityReviewConfig("./test/type-resolution-compat/tsconfig.compat.json");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createSyntheticCommunityReviewConfig } from "./eslint.synthetic-config.mjs";
|
||||||
|
|
||||||
|
export default createSyntheticCommunityReviewConfig("./test/type-resolution-compat/tsconfig.legacy.json");
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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<string[]> {
|
||||||
|
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<string> {
|
||||||
|
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<string, unknown>,
|
||||||
|
declarations: Record<string, string>,
|
||||||
|
overrides: Record<string, unknown> = {}
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> };
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<Deno.CommandOutput> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<T> = 0 extends 1 & T ? true : false;",
|
||||||
|
"type ExpectFalse<T extends false> = T;",
|
||||||
|
"type ExpectTrue<T extends true> = T;",
|
||||||
|
"type FilePathIsTyped = ExpectFalse<IsAny<FilePath>>;",
|
||||||
|
"type FilePathIsString = ExpectTrue<FilePath extends string ? true : false>;",
|
||||||
|
"type SettingsAreTyped = ExpectFalse<IsAny<ObsidianLiveSyncSettings>>;",
|
||||||
|
'type SettingsRetainRemoteType = ExpectTrue<"remoteType" extends keyof ObsidianLiveSyncSettings ? true : false>;',
|
||||||
|
"type ServiceHubIsTyped = ExpectFalse<IsAny<InjectableServiceHub>>;",
|
||||||
|
'type ServiceHubRetainsSetting = ExpectTrue<"setting" extends keyof InjectableServiceHub ? true : false>;',
|
||||||
|
"type P2PResultIsTyped = ExpectFalse<IsAny<UseP2PReplicatorResult>>;",
|
||||||
|
'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 extends true> = T;",
|
||||||
|
"export type FilePathIsString = ExpectTrue<FilePath extends string ? true : false>;",
|
||||||
|
"",
|
||||||
|
].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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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<EslintMessage[]> {
|
||||||
|
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`
|
||||||
|
);
|
||||||
|
});
|
||||||
+4
-1
@@ -19,7 +19,9 @@
|
|||||||
"strictBindCallApply": true,
|
"strictBindCallApply": true,
|
||||||
"strictFunctionTypes": true,
|
"strictFunctionTypes": true,
|
||||||
"paths": {
|
"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"],
|
"include": ["**/*.ts", "test/**/*.test.ts", "**/*.unit.spec.ts", "**/*.svelte"],
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"**/_test/**",
|
"**/_test/**",
|
||||||
"test/browser-apps",
|
"test/browser-apps",
|
||||||
"test/styles/*.deno.ts",
|
"test/styles/*.deno.ts",
|
||||||
|
"test/type-resolution-compat",
|
||||||
"utilsdeno"
|
"utilsdeno"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user