@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.
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export interface RuleResult {
2
+ pass: boolean;
3
+ message: () => string;
4
+ }
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
+ });