test: speed up import-heavy tests

This commit is contained in:
Peter Steinberger
2026-04-25 11:04:00 +01:00
parent b9ea631b4b
commit 5985e1d8b9
8 changed files with 415 additions and 342 deletions

View File

@@ -1,21 +1,19 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs";
import {
collectTypeScriptInventory,
collectModuleReferencesFromSource,
normalizeRepoPath,
resolveRepoSpecifier,
visitModuleSpecifiers,
writeLine,
} from "./lib/guard-inventory-utils.mjs";
import {
collectTypeScriptFilesFromRoots,
resolveSourceRoots,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -37,7 +35,7 @@ function pushEntry(entries, entry) {
entries.push(entry);
}
function scanPluginSdkExtensionFacadeSmells(sourceFile, filePath) {
function scanPluginSdkExtensionFacadeSmells(source, filePath) {
const relativeFile = normalizeRepoPath(repoRoot, filePath);
if (!relativeFile.startsWith("src/plugin-sdk/")) {
return [];
@@ -45,28 +43,28 @@ function scanPluginSdkExtensionFacadeSmells(sourceFile, filePath) {
const entries = [];
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
for (const { kind, line, specifier } of collectModuleReferencesFromSource(source)) {
if (kind !== "export") {
return;
continue;
}
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
if (!resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
return;
continue;
}
pushEntry(entries, {
category: "plugin-sdk-extension-facade",
file: relativeFile,
line: toLine(sourceFile, specifierNode),
line,
kind,
specifier,
resolvedPath,
reason: "plugin-sdk public surface re-exports extension-owned implementation",
});
});
}
return entries;
}
function scanRuntimeTypeImplementationSmells(sourceFile, filePath) {
function scanRuntimeTypeImplementationSmells(source, filePath) {
const relativeFile = normalizeRepoPath(repoRoot, filePath);
if (!/^src\/plugins\/runtime\/types(?:-[^/]+)?\.ts$/.test(relativeFile)) {
return [];
@@ -74,39 +72,32 @@ function scanRuntimeTypeImplementationSmells(sourceFile, filePath) {
const entries = [];
function visit(node) {
if (
ts.isImportTypeNode(node) &&
ts.isLiteralTypeNode(node.argument) &&
ts.isStringLiteral(node.argument.literal)
) {
const specifier = node.argument.literal.text;
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
if (
resolvedPath &&
(/^src\/plugins\/runtime\/runtime-[^/]+\.ts$/.test(resolvedPath) ||
/^extensions\/[^/]+\/runtime-api\.[^/]+$/.test(resolvedPath))
) {
pushEntry(entries, {
category: "runtime-type-implementation-edge",
file: relativeFile,
line: toLine(sourceFile, node.argument.literal),
kind: "import-type",
specifier,
resolvedPath,
reason: "runtime type file references implementation shim directly",
});
}
for (const { kind, line, specifier } of collectModuleReferencesFromSource(source)) {
if (kind !== "dynamic-import") {
continue;
}
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
if (
resolvedPath &&
(/^src\/plugins\/runtime\/runtime-[^/]+\.ts$/.test(resolvedPath) ||
/^extensions\/[^/]+\/runtime-api\.[^/]+$/.test(resolvedPath))
) {
pushEntry(entries, {
category: "runtime-type-implementation-edge",
file: relativeFile,
line,
kind: "import-type",
specifier,
resolvedPath,
reason: "runtime type file references implementation shim directly",
});
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return entries;
}
function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
function scanRuntimeServiceLocatorSmells(source, filePath) {
const relativeFile = normalizeRepoPath(repoRoot, filePath);
if (
!relativeFile.startsWith("src/plugin-sdk/") &&
@@ -120,47 +111,25 @@ function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
const runtimeStoreCalls = [];
const mutableStateNodes = [];
for (const statement of sourceFile.statements) {
if (ts.isFunctionDeclaration(statement) && statement.name) {
const isExported = statement.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
);
if (isExported) {
exportedNames.add(statement.name.text);
}
} else if (ts.isVariableStatement(statement)) {
const isExported = statement.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
);
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name) && isExported) {
exportedNames.add(declaration.name.text);
}
if (
!isExported &&
(statement.declarationList.flags & ts.NodeFlags.Let) !== 0 &&
ts.isIdentifier(declaration.name)
) {
mutableStateNodes.push(declaration.name);
}
}
const lines = source.split(/\r?\n/);
for (const [index, line] of lines.entries()) {
const lineNumber = index + 1;
const exportedFunction = line.match(/^\s*export\s+function\s+([A-Za-z_$][\w$]*)/);
if (exportedFunction) {
exportedNames.add(exportedFunction[1]);
}
const exportedVariable = line.match(/^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/);
if (exportedVariable) {
exportedNames.add(exportedVariable[1]);
}
for (const mutableMatch of line.matchAll(/^\s*let\s+([A-Za-z_$][\w$]*)/g)) {
mutableStateNodes.push({ line: lineNumber, text: mutableMatch[1] });
}
if (line.includes("createPluginRuntimeStore")) {
runtimeStoreCalls.push({ line: lineNumber });
}
}
function visit(node) {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "createPluginRuntimeStore"
) {
runtimeStoreCalls.push(node.expression);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
const getterNames = [...exportedNames].filter((name) => /^get[A-Z]/.test(name));
const setterNames = [...exportedNames].filter((name) => /^set[A-Z]/.test(name));
@@ -169,7 +138,7 @@ function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
pushEntry(entries, {
category: "runtime-service-locator",
file: relativeFile,
line: toLine(sourceFile, callNode),
line: callNode.line,
kind: "runtime-store",
specifier: "createPluginRuntimeStore",
resolvedPath: relativeFile,
@@ -183,7 +152,7 @@ function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
pushEntry(entries, {
category: "runtime-service-locator",
file: relativeFile,
line: toLine(sourceFile, identifier),
line: identifier.line,
kind: "mutable-state",
specifier: identifier.text,
resolvedPath: relativeFile,
@@ -201,18 +170,16 @@ export async function collectArchitectureSmells() {
const files = (await collectTypeScriptFilesFromRoots(scanRoots)).toSorted((left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
);
return await collectTypeScriptInventory({
ts,
files,
compareEntries,
collectEntries(sourceFile, filePath) {
return [
...scanPluginSdkExtensionFacadeSmells(sourceFile, filePath),
...scanRuntimeTypeImplementationSmells(sourceFile, filePath),
...scanRuntimeServiceLocatorSmells(sourceFile, filePath),
];
},
});
const entriesByFile = await Promise.all(
files.map(async (filePath) => {
const source = await fs.readFile(filePath, "utf8");
const entries = scanPluginSdkExtensionFacadeSmells(source, filePath);
entries.push(...scanRuntimeTypeImplementationSmells(source, filePath));
entries.push(...scanRuntimeServiceLocatorSmells(source, filePath));
return entries;
}),
);
return entriesByFile.flat().toSorted(compareEntries);
})();
try {
return await architectureSmellsPromise;

View File

@@ -3,13 +3,13 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import {
BUNDLED_PLUGIN_PATH_PREFIX,
BUNDLED_PLUGIN_ROOT_DIR,
} from "./lib/bundled-plugin-paths.mjs";
import { classifyBundledExtensionSourcePath } from "./lib/extension-source-classifier.mjs";
import {
collectModuleReferencesFromSource,
diffInventoryEntries,
normalizeRepoPath,
resolveRepoSpecifier,
@@ -107,7 +107,7 @@ async function collectExtensionModuleReferences() {
}
return {
filePath,
references: collectModuleReferences(source),
references: collectModuleReferencesFromSource(source),
};
}),
);
@@ -125,49 +125,6 @@ function mayContainModuleSpecifier(source) {
);
}
function collectModuleReferences(source) {
const lineStarts = computeLineStarts(source);
return ts.preProcessFile(source, true, true).importedFiles.map((reference) => ({
kind: inferModuleReferenceKind(source, reference.pos),
line: lineFromPosition(lineStarts, reference.pos),
specifier: reference.fileName,
}));
}
function computeLineStarts(source) {
const lineStarts = [0];
for (let index = 0; index < source.length; index += 1) {
if (source.charCodeAt(index) === 10) {
lineStarts.push(index + 1);
}
}
return lineStarts;
}
function lineFromPosition(lineStarts, position) {
let low = 0;
let high = lineStarts.length - 1;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
if (lineStarts[middle] <= position) {
low = middle + 1;
} else {
high = middle - 1;
}
}
return high + 1;
}
function inferModuleReferenceKind(source, specifierStart) {
const importIndex = source.lastIndexOf("import", specifierStart);
const exportIndex = source.lastIndexOf("export", specifierStart);
if (exportIndex > importIndex) {
return "export";
}
const importPrefix = source.slice(importIndex, specifierStart);
return /\bimport\s*\(\s*["']?$/.test(importPrefix) ? "dynamic-import" : "import";
}
function resolveExtensionRoot(filePath) {
const relativePath = normalizeRepoPath(repoRoot, filePath);
const segments = relativePath.split("/");

View File

@@ -1,19 +1,17 @@
import ts from "typescript";
import { promises as fs } from "node:fs";
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./bundled-plugin-paths.mjs";
import {
collectTypeScriptInventory,
collectModuleReferencesFromSource,
createCachedAsync,
formatGroupedInventoryHuman,
normalizeRepoPath,
resolveRepoSpecifier,
visitModuleSpecifiers,
writeLine,
} from "./guard-inventory-utils.mjs";
import {
collectTypeScriptFilesFromRoots,
resolveRepoRoot,
resolveSourceRoots,
toLine,
} from "./ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
@@ -38,27 +36,29 @@ function classifyResolvedExtensionReason(kind, boundaryLabel) {
return `${verb} bundled plugin file from ${boundaryLabel} boundary`;
}
function scanImportBoundaryViolations(sourceFile, filePath, boundaryLabel, allowResolvedPath) {
function scanImportBoundaryViolations(source, filePath, boundaryLabel, allowResolvedPath) {
const entries = [];
const relativeFile = normalizeRepoPath(repoRoot, filePath);
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
for (const reference of collectModuleReferencesFromSource(source)) {
const kind = reference.kind;
const specifier = reference.specifier;
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
if (!resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
return;
continue;
}
if (allowResolvedPath?.(resolvedPath, { kind, specifier, file: relativeFile })) {
return;
continue;
}
entries.push({
file: relativeFile,
line: toLine(sourceFile, specifierNode),
line: reference.line,
kind,
specifier,
resolvedPath,
reason: classifyResolvedExtensionReason(kind, boundaryLabel),
});
});
}
return entries;
}
@@ -72,22 +72,25 @@ export function createExtensionImportBoundaryChecker(params) {
.toSorted((left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
);
return await collectTypeScriptInventory({
ts,
files,
compareEntries,
collectEntries(sourceFile, filePath) {
const entriesByFile = await Promise.all(
files.map(async (filePath) => {
const source = await fs.readFile(filePath, "utf8");
if (
params.skipSourcesWithoutBundledPluginPrefix &&
!source.includes(BUNDLED_PLUGIN_PATH_PREFIX)
) {
return [];
}
return scanImportBoundaryViolations(
sourceFile,
source,
filePath,
params.boundaryLabel,
params.allowResolvedPath,
);
},
shouldParseSource: params.skipSourcesWithoutBundledPluginPrefix
? (source) => source.includes(BUNDLED_PLUGIN_PATH_PREFIX)
: undefined,
});
}),
);
const inventory = entriesByFile.flat();
return inventory.toSorted(compareEntries);
});
async function main(argv = process.argv.slice(2), io) {

View File

@@ -75,6 +75,122 @@ export function writeLine(stream, text) {
stream.write(`${text}\n`);
}
export function collectModuleReferencesFromSource(source) {
const lineStarts = computeLineStarts(source);
const isCodePosition = createCodePositionChecker(source);
const references = [];
const push = (kind, specifier, position, syntaxPosition) => {
if (!isCodePosition(syntaxPosition)) {
return;
}
references.push({
kind,
line: lineFromPosition(lineStarts, position),
specifier,
});
};
for (const match of source.matchAll(/\bimport\s*\(\s*(["'])([^"']+)\1/g)) {
push("dynamic-import", match[2], match.index + match[0].lastIndexOf(match[1]), match.index);
}
for (const match of source.matchAll(/^\s*import\s*(["'])([^"']+)\1/gm)) {
push(
"import",
match[2],
match.index + match[0].lastIndexOf(match[1]),
match.index + match[0].indexOf("import"),
);
}
for (const match of source.matchAll(
/^\s*(import|export)\s+(?:type\s+)?[^;"']*?\bfrom\s*(["'])([^"']+)\2/gm,
)) {
push(
match[1],
match[3],
match.index + match[0].lastIndexOf(match[2]),
match.index + match[0].indexOf(match[1]),
);
}
return references.toSorted(
(left, right) =>
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier),
);
}
function createCodePositionChecker(source) {
const codePositions = new Uint8Array(source.length);
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (char === "/" && next === "/") {
index += 2;
while (index < source.length && source.charCodeAt(index) !== 10) {
index += 1;
}
index -= 1;
continue;
}
if (char === "/" && next === "*") {
index += 2;
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
index += 1;
}
index += 1;
continue;
}
if (char === "'" || char === '"' || char === "`") {
const quote = char;
index += 1;
while (index < source.length) {
if (source[index] === "\\") {
index += 2;
continue;
}
if (source[index] === quote) {
break;
}
index += 1;
}
continue;
}
codePositions[index] = 1;
}
return (position) => codePositions[position] === 1;
}
function computeLineStarts(source) {
const lineStarts = [0];
for (let index = 0; index < source.length; index += 1) {
if (source.charCodeAt(index) === 10) {
lineStarts.push(index + 1);
}
}
return lineStarts;
}
function lineFromPosition(lineStarts, position) {
let low = 0;
let high = lineStarts.length - 1;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
if (lineStarts[middle] <= position) {
low = middle + 1;
} else {
high = middle - 1;
}
}
return high + 1;
}
export function createCachedAsync(factory) {
let cachedPromise = null;
return async function getCachedValue() {