@archest/vitest 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +596 -0
- package/dist/index.mjs.map +1 -0
- package/dist/src/core/ClassLocator.d.ts +20 -0
- package/dist/src/core/FileLocator.d.ts +15 -0
- package/dist/src/core/FunctionLocator.d.ts +15 -0
- package/dist/src/core/LayerLocator.d.ts +23 -0
- package/dist/src/core/Project.d.ts +20 -0
- package/dist/src/core/PropertyLocator.d.ts +12 -0
- package/dist/src/core/SliceLocator.d.ts +10 -0
- package/dist/src/core/types.d.ts +4 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/matchers/index.d.ts +15 -0
- package/package.json +44 -0
- package/src/core/ClassLocator.ts +226 -0
- package/src/core/FileLocator.ts +168 -0
- package/src/core/FunctionLocator.ts +105 -0
- package/src/core/LayerLocator.ts +119 -0
- package/src/core/Project.ts +171 -0
- package/src/core/PropertyLocator.ts +50 -0
- package/src/core/SliceLocator.ts +92 -0
- package/src/core/types.ts +4 -0
- package/src/index.ts +9 -0
- package/src/matchers/index.ts +113 -0
- package/test/architecture.test.ts +59 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { RuleResult } from './types';
|
|
3
|
+
|
|
4
|
+
export class SliceLocator {
|
|
5
|
+
private slicePattern: RegExp;
|
|
6
|
+
private sliceIds: Set<string> = new Set();
|
|
7
|
+
private sliceFiles: Map<string, ts.SourceFile[]> = new Map();
|
|
8
|
+
|
|
9
|
+
constructor(sourceFiles: ts.SourceFile[], private program: ts.Program, pattern: string) {
|
|
10
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
11
|
+
const regexStr = escaped.replace(/\*/g, '([^/\\\\]+)');
|
|
12
|
+
this.slicePattern = new RegExp(regexStr);
|
|
13
|
+
|
|
14
|
+
for (const sf of sourceFiles) {
|
|
15
|
+
const match = sf.fileName.match(this.slicePattern);
|
|
16
|
+
if (match && match[1]) {
|
|
17
|
+
const sliceId = match[1];
|
|
18
|
+
this.sliceIds.add(sliceId);
|
|
19
|
+
if (!this.sliceFiles.has(sliceId)) {
|
|
20
|
+
this.sliceFiles.set(sliceId, []);
|
|
21
|
+
}
|
|
22
|
+
this.sliceFiles.get(sliceId)!.push(sf);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
checkBeFreeOfCycles(isNot: boolean): RuleResult {
|
|
28
|
+
const graph: Map<string, Set<string>> = new Map();
|
|
29
|
+
for (const slice of this.sliceIds) {
|
|
30
|
+
graph.set(slice, new Set());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const [sliceId, files] of this.sliceFiles.entries()) {
|
|
34
|
+
for (const sf of files) {
|
|
35
|
+
ts.forEachChild(sf, (node) => {
|
|
36
|
+
if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
37
|
+
const importPath = node.moduleSpecifier.text;
|
|
38
|
+
const resolved = ts.resolveModuleName(importPath, sf.fileName, this.program.getCompilerOptions(), ts.sys);
|
|
39
|
+
if (resolved.resolvedModule && resolved.resolvedModule.resolvedFileName) {
|
|
40
|
+
const targetMatch = resolved.resolvedModule.resolvedFileName.match(this.slicePattern);
|
|
41
|
+
if (targetMatch && targetMatch[1]) {
|
|
42
|
+
const targetSlice = targetMatch[1];
|
|
43
|
+
if (targetSlice !== sliceId && this.sliceIds.has(targetSlice)) {
|
|
44
|
+
graph.get(sliceId)!.add(targetSlice);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const visited = new Set<string>();
|
|
54
|
+
const recursionStack = new Set<string>();
|
|
55
|
+
const violations: string[] = [];
|
|
56
|
+
|
|
57
|
+
const dfs = (node: string, path: string[]): boolean => {
|
|
58
|
+
visited.add(node);
|
|
59
|
+
recursionStack.add(node);
|
|
60
|
+
|
|
61
|
+
for (const neighbor of (graph.get(node) || [])) {
|
|
62
|
+
if (!visited.has(neighbor)) {
|
|
63
|
+
if (dfs(neighbor, [...path, neighbor])) return true;
|
|
64
|
+
} else if (recursionStack.has(neighbor)) {
|
|
65
|
+
violations.push(`Cycle detected between slices: ${path.join(' -> ')} -> ${neighbor}`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
recursionStack.delete(node);
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
for (const slice of this.sliceIds) {
|
|
75
|
+
if (!visited.has(slice)) {
|
|
76
|
+
dfs(slice, [slice]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (isNot) {
|
|
81
|
+
return {
|
|
82
|
+
pass: violations.length > 0,
|
|
83
|
+
message: () => violations.length > 0 ? '' : 'Expected cycles between slices but found none.'
|
|
84
|
+
};
|
|
85
|
+
} else {
|
|
86
|
+
return {
|
|
87
|
+
pass: violations.length === 0,
|
|
88
|
+
message: () => violations.join('\n')
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Project, parseProject } from './core/Project';
|
|
2
|
+
export { setupMatchers } from './matchers';
|
|
3
|
+
export * from './core/FileLocator';
|
|
4
|
+
export * from './core/ClassLocator';
|
|
5
|
+
export * from './core/FunctionLocator';
|
|
6
|
+
export * from './core/PropertyLocator';
|
|
7
|
+
export * from './core/SliceLocator';
|
|
8
|
+
export * from './core/LayerLocator';
|
|
9
|
+
export * from './core/types';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { expect } from 'vitest';
|
|
2
|
+
import { RuleResult } from '../core/types';
|
|
3
|
+
import { ClassLocator } from '../core/ClassLocator';
|
|
4
|
+
import { FunctionLocator } from '../core/FunctionLocator';
|
|
5
|
+
import { PropertyLocator } from '../core/PropertyLocator';
|
|
6
|
+
import { FileLocator } from '../core/FileLocator';
|
|
7
|
+
import { SliceLocator } from '../core/SliceLocator';
|
|
8
|
+
import { LayeredArchitectureBuilder } from '../core/LayerLocator';
|
|
9
|
+
|
|
10
|
+
export function setupMatchers() {
|
|
11
|
+
expect.extend({
|
|
12
|
+
toPass(received: RuleResult | LayeredArchitectureBuilder) {
|
|
13
|
+
const result = received instanceof LayeredArchitectureBuilder ? received.check() : received;
|
|
14
|
+
const { pass, message } = result;
|
|
15
|
+
return {
|
|
16
|
+
pass: this.isNot ? !pass : pass,
|
|
17
|
+
message: pass
|
|
18
|
+
? () => 'Expected rule not to pass'
|
|
19
|
+
: () => message()
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
toResideInFolder(received: ClassLocator, folder: string) {
|
|
24
|
+
const { pass, message } = received.checkResideInFolder(folder, this.isNot);
|
|
25
|
+
return {
|
|
26
|
+
pass: this.isNot ? !pass : pass,
|
|
27
|
+
message
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
toHaveModifier(received: ClassLocator | FunctionLocator, modifier: string) {
|
|
32
|
+
const { pass, message } = received.checkHaveModifier(modifier, this.isNot);
|
|
33
|
+
return {
|
|
34
|
+
pass: this.isNot ? !pass : pass,
|
|
35
|
+
message
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
toExtendClass(received: ClassLocator, className: string) {
|
|
42
|
+
const { pass, message } = received.checkExtendClass(className, this.isNot);
|
|
43
|
+
return {
|
|
44
|
+
pass: this.isNot ? !pass : pass,
|
|
45
|
+
message
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
toImplementInterface(received: ClassLocator, interfaceName: string) {
|
|
50
|
+
const { pass, message } = received.checkImplementInterface(interfaceName, this.isNot);
|
|
51
|
+
return {
|
|
52
|
+
pass: this.isNot ? !pass : pass,
|
|
53
|
+
message
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
toHaveExplicitReturnType(received: FunctionLocator) {
|
|
58
|
+
const { pass, message } = received.checkHaveExplicitReturnType(this.isNot);
|
|
59
|
+
return {
|
|
60
|
+
pass: this.isNot ? !pass : pass,
|
|
61
|
+
message
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
toBeReadonly(received: PropertyLocator) {
|
|
66
|
+
const { pass, message } = received.checkBeReadonly(this.isNot);
|
|
67
|
+
return {
|
|
68
|
+
pass: this.isNot ? !pass : pass,
|
|
69
|
+
message
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
toDependOnFilesInFolder(received: FileLocator, folder: string) {
|
|
74
|
+
const { pass, message } = received.checkDependOnFilesInFolder(folder, this.isNot);
|
|
75
|
+
return {
|
|
76
|
+
pass: this.isNot ? !pass : pass,
|
|
77
|
+
message
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
toBeFreeOfCycles(received: FileLocator | SliceLocator) {
|
|
82
|
+
const { pass, message } = received.checkBeFreeOfCycles(this.isNot);
|
|
83
|
+
return {
|
|
84
|
+
pass: this.isNot ? !pass : pass,
|
|
85
|
+
message
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
toMatchNamePattern(received: FileLocator | ClassLocator | FunctionLocator, pattern: string | RegExp) {
|
|
90
|
+
const { pass, message } = received.checkMatchNamePattern(pattern, this.isNot);
|
|
91
|
+
return {
|
|
92
|
+
pass: this.isNot ? !pass : pass,
|
|
93
|
+
message
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
declare module 'vitest' {
|
|
100
|
+
interface Assertion<T = any> {
|
|
101
|
+
toPass(): void;
|
|
102
|
+
toResideInFolder(folder: string): void;
|
|
103
|
+
toHaveModifier(modifier: 'export' | 'default' | 'abstract' | 'async' | 'private' | 'public'): void;
|
|
104
|
+
|
|
105
|
+
toExtendClass(className: string): void;
|
|
106
|
+
toImplementInterface(interfaceName: string): void;
|
|
107
|
+
toHaveExplicitReturnType(): void;
|
|
108
|
+
toBeReadonly(): void;
|
|
109
|
+
toDependOnFilesInFolder(folder: string): void;
|
|
110
|
+
toBeFreeOfCycles(): void;
|
|
111
|
+
toMatchNamePattern(pattern: string | RegExp): void;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseProject } from '../src/core/Project';
|
|
3
|
+
import { setupMatchers } from '../src/matchers';
|
|
4
|
+
|
|
5
|
+
setupMatchers();
|
|
6
|
+
|
|
7
|
+
describe('@archest/vitest', () => {
|
|
8
|
+
const project = parseProject(); // Automatically picks up tsconfig.json in the current directory
|
|
9
|
+
|
|
10
|
+
describe('FileLocator (Dependency Checks)', () => {
|
|
11
|
+
it('core classes should not depend on matchers', () => {
|
|
12
|
+
const coreFiles = project.getFiles({ inFolder: 'core' });
|
|
13
|
+
expect(coreFiles).not.toDependOnFilesInFolder('matchers');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('matchers should depend on core', () => {
|
|
17
|
+
const matcherFiles = project.getFiles({ inFolder: 'matchers' });
|
|
18
|
+
expect(matcherFiles).toDependOnFilesInFolder('core');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('ClassLocator (Declaration Checks)', () => {
|
|
23
|
+
it('locators should have export modifier', () => {
|
|
24
|
+
const locators = project.getClasses({ inFolder: 'core', matchNamePattern: /Locator$/ });
|
|
25
|
+
expect(locators).toHaveModifier('export');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('Project class should be exported', () => {
|
|
29
|
+
const projectClasses = project.getClasses({ matchNamePattern: /Project$/ });
|
|
30
|
+
expect(projectClasses).toHaveModifier('export');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('FileLocator (Pattern matching)', () => {
|
|
35
|
+
it('src files should match the src pattern', () => {
|
|
36
|
+
const srcFiles = project.getFiles({ matchNamePattern: /src\/core\/.*/ });
|
|
37
|
+
expect(srcFiles).not.toDependOnFilesInFolder('matchers');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('core classes should be free of cycles', () => {
|
|
41
|
+
const coreFiles = project.getFiles({ inFolder: 'core' });
|
|
42
|
+
expect(coreFiles).toBeFreeOfCycles();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('LayeredArchitectureBuilder', () => {
|
|
47
|
+
it('core should not depend on matchers layer', () => {
|
|
48
|
+
const architecture = project.layeredArchitecture()
|
|
49
|
+
.layer('Core', 'core')
|
|
50
|
+
.layer('Matchers', 'matchers')
|
|
51
|
+
.layer('Root', 'src'); // src/index.ts is just under src
|
|
52
|
+
|
|
53
|
+
const rule = architecture
|
|
54
|
+
.whereLayer('Matchers').shouldOnlyBeAccessedBy('Core', 'Root');
|
|
55
|
+
|
|
56
|
+
expect(rule).toPass();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"outDir": "dist"
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"]
|
|
17
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import dts from 'vite-plugin-dts';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
build: {
|
|
7
|
+
lib: {
|
|
8
|
+
entry: resolve(__dirname, 'src/index.ts'),
|
|
9
|
+
name: 'VitestArch',
|
|
10
|
+
fileName: (format) => `index.${format === 'es' ? 'mjs' : 'js'}`,
|
|
11
|
+
},
|
|
12
|
+
rollupOptions: {
|
|
13
|
+
external: ['typescript', 'vitest', 'path', 'fs'],
|
|
14
|
+
},
|
|
15
|
+
sourcemap: true,
|
|
16
|
+
},
|
|
17
|
+
plugins: [dts()],
|
|
18
|
+
});
|