@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,168 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { RuleResult } from './types';
|
|
3
|
+
|
|
4
|
+
export interface FileQueryOptions {
|
|
5
|
+
inFolder?: string;
|
|
6
|
+
matchNamePattern?: string | RegExp;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class FileLocator {
|
|
10
|
+
public readonly files: ts.SourceFile[];
|
|
11
|
+
|
|
12
|
+
constructor(sourceFiles: ts.SourceFile[], private program: ts.Program, options?: FileQueryOptions) {
|
|
13
|
+
let filtered = sourceFiles;
|
|
14
|
+
|
|
15
|
+
if (options?.inFolder) {
|
|
16
|
+
filtered = filtered.filter((file) => file.fileName.includes(`/${options.inFolder}/`) || file.fileName.includes(`\\${options.inFolder}\\`));
|
|
17
|
+
}
|
|
18
|
+
if (options?.matchNamePattern) {
|
|
19
|
+
const regex = typeof options.matchNamePattern === 'string' ? new RegExp(options.matchNamePattern) : options.matchNamePattern;
|
|
20
|
+
filtered = filtered.filter((file) => regex.test(file.fileName));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
this.files = filtered;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private getFileDependencies(sourceFile: ts.SourceFile): string[] {
|
|
27
|
+
const dependencies: string[] = [];
|
|
28
|
+
const compilerOptions = this.program.getCompilerOptions();
|
|
29
|
+
|
|
30
|
+
const visit = (node: ts.Node) => {
|
|
31
|
+
if (ts.isImportDeclaration(node)) {
|
|
32
|
+
const moduleSpecifier = (node.moduleSpecifier as ts.StringLiteral).text;
|
|
33
|
+
const resolved = ts.resolveModuleName(
|
|
34
|
+
moduleSpecifier,
|
|
35
|
+
sourceFile.fileName,
|
|
36
|
+
compilerOptions,
|
|
37
|
+
ts.sys
|
|
38
|
+
);
|
|
39
|
+
if (resolved.resolvedModule && resolved.resolvedModule.resolvedFileName) {
|
|
40
|
+
if (!resolved.resolvedModule.isExternalLibraryImport) {
|
|
41
|
+
dependencies.push(resolved.resolvedModule.resolvedFileName);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
|
45
|
+
const moduleSpecifier = (node.moduleSpecifier as ts.StringLiteral).text;
|
|
46
|
+
const resolved = ts.resolveModuleName(
|
|
47
|
+
moduleSpecifier,
|
|
48
|
+
sourceFile.fileName,
|
|
49
|
+
compilerOptions,
|
|
50
|
+
ts.sys
|
|
51
|
+
);
|
|
52
|
+
if (resolved.resolvedModule && resolved.resolvedModule.resolvedFileName) {
|
|
53
|
+
if (!resolved.resolvedModule.isExternalLibraryImport) {
|
|
54
|
+
dependencies.push(resolved.resolvedModule.resolvedFileName);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
ts.forEachChild(node, visit);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
visit(sourceFile);
|
|
62
|
+
return dependencies;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
checkDependOnFilesInFolder(targetFolder: string, isNot: boolean): RuleResult {
|
|
66
|
+
const violations: string[] = [];
|
|
67
|
+
|
|
68
|
+
for (const file of this.files) {
|
|
69
|
+
const dependencies = this.getFileDependencies(file);
|
|
70
|
+
|
|
71
|
+
const dependsOnTarget = dependencies.some((depPath) => depPath.includes(`/${targetFolder}/`) || depPath.includes(`\\${targetFolder}\\`));
|
|
72
|
+
|
|
73
|
+
if (isNot && dependsOnTarget) {
|
|
74
|
+
violations.push(`File ${file.fileName} depends on files in ${targetFolder}, but it shouldn't.`);
|
|
75
|
+
} else if (!isNot && !dependsOnTarget) {
|
|
76
|
+
violations.push(`File ${file.fileName} does not depend on files in ${targetFolder}, but it should.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
pass: violations.length === 0,
|
|
82
|
+
message: () => violations.join('\n'),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
checkBeFreeOfCycles(isNot: boolean): RuleResult {
|
|
87
|
+
const violations: string[] = [];
|
|
88
|
+
const targetedFilePaths = new Set<string>(this.files.map(f => f.fileName));
|
|
89
|
+
const graph = new Map<string, string[]>();
|
|
90
|
+
|
|
91
|
+
for (const file of this.files) {
|
|
92
|
+
const filePath = file.fileName;
|
|
93
|
+
const dependencies = this.getFileDependencies(file)
|
|
94
|
+
.filter((p) => targetedFilePaths.has(p));
|
|
95
|
+
|
|
96
|
+
graph.set(filePath, dependencies);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const visited = new Set<string>();
|
|
100
|
+
const recStack = new Set<string>();
|
|
101
|
+
const cycles: string[][] = [];
|
|
102
|
+
|
|
103
|
+
const isCyclicUtil = (node: string, path: string[]) => {
|
|
104
|
+
if (recStack.has(node)) {
|
|
105
|
+
const cycleStartIndex = path.indexOf(node);
|
|
106
|
+
const cycleStr = [...path.slice(cycleStartIndex), node].join('->');
|
|
107
|
+
if (!cycles.some(c => c.join('->') === cycleStr)) {
|
|
108
|
+
cycles.push([...path.slice(cycleStartIndex), node]);
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (visited.has(node)) return false;
|
|
114
|
+
|
|
115
|
+
visited.add(node);
|
|
116
|
+
recStack.add(node);
|
|
117
|
+
path.push(node);
|
|
118
|
+
|
|
119
|
+
const neighbors = graph.get(node) || [];
|
|
120
|
+
for (const neighbor of neighbors) {
|
|
121
|
+
isCyclicUtil(neighbor, path);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
recStack.delete(node);
|
|
125
|
+
path.pop();
|
|
126
|
+
return false;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
for (const node of graph.keys()) {
|
|
130
|
+
if (!visited.has(node)) {
|
|
131
|
+
isCyclicUtil(node, []);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (isNot && cycles.length === 0) {
|
|
136
|
+
violations.push('Expected cyclic dependencies, but found none.');
|
|
137
|
+
} else if (!isNot && cycles.length > 0) {
|
|
138
|
+
for (const cycle of cycles) {
|
|
139
|
+
violations.push(`Cycle detected: ${cycle.join(' -> ')}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
pass: violations.length === 0,
|
|
145
|
+
message: () => violations.join('\n'),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
checkMatchNamePattern(pattern: string | RegExp, isNot: boolean): RuleResult {
|
|
150
|
+
const violations: string[] = [];
|
|
151
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
152
|
+
|
|
153
|
+
for (const file of this.files) {
|
|
154
|
+
const matches = regex.test(file.fileName);
|
|
155
|
+
|
|
156
|
+
if (isNot && matches) {
|
|
157
|
+
violations.push(`File ${file.fileName} matches pattern ${pattern}, but it shouldn't.`);
|
|
158
|
+
} else if (!isNot && !matches) {
|
|
159
|
+
violations.push(`File ${file.fileName} does not match pattern ${pattern}, but it should.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
pass: violations.length === 0,
|
|
165
|
+
message: () => violations.join('\n'),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { RuleResult } from './types';
|
|
3
|
+
|
|
4
|
+
export interface FunctionQueryOptions {
|
|
5
|
+
inFolder?: string;
|
|
6
|
+
matchNamePattern?: string | RegExp;
|
|
7
|
+
isTopLevel?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class FunctionLocator {
|
|
11
|
+
public readonly functions: ts.FunctionLikeDeclaration[];
|
|
12
|
+
|
|
13
|
+
constructor(functions: ts.FunctionLikeDeclaration[], private program: ts.Program, options?: FunctionQueryOptions) {
|
|
14
|
+
let filtered = functions;
|
|
15
|
+
|
|
16
|
+
if (options?.inFolder) {
|
|
17
|
+
filtered = filtered.filter((f) => {
|
|
18
|
+
const sourceFile = f.getSourceFile();
|
|
19
|
+
return sourceFile.fileName.includes(`/${options.inFolder}/`) || sourceFile.fileName.includes(`\\${options.inFolder}\\`);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (options?.matchNamePattern) {
|
|
24
|
+
const regex = typeof options.matchNamePattern === 'string' ? new RegExp(options.matchNamePattern) : options.matchNamePattern;
|
|
25
|
+
filtered = filtered.filter((f) => {
|
|
26
|
+
return f.name && ts.isIdentifier(f.name) && regex.test(f.name.text);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (options?.isTopLevel) {
|
|
31
|
+
filtered = filtered.filter((f) => {
|
|
32
|
+
return ts.isSourceFile(f.parent);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.functions = filtered;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
checkHaveModifier(modifierStr: string, isNot: boolean): RuleResult {
|
|
40
|
+
let targetKind: ts.SyntaxKind;
|
|
41
|
+
switch (modifierStr) {
|
|
42
|
+
case 'export': targetKind = ts.SyntaxKind.ExportKeyword; break;
|
|
43
|
+
case 'async': targetKind = ts.SyntaxKind.AsyncKeyword; break;
|
|
44
|
+
case 'private': targetKind = ts.SyntaxKind.PrivateKeyword; break;
|
|
45
|
+
case 'public': targetKind = ts.SyntaxKind.PublicKeyword; break;
|
|
46
|
+
default:
|
|
47
|
+
throw new Error(`Modifier ${modifierStr} is not fully supported.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const violations: string[] = [];
|
|
51
|
+
for (const f of this.functions) {
|
|
52
|
+
const name = f.name && ts.isIdentifier(f.name) ? f.name.text : 'Anonymous Function';
|
|
53
|
+
const modifiers = ts.canHaveModifiers(f) ? ts.getModifiers(f) : undefined;
|
|
54
|
+
const matches = modifiers && modifiers.some((m: ts.Modifier) => m.kind === targetKind);
|
|
55
|
+
|
|
56
|
+
if (isNot && matches) {
|
|
57
|
+
violations.push(`Function ${name} has modifier ${modifierStr}, but it shouldn't.`);
|
|
58
|
+
} else if (!isNot && !matches) {
|
|
59
|
+
violations.push(`Function ${name} does not have modifier ${modifierStr}, but it should.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
pass: violations.length === 0,
|
|
64
|
+
message: () => violations.join('\n'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
checkHaveExplicitReturnType(isNot: boolean): RuleResult {
|
|
69
|
+
const violations: string[] = [];
|
|
70
|
+
for (const f of this.functions) {
|
|
71
|
+
const name = f.name && ts.isIdentifier(f.name) ? f.name.text : 'Anonymous Function';
|
|
72
|
+
const hasType = !!f.type;
|
|
73
|
+
|
|
74
|
+
if (isNot && hasType) {
|
|
75
|
+
violations.push(`Function ${name} has an explicit return type, but it shouldn't.`);
|
|
76
|
+
} else if (!isNot && !hasType) {
|
|
77
|
+
violations.push(`Function ${name} does not have an explicit return type, but it should.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
pass: violations.length === 0,
|
|
82
|
+
message: () => violations.join('\n'),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
checkMatchNamePattern(pattern: string | RegExp, isNot: boolean): RuleResult {
|
|
87
|
+
const violations: string[] = [];
|
|
88
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
89
|
+
|
|
90
|
+
for (const f of this.functions) {
|
|
91
|
+
const name = f.name && ts.isIdentifier(f.name) ? f.name.text : undefined;
|
|
92
|
+
const matches = name && regex.test(name);
|
|
93
|
+
|
|
94
|
+
if (isNot && matches) {
|
|
95
|
+
violations.push(`Function ${name} matches pattern ${pattern}, but it shouldn't.`);
|
|
96
|
+
} else if (!isNot && !matches) {
|
|
97
|
+
violations.push(`Function ${name || 'Anonymous Function'} does not match pattern ${pattern}, but it should.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
pass: violations.length === 0,
|
|
102
|
+
message: () => violations.join('\n'),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { RuleResult } from './types';
|
|
3
|
+
|
|
4
|
+
export class LayeredArchitectureBuilder {
|
|
5
|
+
private files: ts.SourceFile[];
|
|
6
|
+
private layers = new Map<string, string>();
|
|
7
|
+
private assertions: Array<(files: ts.SourceFile[]) => string[]> = [];
|
|
8
|
+
|
|
9
|
+
constructor(files: ts.SourceFile[], public program: ts.Program) {
|
|
10
|
+
this.files = files;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
layer(name: string, folderPattern: string): this {
|
|
14
|
+
this.layers.set(name, folderPattern);
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
whereLayer(name: string): LayerAssertionBuilder {
|
|
19
|
+
if (!this.layers.has(name)) {
|
|
20
|
+
throw new Error(`Layer ${name} is not defined`);
|
|
21
|
+
}
|
|
22
|
+
return new LayerAssertionBuilder(this, name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
addAssertion(assertion: (files: ts.SourceFile[]) => string[]) {
|
|
26
|
+
this.assertions.push(assertion);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getFiles(): ts.SourceFile[] {
|
|
30
|
+
return this.files;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getLayerPattern(name: string): string {
|
|
34
|
+
return this.layers.get(name)!;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getFileDependencies(sourceFile: ts.SourceFile): string[] {
|
|
38
|
+
const dependencies: string[] = [];
|
|
39
|
+
const compilerOptions = this.program.getCompilerOptions();
|
|
40
|
+
|
|
41
|
+
const visit = (node: ts.Node) => {
|
|
42
|
+
if (ts.isImportDeclaration(node)) {
|
|
43
|
+
const moduleSpecifier = (node.moduleSpecifier as ts.StringLiteral).text;
|
|
44
|
+
const resolved = ts.resolveModuleName(moduleSpecifier, sourceFile.fileName, compilerOptions, ts.sys);
|
|
45
|
+
if (resolved.resolvedModule && resolved.resolvedModule.resolvedFileName && !resolved.resolvedModule.isExternalLibraryImport) {
|
|
46
|
+
dependencies.push(resolved.resolvedModule.resolvedFileName);
|
|
47
|
+
}
|
|
48
|
+
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
|
49
|
+
const moduleSpecifier = (node.moduleSpecifier as ts.StringLiteral).text;
|
|
50
|
+
const resolved = ts.resolveModuleName(moduleSpecifier, sourceFile.fileName, compilerOptions, ts.sys);
|
|
51
|
+
if (resolved.resolvedModule && resolved.resolvedModule.resolvedFileName && !resolved.resolvedModule.isExternalLibraryImport) {
|
|
52
|
+
dependencies.push(resolved.resolvedModule.resolvedFileName);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
ts.forEachChild(node, visit);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
visit(sourceFile);
|
|
59
|
+
return dependencies;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
check(): RuleResult {
|
|
63
|
+
const violations: string[] = [];
|
|
64
|
+
for (const assertion of this.assertions) {
|
|
65
|
+
violations.push(...assertion(this.files));
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
pass: violations.length === 0,
|
|
69
|
+
message: () => violations.join('\n')
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class LayerAssertionBuilder {
|
|
75
|
+
constructor(private parent: LayeredArchitectureBuilder, private targetLayer: string) {}
|
|
76
|
+
|
|
77
|
+
shouldNotBeAccessedByAnyLayer(): LayeredArchitectureBuilder {
|
|
78
|
+
this.parent.addAssertion((files) => {
|
|
79
|
+
const violations: string[] = [];
|
|
80
|
+
const targetPattern = this.parent.getLayerPattern(this.targetLayer);
|
|
81
|
+
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
const filePath = file.fileName;
|
|
84
|
+
if (!filePath.includes(`/${targetPattern}/`) && !filePath.includes(`\\${targetPattern}\\`)) {
|
|
85
|
+
const dependencies = this.parent.getFileDependencies(file);
|
|
86
|
+
const importsTarget = dependencies.some(dep => dep.includes(`/${targetPattern}/`) || dep.includes(`\\${targetPattern}\\`));
|
|
87
|
+
if (importsTarget) {
|
|
88
|
+
violations.push(`File ${filePath} accesses layer ${this.targetLayer} but it shouldn't.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return violations;
|
|
93
|
+
});
|
|
94
|
+
return this.parent;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
shouldOnlyBeAccessedBy(...allowedLayers: string[]): LayeredArchitectureBuilder {
|
|
98
|
+
this.parent.addAssertion((files) => {
|
|
99
|
+
const violations: string[] = [];
|
|
100
|
+
const targetPattern = this.parent.getLayerPattern(this.targetLayer);
|
|
101
|
+
const allowedPatterns = allowedLayers.map(l => this.parent.getLayerPattern(l));
|
|
102
|
+
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const filePath = file.fileName;
|
|
105
|
+
const isInAllowedLayer = allowedPatterns.some(p => filePath.includes(`/${p}/`) || filePath.includes(`\\${p}\\`));
|
|
106
|
+
|
|
107
|
+
if (!filePath.includes(`/${targetPattern}/`) && !filePath.includes(`\\${targetPattern}\\`) && !isInAllowedLayer) {
|
|
108
|
+
const dependencies = this.parent.getFileDependencies(file);
|
|
109
|
+
const importsTarget = dependencies.some(dep => dep.includes(`/${targetPattern}/`) || dep.includes(`\\${targetPattern}\\`));
|
|
110
|
+
if (importsTarget) {
|
|
111
|
+
violations.push(`File ${filePath} accesses layer ${this.targetLayer} but only ${allowedLayers.join(', ')} are allowed.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return violations;
|
|
116
|
+
});
|
|
117
|
+
return this.parent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { FileLocator, FileQueryOptions } from './FileLocator';
|
|
4
|
+
import { ClassLocator, ClassQueryOptions } from './ClassLocator';
|
|
5
|
+
import { LayeredArchitectureBuilder } from './LayerLocator';
|
|
6
|
+
import { FunctionLocator, FunctionQueryOptions } from './FunctionLocator';
|
|
7
|
+
import { PropertyLocator, PropertyQueryOptions } from './PropertyLocator';
|
|
8
|
+
import { SliceLocator } from './SliceLocator';
|
|
9
|
+
|
|
10
|
+
export class Project {
|
|
11
|
+
private program: ts.Program;
|
|
12
|
+
private typeChecker: ts.TypeChecker;
|
|
13
|
+
|
|
14
|
+
constructor(options: { tsConfigFilePath?: string } = {}) {
|
|
15
|
+
let compilerOptions: ts.CompilerOptions = {};
|
|
16
|
+
const configPath = options.tsConfigFilePath || ts.findConfigFile(
|
|
17
|
+
process.cwd(),
|
|
18
|
+
ts.sys.fileExists,
|
|
19
|
+
'tsconfig.json'
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (configPath) {
|
|
23
|
+
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
24
|
+
const parsedCommandLine = ts.parseJsonConfigFileContent(
|
|
25
|
+
configFile.config,
|
|
26
|
+
ts.sys,
|
|
27
|
+
path.dirname(configPath),
|
|
28
|
+
undefined,
|
|
29
|
+
configPath,
|
|
30
|
+
undefined,
|
|
31
|
+
[
|
|
32
|
+
{ extension: '.vue', isMixedContent: true, scriptKind: ts.ScriptKind.TS },
|
|
33
|
+
{ extension: '.svelte', isMixedContent: true, scriptKind: ts.ScriptKind.TS }
|
|
34
|
+
]
|
|
35
|
+
);
|
|
36
|
+
compilerOptions = parsedCommandLine.options;
|
|
37
|
+
|
|
38
|
+
const host = ts.createCompilerHost(compilerOptions);
|
|
39
|
+
const originalGetSourceFile = host.getSourceFile;
|
|
40
|
+
|
|
41
|
+
let vueCompiler: any;
|
|
42
|
+
try {
|
|
43
|
+
vueCompiler = require('@vue/compiler-sfc');
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// Not a Vue project
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => {
|
|
49
|
+
if (fileName.endsWith('.vue')) {
|
|
50
|
+
const content = ts.sys.readFile(fileName);
|
|
51
|
+
if (content) {
|
|
52
|
+
let scriptContent = '';
|
|
53
|
+
if (vueCompiler) {
|
|
54
|
+
const parsed = vueCompiler.parse(content);
|
|
55
|
+
const script = parsed.descriptor.script;
|
|
56
|
+
const scriptSetup = parsed.descriptor.scriptSetup;
|
|
57
|
+
if (script) scriptContent += script.content + '\n';
|
|
58
|
+
if (scriptSetup) scriptContent += scriptSetup.content + '\n';
|
|
59
|
+
} else {
|
|
60
|
+
// Fallback for Svelte or if compiler-sfc is missing (fragile but works for simple cases)
|
|
61
|
+
const match = content.match(/<script[^>]*>(.*?)<\/script>/s);
|
|
62
|
+
if (match) scriptContent = match[1];
|
|
63
|
+
}
|
|
64
|
+
return ts.createSourceFile(fileName, scriptContent, languageVersion, true, ts.ScriptKind.TS);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (fileName.endsWith('.svelte')) {
|
|
69
|
+
const content = ts.sys.readFile(fileName);
|
|
70
|
+
if (content) {
|
|
71
|
+
let scriptContent = '';
|
|
72
|
+
const match = content.match(/<script[^>]*>(.*?)<\/script>/s);
|
|
73
|
+
if (match) scriptContent = match[1];
|
|
74
|
+
return ts.createSourceFile(fileName, scriptContent, languageVersion, true, ts.ScriptKind.TS);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return originalGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
this.program = ts.createProgram({
|
|
82
|
+
rootNames: parsedCommandLine.fileNames,
|
|
83
|
+
options: compilerOptions,
|
|
84
|
+
host: host
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
throw new Error("Could not find tsconfig.json");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.typeChecker = this.program.getTypeChecker();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getFiles(options?: FileQueryOptions) {
|
|
94
|
+
const sourceFiles = this.program.getSourceFiles().filter(
|
|
95
|
+
sf => !sf.isDeclarationFile && !sf.fileName.includes('node_modules')
|
|
96
|
+
);
|
|
97
|
+
return new FileLocator(sourceFiles, this.program, options);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getClasses(options?: ClassQueryOptions) {
|
|
101
|
+
const sourceFiles = this.program.getSourceFiles().filter(
|
|
102
|
+
sf => !sf.isDeclarationFile && !sf.fileName.includes('node_modules')
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const allClasses: ts.ClassDeclaration[] = [];
|
|
106
|
+
for (const sf of sourceFiles) {
|
|
107
|
+
const visit = (node: ts.Node) => {
|
|
108
|
+
if (ts.isClassDeclaration(node)) {
|
|
109
|
+
allClasses.push(node);
|
|
110
|
+
}
|
|
111
|
+
ts.forEachChild(node, visit);
|
|
112
|
+
};
|
|
113
|
+
visit(sf);
|
|
114
|
+
}
|
|
115
|
+
return new ClassLocator(allClasses, this.program, options);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
layeredArchitecture() {
|
|
119
|
+
const sourceFiles = this.program.getSourceFiles().filter(
|
|
120
|
+
sf => !sf.isDeclarationFile && !sf.fileName.includes('node_modules')
|
|
121
|
+
);
|
|
122
|
+
return new LayeredArchitectureBuilder(sourceFiles, this.program);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
getFunctions(options?: FunctionQueryOptions) {
|
|
126
|
+
const sourceFiles = this.program.getSourceFiles().filter(
|
|
127
|
+
sf => !sf.isDeclarationFile && !sf.fileName.includes('node_modules')
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const allFunctions: ts.FunctionLikeDeclaration[] = [];
|
|
131
|
+
for (const sf of sourceFiles) {
|
|
132
|
+
const visit = (node: ts.Node) => {
|
|
133
|
+
if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isArrowFunction(node)) {
|
|
134
|
+
allFunctions.push(node);
|
|
135
|
+
}
|
|
136
|
+
ts.forEachChild(node, visit);
|
|
137
|
+
};
|
|
138
|
+
visit(sf);
|
|
139
|
+
}
|
|
140
|
+
return new FunctionLocator(allFunctions, this.program, options);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
getProperties(options?: PropertyQueryOptions) {
|
|
144
|
+
const sourceFiles = this.program.getSourceFiles().filter(
|
|
145
|
+
sf => !sf.isDeclarationFile && !sf.fileName.includes('node_modules')
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const allProperties: ts.PropertyDeclaration[] = [];
|
|
149
|
+
for (const sf of sourceFiles) {
|
|
150
|
+
const visit = (node: ts.Node) => {
|
|
151
|
+
if (ts.isPropertyDeclaration(node)) {
|
|
152
|
+
allProperties.push(node);
|
|
153
|
+
}
|
|
154
|
+
ts.forEachChild(node, visit);
|
|
155
|
+
};
|
|
156
|
+
visit(sf);
|
|
157
|
+
}
|
|
158
|
+
return new PropertyLocator(allProperties, this.program, options);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getSlices(pattern: string) {
|
|
162
|
+
const sourceFiles = this.program.getSourceFiles().filter(
|
|
163
|
+
sf => !sf.isDeclarationFile && !sf.fileName.includes('node_modules')
|
|
164
|
+
);
|
|
165
|
+
return new SliceLocator(sourceFiles, this.program, pattern);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseProject(tsConfigFilePath?: string): Project {
|
|
170
|
+
return new Project({ tsConfigFilePath });
|
|
171
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { RuleResult } from './types';
|
|
3
|
+
|
|
4
|
+
export interface PropertyQueryOptions {
|
|
5
|
+
inFolder?: string;
|
|
6
|
+
matchNamePattern?: string | RegExp;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class PropertyLocator {
|
|
10
|
+
public readonly properties: ts.PropertyDeclaration[];
|
|
11
|
+
|
|
12
|
+
constructor(properties: ts.PropertyDeclaration[], private program: ts.Program, options?: PropertyQueryOptions) {
|
|
13
|
+
let filtered = properties;
|
|
14
|
+
|
|
15
|
+
if (options?.inFolder) {
|
|
16
|
+
filtered = filtered.filter((p) => {
|
|
17
|
+
const sourceFile = p.getSourceFile();
|
|
18
|
+
return sourceFile.fileName.includes(`/${options.inFolder}/`) || sourceFile.fileName.includes(`\\${options.inFolder}\\`);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (options?.matchNamePattern) {
|
|
23
|
+
const regex = typeof options.matchNamePattern === 'string' ? new RegExp(options.matchNamePattern) : options.matchNamePattern;
|
|
24
|
+
filtered = filtered.filter((p) => {
|
|
25
|
+
return ts.isIdentifier(p.name) && regex.test(p.name.text);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.properties = filtered;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
checkBeReadonly(isNot: boolean): RuleResult {
|
|
33
|
+
const violations: string[] = [];
|
|
34
|
+
for (const p of this.properties) {
|
|
35
|
+
const name = ts.isIdentifier(p.name) ? p.name.text : 'Anonymous Property';
|
|
36
|
+
const modifiers = ts.canHaveModifiers(p) ? ts.getModifiers(p) : undefined;
|
|
37
|
+
const matches = modifiers && modifiers.some((m: ts.Modifier) => m.kind === ts.SyntaxKind.ReadonlyKeyword);
|
|
38
|
+
|
|
39
|
+
if (isNot && matches) {
|
|
40
|
+
violations.push(`Property ${name} is readonly, but it shouldn't be.`);
|
|
41
|
+
} else if (!isNot && !matches) {
|
|
42
|
+
violations.push(`Property ${name} is not readonly, but it should be.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
pass: violations.length === 0,
|
|
47
|
+
message: () => violations.join('\n'),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|