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