could not get tsarch working
npm uninstall tsarch
inspired by https://stackoverflow.com/a/69210603/820837
hacked together my own (in a very rough state)
src/architecture.spec.ts
import { describe, expect, it } from 'vitest';
import { verifyArchitecture } from './archtest';
describe('Architecture test', () => {
it('services should not depend on db', async () => {
expect(await verifyArchitecture({
filesFromFolder: 'src/lib/server/services',
notDependOnFolder: 'src/lib/server/db'
})).toEqual([]);
});
it('repositories should not depend on services', async () => {
expect(await verifyArchitecture({
filesFromFolder: 'src/lib/server/repositories',
notDependOnFolder: 'src/lib/server/services'
})).toEqual([]);
});
it('db should not depend on services', async () => {
expect(await verifyArchitecture({
filesFromFolder: 'src/lib/server/db',
notDependOnFolder: 'src/lib/server/services'
})).toEqual([]);
});
});
src/archtest/index.ts
import { readdir, readFile } from 'node:fs/promises';
import * as ts from 'typescript';
async function getFiles(folder: string) {
if (folder.endsWith('/')) throw new Error('folder cannot end in /');
if (folder === '') throw new Error('folder cannot be empty');
const files = await readdir(folder, { recursive: true });
return files.map(file => `${folder}/${file}`);
}
type FileDependencies = {
file: string,
dependencies: Dependency[]
}
async function getDependenciesFromFile(file: string): Promise<FileDependencies | null> {
try {
const source = await readFile(file);
const rootNode = ts.createSourceFile(
file,
source.toString(),
ts.ScriptTarget.Latest,
/*setParentNodes */ true
);
return {
file: file,
dependencies: rootNode.getChildren().flatMap(c => getDependenciesFromNode(file, c))
};
} catch (e: unknown) {
if(e.code === 'EISDIR') return null
throw new Error(`failed to get dependencies from file '${file}': ${e}`);
}
}
function unLib(dependency: Dependency) {
// todo: relative depending on tsconfig.json
return {
...dependency,
referencedSpecifier: dependency.referencedSpecifier.replace(/^\$lib\//, 'src/lib/')
};
}
const specifierRelativeFile = /^\..*$/;
const specifierNodeModule = /^[^.]/;
type Dependency = {
typeOnly: boolean
relativePathReference: boolean
referencingPath: string
referencedSpecifier: string
}
function getDependenciesFromNode(path: string, node: ts.Node): Dependency[] {
switch (node.kind) {
case ts.SyntaxKind.ExportDeclaration: {
console.log('skipping ExportDeclaration');
return [];
}
case ts.SyntaxKind.ImportDeclaration: {
const importDeclaration = node as ts.ImportDeclaration;
const importClause = importDeclaration.importClause;
const specifier = (importDeclaration.moduleSpecifier as ts.StringLiteral).text;
if (!specifier) {
console.log('no specifier');
return [];
}
if (specifierRelativeFile.test(specifier)) {
return [{
typeOnly: (!!importClause && !importClause.isTypeOnly),
relativePathReference: true,
referencingPath: path,
referencedSpecifier: specifier
}];
} else if (specifierNodeModule.test(specifier)) {
return [{
typeOnly: (!!importClause && !importClause.isTypeOnly),
relativePathReference: false,
referencingPath: path,
referencedSpecifier: specifier
}];
} else {
console.log('specifier neither relative nor module', specifier);
return [];
}
}
case ts.SyntaxKind.CallExpression: {
const callExpression = node as ts.CallExpression;
if (!((callExpression.expression.kind === ts.SyntaxKind.ImportKeyword ||
(callExpression.expression.kind === ts.SyntaxKind.Identifier &&
callExpression.expression.getText() === 'require')) &&
callExpression.arguments[0]?.kind === ts.SyntaxKind.StringLiteral)) {
return node.getChildren().flatMap(c => getDependenciesFromNode(path, c));
}
const specifier = (callExpression.arguments[0] as ts.StringLiteral).text;
if (specifierRelativeFile.test(specifier)) {
return [{
typeOnly: false,
relativePathReference: true,
referencingPath: path,
referencedSpecifier: specifier
}];
} else if (specifierNodeModule.test(specifier)) {
return [{
typeOnly: false,
relativePathReference: false,
referencingPath: path,
referencedSpecifier: specifier
}];
} else {
return node.getChildren().flatMap(c => getDependenciesFromNode(path, c));
}
}
default: {
return node.getChildren().flatMap(c => getDependenciesFromNode(path, c));
}
}
}
export type ArchitectureSpec = {
filesFromFolder: string
notDependOnFolder: string
}
export type Violation = {
file: string,
message: string
}
export async function verifyArchitecture(spec: ArchitectureSpec): Promise<Violation[]> {
const codeFileExtensions = ['.ts', '.js']
const filesFromFolder = (await getFiles(spec.filesFromFolder))
.filter(file => codeFileExtensions.filter(ext => file.endsWith(ext)).length );
const parsedFileDependencies = await Promise.all(filesFromFolder.map(getDependenciesFromFile));
const dependenciesFromFolder = parsedFileDependencies
.filter(d => d!==null)
.map(f => ({
...f, dependencies: f.dependencies
.map(unLib)
// unRelative
.map(d => d.referencedSpecifier)
}))
;
const violations: Violation[] = dependenciesFromFolder
.map(f => {
const notAllowed = f.dependencies.filter(d => d.startsWith(spec.notDependOnFolder));
if (notAllowed.length) {
return {
file: f.file,
message: `should not depend on folder ${spec.notDependOnFolder}`
};
}
return null;
})
.filter(v => v !== null);
return violations;
}