Files
TREK/client/scripts/lib/imports.mjs
T
Maurice 3e37dcfb45 fix(client): lint:layers holds src/types.ts to the rules of types
A file's layer was the first path segment under src/, so src/types.ts,
the shared types module every layer imports as '../types', got the
layer "types.ts" and no rule at all, while the imports into it already
resolved to "types". It could import a component or a page unnoticed.
A file directly in src/ is now the layer of its name without the
extension, for the importer and the import alike. No file changes its
count: types.ts imports only @trek/shared today.
2026-10-08 13:04:40 +02:00

88 lines
3.7 KiB
JavaScript

/*
* The imports of a source file, read from its syntax tree rather than with a
* regular expression, so an import split over lines, a dynamic import() and a
* re-export count, and an import inside a comment or a string does not.
*/
import { dirname, join, relative, resolve } from 'node:path';
import ts from 'typescript';
import { listFiles, readText, TEST_FILE, toKey } from './ratchet.mjs';
const bindingsAreTypes = (elements) => elements.length > 0 && elements.every((el) => el.isTypeOnly);
/**
* Every module the file names, with the line it is named on and whether the
* import only brings in types (erased from the bundle, so no runtime path).
*/
export function importsOf(source, file) {
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind);
const found = [];
const add = (node, literal, typeOnly) => {
if (!literal || !ts.isStringLiteralLike(literal)) return;
found.push({
specifier: literal.text,
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
typeOnly,
});
};
const visit = (node) => {
if (ts.isImportDeclaration(node)) {
const clause = node.importClause;
const named =
clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements : null;
const typeOnly = Boolean(clause && (clause.isTypeOnly || (!clause.name && named && bindingsAreTypes(named))));
add(node, node.moduleSpecifier, typeOnly);
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
const named = node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements : null;
add(node, node.moduleSpecifier, node.isTypeOnly || Boolean(named && bindingsAreTypes(named)));
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
add(node, node.moduleReference.expression, node.isTypeOnly);
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
add(node, node.argument.literal, true);
} else if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
) {
add(node, node.arguments[0], false);
}
ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
/**
* The layer of a path from src/: its top directory ("components", "api"), or,
* for a file directly in src/, its name without the extension. src/types.ts
* is then the layer "types" both when it imports and when it is imported as
* '../types', and its rules are those of the src/types/ directory.
*/
export function layerOfPath(fromSrc) {
const [top, ...rest] = fromSrc.split('/');
return rest.length ? top : top.replace(/(\.d)?\.[cm]?[jt]sx?$/, '');
}
/**
* The layer a relative import lands in (see layerOfPath), or null for a
* package or a path outside src/.
*/
export function layerOf(srcRoot, fromFile, specifier) {
if (!specifier.startsWith('.')) return null;
const target = relative(srcRoot, resolve(dirname(fromFile), specifier))
.split('\\')
.join('/');
if (!target || target.startsWith('..')) return null;
return layerOfPath(target);
}
/** Every source file under root/src outside the tests, with its key, its layer and its imports resolved to layers. */
export function sourceGraph(root) {
const src = join(root, 'src');
return listFiles(root, ['src'], (key) => /\.tsx?$/.test(key) && !TEST_FILE.test(key)).map((path) => ({
key: toKey(root, path),
layer: layerOfPath(toKey(src, path)),
imports: importsOf(readText(path), path).map((imp) => ({ ...imp, layer: layerOf(src, path, imp.specifier) })),
}));
}