@eduardbar/drift 1.0.0 → 1.1.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/.github/actions/drift-scan/README.md +61 -0
- package/.github/actions/drift-scan/action.yml +65 -0
- package/.github/workflows/publish-vscode.yml +3 -1
- package/AGENTS.md +53 -11
- package/README.md +68 -1
- package/dist/analyzer.d.ts +6 -2
- package/dist/analyzer.js +116 -3
- package/dist/badge.js +40 -22
- package/dist/ci.js +32 -18
- package/dist/cli.js +83 -5
- package/dist/diff.d.ts +0 -7
- package/dist/diff.js +26 -25
- package/dist/fix.d.ts +4 -0
- package/dist/fix.js +59 -47
- package/dist/git/trend.js +1 -0
- package/dist/git.d.ts +0 -9
- package/dist/git.js +25 -19
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/map.d.ts +3 -0
- package/dist/map.js +103 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +176 -0
- package/dist/plugins.d.ts +6 -0
- package/dist/plugins.js +74 -0
- package/dist/printer.js +20 -0
- package/dist/report.js +34 -0
- package/dist/reporter.js +85 -2
- package/dist/review.d.ts +15 -0
- package/dist/review.js +80 -0
- package/dist/rules/comments.d.ts +4 -0
- package/dist/rules/comments.js +45 -0
- package/dist/rules/complexity.d.ts +4 -0
- package/dist/rules/complexity.js +51 -0
- package/dist/rules/coupling.d.ts +4 -0
- package/dist/rules/coupling.js +19 -0
- package/dist/rules/magic.d.ts +4 -0
- package/dist/rules/magic.js +33 -0
- package/dist/rules/nesting.d.ts +5 -0
- package/dist/rules/nesting.js +82 -0
- package/dist/rules/phase0-basic.js +14 -7
- package/dist/rules/phase1-complexity.d.ts +6 -30
- package/dist/rules/phase1-complexity.js +7 -276
- package/dist/rules/phase2-crossfile.d.ts +0 -4
- package/dist/rules/phase2-crossfile.js +52 -39
- package/dist/rules/phase3-arch.d.ts +0 -8
- package/dist/rules/phase3-arch.js +26 -23
- package/dist/rules/phase3-configurable.d.ts +6 -0
- package/dist/rules/phase3-configurable.js +97 -0
- package/dist/rules/phase8-semantic.d.ts +0 -5
- package/dist/rules/phase8-semantic.js +30 -29
- package/dist/rules/promise.d.ts +4 -0
- package/dist/rules/promise.js +24 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +69 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +208 -0
- package/package.json +1 -1
- package/packages/eslint-plugin-drift/src/index.ts +1 -1
- package/packages/vscode-drift/package.json +1 -1
- package/packages/vscode-drift/src/analyzer.ts +2 -0
- package/packages/vscode-drift/src/extension.ts +87 -63
- package/packages/vscode-drift/src/statusbar.ts +13 -5
- package/packages/vscode-drift/src/treeview.ts +2 -0
- package/src/analyzer.ts +144 -12
- package/src/badge.ts +38 -16
- package/src/ci.ts +38 -17
- package/src/cli.ts +96 -6
- package/src/diff.ts +36 -30
- package/src/fix.ts +77 -53
- package/src/git/trend.ts +3 -2
- package/src/git.ts +31 -22
- package/src/index.ts +16 -1
- package/src/map.ts +117 -0
- package/src/metrics.ts +200 -0
- package/src/plugins.ts +76 -0
- package/src/printer.ts +20 -0
- package/src/report.ts +35 -0
- package/src/reporter.ts +95 -2
- package/src/review.ts +98 -0
- package/src/rules/comments.ts +56 -0
- package/src/rules/complexity.ts +57 -0
- package/src/rules/coupling.ts +23 -0
- package/src/rules/magic.ts +38 -0
- package/src/rules/nesting.ts +88 -0
- package/src/rules/phase0-basic.ts +14 -7
- package/src/rules/phase1-complexity.ts +8 -302
- package/src/rules/phase2-crossfile.ts +68 -40
- package/src/rules/phase3-arch.ts +34 -30
- package/src/rules/phase3-configurable.ts +132 -0
- package/src/rules/phase8-semantic.ts +33 -29
- package/src/rules/promise.ts +29 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +75 -1
- package/src/utils.ts +3 -1
- package/tests/new-features.test.ts +153 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
1
2
|
import * as fs from 'node:fs';
|
|
2
3
|
import * as path from 'node:path';
|
|
4
|
+
const SNIPPET_LENGTH = 80;
|
|
5
|
+
// drift-ignore
|
|
6
|
+
const BIN_DIR = '/bin/';
|
|
3
7
|
/**
|
|
4
8
|
* Detect files that are never imported by any other file in the project.
|
|
5
9
|
* Entry-point files (index, main, cli, app, bin/) are excluded.
|
|
@@ -9,7 +13,7 @@ export function detectDeadFiles(sourceFiles, allImportedPaths, ruleWeights) {
|
|
|
9
13
|
for (const sf of sourceFiles) {
|
|
10
14
|
const sfPath = sf.getFilePath();
|
|
11
15
|
const basename = path.basename(sfPath);
|
|
12
|
-
const isBinFile = sfPath.replace(/\\/g, '/').includes(
|
|
16
|
+
const isBinFile = sfPath.replace(/\\/g, '/').includes(BIN_DIR);
|
|
13
17
|
const isEntryPoint = /^(index|main|cli|app)\.(ts|tsx|js|jsx)$/.test(basename) || isBinFile;
|
|
14
18
|
if (!isEntryPoint && !allImportedPaths.has(sfPath)) {
|
|
15
19
|
issues.set(sfPath, {
|
|
@@ -28,6 +32,49 @@ export function detectDeadFiles(sourceFiles, allImportedPaths, ruleWeights) {
|
|
|
28
32
|
* Detect named exports that are never imported by any other file.
|
|
29
33
|
* Barrel files (index.*) are excluded since their entire surface is the public API.
|
|
30
34
|
*/
|
|
35
|
+
function checkExportDeclarations(sf, sfPath, importedNamesForFile, ruleWeights) {
|
|
36
|
+
const issues = [];
|
|
37
|
+
for (const exportDecl of sf.getExportDeclarations()) {
|
|
38
|
+
for (const namedExport of exportDecl.getNamedExports()) {
|
|
39
|
+
const name = namedExport.getName();
|
|
40
|
+
if (!importedNamesForFile?.has(name)) {
|
|
41
|
+
issues.push({
|
|
42
|
+
rule: 'unused-export',
|
|
43
|
+
severity: ruleWeights['unused-export'].severity,
|
|
44
|
+
message: `'${name}' is exported but never imported`,
|
|
45
|
+
line: namedExport.getStartLineNumber(),
|
|
46
|
+
column: 1,
|
|
47
|
+
snippet: namedExport.getText().slice(0, SNIPPET_LENGTH),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return issues;
|
|
53
|
+
}
|
|
54
|
+
function checkInlineExports(sf, sfPath, importedNamesForFile, ruleWeights) {
|
|
55
|
+
const issues = [];
|
|
56
|
+
for (const exportSymbol of sf.getExportedDeclarations()) {
|
|
57
|
+
const [exportName, declarations] = [exportSymbol[0], exportSymbol[1]];
|
|
58
|
+
if (exportName === 'default')
|
|
59
|
+
continue;
|
|
60
|
+
if (importedNamesForFile?.has(exportName))
|
|
61
|
+
continue;
|
|
62
|
+
for (const decl of declarations) {
|
|
63
|
+
if (decl.getSourceFile().getFilePath() !== sfPath)
|
|
64
|
+
continue;
|
|
65
|
+
issues.push({
|
|
66
|
+
rule: 'unused-export',
|
|
67
|
+
severity: ruleWeights['unused-export'].severity,
|
|
68
|
+
message: `'${exportName}' is exported but never imported`,
|
|
69
|
+
line: decl.getStartLineNumber(),
|
|
70
|
+
column: 1,
|
|
71
|
+
snippet: decl.getText().split('\n')[0].slice(0, SNIPPET_LENGTH),
|
|
72
|
+
});
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return issues;
|
|
77
|
+
}
|
|
31
78
|
export function detectUnusedExports(sourceFiles, allImportedNames, ruleWeights) {
|
|
32
79
|
const result = new Map();
|
|
33
80
|
for (const sf of sourceFiles) {
|
|
@@ -38,44 +85,10 @@ export function detectUnusedExports(sourceFiles, allImportedNames, ruleWeights)
|
|
|
38
85
|
const hasNamespaceImport = importedNamesForFile?.has('*') ?? false;
|
|
39
86
|
if (isBarrel || hasNamespaceImport)
|
|
40
87
|
continue;
|
|
41
|
-
const issues = [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!importedNamesForFile?.has(name)) {
|
|
46
|
-
issues.push({
|
|
47
|
-
rule: 'unused-export',
|
|
48
|
-
severity: ruleWeights['unused-export'].severity,
|
|
49
|
-
message: `'${name}' is exported but never imported`,
|
|
50
|
-
line: namedExport.getStartLineNumber(),
|
|
51
|
-
column: 1,
|
|
52
|
-
snippet: namedExport.getText().slice(0, 80),
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
// Also check inline export declarations (export function foo, export const bar)
|
|
58
|
-
for (const exportSymbol of sf.getExportedDeclarations()) {
|
|
59
|
-
const [exportName, declarations] = [exportSymbol[0], exportSymbol[1]];
|
|
60
|
-
if (exportName === 'default')
|
|
61
|
-
continue;
|
|
62
|
-
if (importedNamesForFile?.has(exportName))
|
|
63
|
-
continue;
|
|
64
|
-
for (const decl of declarations) {
|
|
65
|
-
// Skip if this is a re-export from another file
|
|
66
|
-
if (decl.getSourceFile().getFilePath() !== sfPath)
|
|
67
|
-
continue;
|
|
68
|
-
issues.push({
|
|
69
|
-
rule: 'unused-export',
|
|
70
|
-
severity: ruleWeights['unused-export'].severity,
|
|
71
|
-
message: `'${exportName}' is exported but never imported`,
|
|
72
|
-
line: decl.getStartLineNumber(),
|
|
73
|
-
column: 1,
|
|
74
|
-
snippet: decl.getText().split('\n')[0].slice(0, 80),
|
|
75
|
-
});
|
|
76
|
-
break; // one issue per export name is enough
|
|
77
|
-
}
|
|
78
|
-
}
|
|
88
|
+
const issues = [
|
|
89
|
+
...checkExportDeclarations(sf, sfPath, importedNamesForFile, ruleWeights),
|
|
90
|
+
...checkInlineExports(sf, sfPath, importedNamesForFile, ruleWeights),
|
|
91
|
+
];
|
|
79
92
|
if (issues.length > 0) {
|
|
80
93
|
result.set(sfPath, issues);
|
|
81
94
|
}
|
|
@@ -12,18 +12,10 @@ export declare function detectCircularDependencies(importGraph: Map<string, Set<
|
|
|
12
12
|
severity: DriftIssue['severity'];
|
|
13
13
|
weight: number;
|
|
14
14
|
}>): Map<string, DriftIssue>;
|
|
15
|
-
/**
|
|
16
|
-
* Detect layer violations based on user-defined layer configuration.
|
|
17
|
-
* Returns a map of filePath → issues[].
|
|
18
|
-
*/
|
|
19
15
|
export declare function detectLayerViolations(importGraph: Map<string, Set<string>>, layers: LayerDefinition[], targetPath: string, ruleWeights: Record<string, {
|
|
20
16
|
severity: DriftIssue['severity'];
|
|
21
17
|
weight: number;
|
|
22
18
|
}>): Map<string, DriftIssue[]>;
|
|
23
|
-
/**
|
|
24
|
-
* Detect cross-boundary imports based on user-defined module boundary configuration.
|
|
25
|
-
* Returns a map of filePath → issues[].
|
|
26
|
-
*/
|
|
27
19
|
export declare function detectCrossBoundaryImports(importGraph: Map<string, Set<string>>, modules: ModuleBoundary[], targetPath: string, ruleWeights: Record<string, {
|
|
28
20
|
severity: DriftIssue['severity'];
|
|
29
21
|
weight: number;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
1
2
|
import * as path from 'node:path';
|
|
2
3
|
/**
|
|
3
4
|
* DFS cycle detection in a directed import graph.
|
|
@@ -66,26 +67,26 @@ export function detectCircularDependencies(importGraph, ruleWeights) {
|
|
|
66
67
|
* Detect layer violations based on user-defined layer configuration.
|
|
67
68
|
* Returns a map of filePath → issues[].
|
|
68
69
|
*/
|
|
70
|
+
function matchLayer(filePath, layers) {
|
|
71
|
+
const rel = filePath.replace(/\\/g, '/');
|
|
72
|
+
return layers.find(layer => layer.patterns.some(pattern => {
|
|
73
|
+
const regexStr = pattern
|
|
74
|
+
.replace(/\\/g, '/')
|
|
75
|
+
.replace(/[.+^${}()|[\]]/g, '\\$&')
|
|
76
|
+
.replace(/\*\*/g, '###DOUBLESTAR###')
|
|
77
|
+
.replace(/\*/g, '[^/]*')
|
|
78
|
+
.replace(/###DOUBLESTAR###/g, '.*');
|
|
79
|
+
return new RegExp(`^${regexStr}`).test(rel);
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
69
82
|
export function detectLayerViolations(importGraph, layers, targetPath, ruleWeights) {
|
|
70
83
|
const result = new Map();
|
|
71
|
-
function getLayer(filePath) {
|
|
72
|
-
const rel = filePath.replace(/\\/g, '/');
|
|
73
|
-
return layers.find(layer => layer.patterns.some(pattern => {
|
|
74
|
-
const regexStr = pattern
|
|
75
|
-
.replace(/\\/g, '/')
|
|
76
|
-
.replace(/[.+^${}()|[\]]/g, '\\$&')
|
|
77
|
-
.replace(/\*\*/g, '###DOUBLESTAR###')
|
|
78
|
-
.replace(/\*/g, '[^/]*')
|
|
79
|
-
.replace(/###DOUBLESTAR###/g, '.*');
|
|
80
|
-
return new RegExp(`^${regexStr}`).test(rel);
|
|
81
|
-
}));
|
|
82
|
-
}
|
|
83
84
|
for (const [filePath, imports] of importGraph.entries()) {
|
|
84
|
-
const fileLayer =
|
|
85
|
+
const fileLayer = matchLayer(filePath, layers);
|
|
85
86
|
if (!fileLayer)
|
|
86
87
|
continue;
|
|
87
88
|
for (const importedPath of imports) {
|
|
88
|
-
const importedLayer =
|
|
89
|
+
const importedLayer = matchLayer(importedPath, layers);
|
|
89
90
|
if (!importedLayer)
|
|
90
91
|
continue;
|
|
91
92
|
if (importedLayer.name === fileLayer.name)
|
|
@@ -110,26 +111,28 @@ export function detectLayerViolations(importGraph, layers, targetPath, ruleWeigh
|
|
|
110
111
|
* Detect cross-boundary imports based on user-defined module boundary configuration.
|
|
111
112
|
* Returns a map of filePath → issues[].
|
|
112
113
|
*/
|
|
114
|
+
function matchModule(filePath, modules) {
|
|
115
|
+
const rel = filePath.replace(/\\/g, '/');
|
|
116
|
+
return modules.find(m => rel.startsWith(m.root.replace(/\\/g, '/')));
|
|
117
|
+
}
|
|
118
|
+
function isAllowedImport(importedPath, allowedImports) {
|
|
119
|
+
const relImported = importedPath.replace(/\\/g, '/');
|
|
120
|
+
return allowedImports.some(allowed => relImported.startsWith(allowed.replace(/\\/g, '/')));
|
|
121
|
+
}
|
|
113
122
|
export function detectCrossBoundaryImports(importGraph, modules, targetPath, ruleWeights) {
|
|
114
123
|
const result = new Map();
|
|
115
|
-
function getModule(filePath) {
|
|
116
|
-
const rel = filePath.replace(/\\/g, '/');
|
|
117
|
-
return modules.find(m => rel.startsWith(m.root.replace(/\\/g, '/')));
|
|
118
|
-
}
|
|
119
124
|
for (const [filePath, imports] of importGraph.entries()) {
|
|
120
|
-
const fileModule =
|
|
125
|
+
const fileModule = matchModule(filePath, modules);
|
|
121
126
|
if (!fileModule)
|
|
122
127
|
continue;
|
|
123
128
|
for (const importedPath of imports) {
|
|
124
|
-
const importedModule =
|
|
129
|
+
const importedModule = matchModule(importedPath, modules);
|
|
125
130
|
if (!importedModule)
|
|
126
131
|
continue;
|
|
127
132
|
if (importedModule.name === fileModule.name)
|
|
128
133
|
continue;
|
|
129
134
|
const allowedImports = fileModule.allowedExternalImports ?? [];
|
|
130
|
-
|
|
131
|
-
const isAllowed = allowedImports.some(allowed => relImported.startsWith(allowed.replace(/\\/g, '/')));
|
|
132
|
-
if (!isAllowed) {
|
|
135
|
+
if (!isAllowedImport(importedPath, allowedImports)) {
|
|
133
136
|
if (!result.has(filePath))
|
|
134
137
|
result.set(filePath, []);
|
|
135
138
|
result.get(filePath).push({
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type SourceFile } from 'ts-morph';
|
|
2
|
+
import type { DriftConfig, DriftIssue } from '../types.js';
|
|
3
|
+
export declare function detectControllerNoDb(file: SourceFile, config?: DriftConfig): DriftIssue[];
|
|
4
|
+
export declare function detectServiceNoHttp(file: SourceFile, config?: DriftConfig): DriftIssue[];
|
|
5
|
+
export declare function detectMaxFunctionLines(file: SourceFile, config?: DriftConfig): DriftIssue[];
|
|
6
|
+
//# sourceMappingURL=phase3-configurable.d.ts.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { SyntaxKind } from 'ts-morph';
|
|
2
|
+
const DB_IMPORT_PATTERNS = [
|
|
3
|
+
/\bprisma\b/i,
|
|
4
|
+
/\btypeorm\b/i,
|
|
5
|
+
/\bsequelize\b/i,
|
|
6
|
+
/\bmongoose\b/i,
|
|
7
|
+
/\bknex\b/i,
|
|
8
|
+
/\brepository\b/i,
|
|
9
|
+
/\/db\//i,
|
|
10
|
+
/\/database\//i,
|
|
11
|
+
];
|
|
12
|
+
const HTTP_IMPORT_PATTERNS = [
|
|
13
|
+
/\bexpress\b/i,
|
|
14
|
+
/\bfastify\b/i,
|
|
15
|
+
/\bkoa\b/i,
|
|
16
|
+
/\bhono\b/i,
|
|
17
|
+
/^http$/i,
|
|
18
|
+
/^https$/i,
|
|
19
|
+
];
|
|
20
|
+
function isControllerFile(filePath) {
|
|
21
|
+
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
|
22
|
+
return normalized.includes('/controller/') || normalized.includes('/controllers/') || normalized.endsWith('controller.ts') || normalized.endsWith('controller.js');
|
|
23
|
+
}
|
|
24
|
+
function isServiceFile(filePath) {
|
|
25
|
+
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
|
26
|
+
return normalized.includes('/service/') || normalized.includes('/services/') || normalized.endsWith('service.ts') || normalized.endsWith('service.js');
|
|
27
|
+
}
|
|
28
|
+
function createIssue(rule, message, line, snippet) {
|
|
29
|
+
return {
|
|
30
|
+
rule,
|
|
31
|
+
severity: 'warning',
|
|
32
|
+
message,
|
|
33
|
+
line,
|
|
34
|
+
column: 1,
|
|
35
|
+
snippet,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function detectControllerNoDb(file, config) {
|
|
39
|
+
if (!config?.architectureRules?.controllerNoDb)
|
|
40
|
+
return [];
|
|
41
|
+
if (!isControllerFile(file.getFilePath()))
|
|
42
|
+
return [];
|
|
43
|
+
const issues = [];
|
|
44
|
+
for (const decl of file.getImportDeclarations()) {
|
|
45
|
+
const value = decl.getModuleSpecifierValue();
|
|
46
|
+
if (DB_IMPORT_PATTERNS.some((pattern) => pattern.test(value))) {
|
|
47
|
+
issues.push(createIssue('controller-no-db', `Controller imports database module '${value}'. Controllers should delegate persistence through services.`, decl.getStartLineNumber(), value));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return issues;
|
|
51
|
+
}
|
|
52
|
+
export function detectServiceNoHttp(file, config) {
|
|
53
|
+
if (!config?.architectureRules?.serviceNoHttp)
|
|
54
|
+
return [];
|
|
55
|
+
if (!isServiceFile(file.getFilePath()))
|
|
56
|
+
return [];
|
|
57
|
+
const issues = [];
|
|
58
|
+
for (const decl of file.getImportDeclarations()) {
|
|
59
|
+
const value = decl.getModuleSpecifierValue();
|
|
60
|
+
if (HTTP_IMPORT_PATTERNS.some((pattern) => pattern.test(value))) {
|
|
61
|
+
issues.push(createIssue('service-no-http', `Service imports HTTP framework '${value}'. Keep transport concerns outside service layer.`, decl.getStartLineNumber(), value));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
65
|
+
const expressionText = call.getExpression().getText();
|
|
66
|
+
if (/\bfetch\b/.test(expressionText)) {
|
|
67
|
+
issues.push(createIssue('service-no-http', 'Service executes HTTP call directly (fetch). Move this to an adapter/client.', call.getStartLineNumber(), expressionText));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return issues;
|
|
71
|
+
}
|
|
72
|
+
export function detectMaxFunctionLines(file, config) {
|
|
73
|
+
const maxLines = config?.architectureRules?.maxFunctionLines;
|
|
74
|
+
if (!maxLines || maxLines <= 0)
|
|
75
|
+
return [];
|
|
76
|
+
const issues = [];
|
|
77
|
+
for (const fn of file.getFunctions()) {
|
|
78
|
+
const body = fn.getBody();
|
|
79
|
+
if (!body)
|
|
80
|
+
continue;
|
|
81
|
+
const lines = body.getEndLineNumber() - body.getStartLineNumber() - 1;
|
|
82
|
+
if (lines > maxLines) {
|
|
83
|
+
issues.push(createIssue('max-function-lines', `Function '${fn.getName() ?? '(anonymous)'}' has ${lines} lines (max: ${maxLines}).`, fn.getStartLineNumber(), fn.getName() ?? '(anonymous)'));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const method of file.getDescendantsOfKind(SyntaxKind.MethodDeclaration)) {
|
|
87
|
+
const body = method.getBody();
|
|
88
|
+
if (!body)
|
|
89
|
+
continue;
|
|
90
|
+
const lines = body.getEndLineNumber() - body.getStartLineNumber() - 1;
|
|
91
|
+
if (lines > maxLines) {
|
|
92
|
+
issues.push(createIssue('max-function-lines', `Method '${method.getName()}' has ${lines} lines (max: ${maxLines}).`, method.getStartLineNumber(), method.getName()));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return issues;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=phase3-configurable.js.map
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { SourceFile, FunctionDeclaration, ArrowFunction, FunctionExpression, MethodDeclaration } from 'ts-morph';
|
|
2
2
|
import type { DriftIssue } from '../types.js';
|
|
3
3
|
export type FunctionLikeNode = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration;
|
|
4
|
-
/** Normalize a function body to a canonical string (Type-2 clone detection).
|
|
5
|
-
* Variable names, parameter names, and numeric/string literals are replaced
|
|
6
|
-
* with canonical tokens so that two functions with identical logic but
|
|
7
|
-
* different identifiers produce the same fingerprint.
|
|
8
|
-
*/
|
|
9
4
|
export declare function normalizeFunctionBody(fn: FunctionLikeNode): string;
|
|
10
5
|
/** Return a SHA-256 fingerprint for a function body (normalized). */
|
|
11
6
|
export declare function fingerprintFunction(fn: FunctionLikeNode): string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
1
2
|
import * as crypto from 'node:crypto';
|
|
2
3
|
import { SyntaxKind, } from 'ts-morph';
|
|
3
4
|
/** Normalize a function body to a canonical string (Type-2 clone detection).
|
|
@@ -5,21 +6,17 @@ import { SyntaxKind, } from 'ts-morph';
|
|
|
5
6
|
* with canonical tokens so that two functions with identical logic but
|
|
6
7
|
* different identifiers produce the same fingerprint.
|
|
7
8
|
*/
|
|
8
|
-
|
|
9
|
-
// Build a substitution map: localName → canonical token
|
|
9
|
+
function buildSubstitutionMap(fn) {
|
|
10
10
|
const subst = new Map();
|
|
11
|
-
// Map parameters first
|
|
12
11
|
for (const [i, param] of fn.getParameters().entries()) {
|
|
13
12
|
const name = param.getName();
|
|
14
13
|
if (name && name !== '_')
|
|
15
14
|
subst.set(name, `P${i}`);
|
|
16
15
|
}
|
|
17
|
-
// Map locally declared variables (VariableDeclaration)
|
|
18
16
|
let varIdx = 0;
|
|
19
17
|
fn.forEachDescendant(node => {
|
|
20
18
|
if (node.getKind() === SyntaxKind.VariableDeclaration) {
|
|
21
19
|
const nameNode = node.getNameNode();
|
|
22
|
-
// Support destructuring — getNameNode() may be a BindingPattern
|
|
23
20
|
if (nameNode.getKind() === SyntaxKind.Identifier) {
|
|
24
21
|
const name = nameNode.getText();
|
|
25
22
|
if (!subst.has(name))
|
|
@@ -27,35 +24,39 @@ export function normalizeFunctionBody(fn) {
|
|
|
27
24
|
}
|
|
28
25
|
}
|
|
29
26
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return 'NL';
|
|
39
|
-
case SyntaxKind.StringLiteral:
|
|
40
|
-
case SyntaxKind.NoSubstitutionTemplateLiteral:
|
|
41
|
-
return 'SL';
|
|
42
|
-
case SyntaxKind.TrueKeyword:
|
|
43
|
-
return 'TRUE';
|
|
44
|
-
case SyntaxKind.FalseKeyword:
|
|
45
|
-
return 'FALSE';
|
|
46
|
-
case SyntaxKind.NullKeyword:
|
|
47
|
-
return 'NULL';
|
|
27
|
+
return subst;
|
|
28
|
+
}
|
|
29
|
+
function serializeNode(node, subst) {
|
|
30
|
+
const kind = node.getKindName();
|
|
31
|
+
switch (node.getKind()) {
|
|
32
|
+
case SyntaxKind.Identifier: {
|
|
33
|
+
const text = node.getText();
|
|
34
|
+
return subst.get(text) ?? text;
|
|
48
35
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
36
|
+
case SyntaxKind.NumericLiteral:
|
|
37
|
+
return 'NL';
|
|
38
|
+
case SyntaxKind.StringLiteral:
|
|
39
|
+
case SyntaxKind.NoSubstitutionTemplateLiteral:
|
|
40
|
+
return 'SL';
|
|
41
|
+
case SyntaxKind.TrueKeyword:
|
|
42
|
+
return 'TRUE';
|
|
43
|
+
case SyntaxKind.FalseKeyword:
|
|
44
|
+
return 'FALSE';
|
|
45
|
+
case SyntaxKind.NullKeyword:
|
|
46
|
+
return 'NULL';
|
|
54
47
|
}
|
|
48
|
+
const children = node.getChildren();
|
|
49
|
+
if (children.length === 0)
|
|
50
|
+
return kind;
|
|
51
|
+
const childStr = children.map(c => serializeNode(c, subst)).join('|');
|
|
52
|
+
return `${kind}(${childStr})`;
|
|
53
|
+
}
|
|
54
|
+
export function normalizeFunctionBody(fn) {
|
|
55
|
+
const subst = buildSubstitutionMap(fn);
|
|
55
56
|
const body = fn.getBody();
|
|
56
57
|
if (!body)
|
|
57
58
|
return '';
|
|
58
|
-
return serializeNode(body);
|
|
59
|
+
return serializeNode(body, subst);
|
|
59
60
|
}
|
|
60
61
|
/** Return a SHA-256 fingerprint for a function body (normalized). */
|
|
61
62
|
export function fingerprintFunction(fn) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SyntaxKind } from 'ts-morph';
|
|
2
|
+
export function detectPromiseStyleMix(file) {
|
|
3
|
+
const text = file.getFullText();
|
|
4
|
+
const hasThen = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).some((node) => {
|
|
5
|
+
const name = node.getName();
|
|
6
|
+
return name === 'then' || name === 'catch';
|
|
7
|
+
});
|
|
8
|
+
const hasAsync = file.getDescendantsOfKind(SyntaxKind.AsyncKeyword).length > 0 ||
|
|
9
|
+
/\bawait\b/.test(text);
|
|
10
|
+
if (hasThen && hasAsync) {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
rule: 'promise-style-mix',
|
|
14
|
+
severity: 'warning',
|
|
15
|
+
message: `File mixes async/await with .then()/.catch(). AI generates both styles without picking one.`,
|
|
16
|
+
line: 1,
|
|
17
|
+
column: 1,
|
|
18
|
+
snippet: `// mixed promise styles detected`,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=promise.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DriftReport } from './types.js';
|
|
2
|
+
export interface SnapshotEntry {
|
|
3
|
+
timestamp: string;
|
|
4
|
+
label: string;
|
|
5
|
+
score: number;
|
|
6
|
+
grade: string;
|
|
7
|
+
totalIssues: number;
|
|
8
|
+
files: number;
|
|
9
|
+
byRule: Record<string, number>;
|
|
10
|
+
}
|
|
11
|
+
export interface SnapshotHistory {
|
|
12
|
+
project: string;
|
|
13
|
+
snapshots: SnapshotEntry[];
|
|
14
|
+
}
|
|
15
|
+
export declare function loadHistory(targetPath: string): SnapshotHistory;
|
|
16
|
+
export declare function saveSnapshot(targetPath: string, report: DriftReport, label?: string): SnapshotEntry;
|
|
17
|
+
export declare function printHistory(history: SnapshotHistory): void;
|
|
18
|
+
export declare function printSnapshotDiff(history: SnapshotHistory, currentScore: number): void;
|
|
19
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import kleur from 'kleur';
|
|
4
|
+
import { scoreToGradeText } from './utils.js';
|
|
5
|
+
const HISTORY_FILE = 'drift-history.json';
|
|
6
|
+
const HEADER_PAD = {
|
|
7
|
+
INDEX: 4,
|
|
8
|
+
DATE: 26,
|
|
9
|
+
LABEL: 20,
|
|
10
|
+
SCORE: 8,
|
|
11
|
+
GRADE: 12,
|
|
12
|
+
ISSUES: 8,
|
|
13
|
+
DELTA: 6,
|
|
14
|
+
};
|
|
15
|
+
const GRADE_THRESHOLDS = {
|
|
16
|
+
LOW: 20,
|
|
17
|
+
MODERATE: 45,
|
|
18
|
+
HIGH: 70,
|
|
19
|
+
};
|
|
20
|
+
export function loadHistory(targetPath) {
|
|
21
|
+
const filePath = path.join(targetPath, HISTORY_FILE);
|
|
22
|
+
if (fs.existsSync(filePath)) {
|
|
23
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
return { project: targetPath, snapshots: [] };
|
|
26
|
+
}
|
|
27
|
+
export function saveSnapshot(targetPath, report, label) {
|
|
28
|
+
const history = loadHistory(targetPath);
|
|
29
|
+
const entry = {
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
label: label ?? '',
|
|
32
|
+
score: report.totalScore,
|
|
33
|
+
grade: scoreToGradeText(report.totalScore).label.toUpperCase(),
|
|
34
|
+
totalIssues: report.totalIssues,
|
|
35
|
+
files: report.totalFiles,
|
|
36
|
+
byRule: { ...report.summary.byRule },
|
|
37
|
+
};
|
|
38
|
+
history.snapshots.push(entry);
|
|
39
|
+
const filePath = path.join(targetPath, HISTORY_FILE);
|
|
40
|
+
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf8');
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
function formatDelta(current, prev) {
|
|
44
|
+
if (!prev)
|
|
45
|
+
return '—';
|
|
46
|
+
const delta = current.score - prev.score;
|
|
47
|
+
if (delta > 0)
|
|
48
|
+
return kleur.red(`+${delta}`);
|
|
49
|
+
if (delta < 0)
|
|
50
|
+
return kleur.green(String(delta));
|
|
51
|
+
return kleur.gray('0');
|
|
52
|
+
}
|
|
53
|
+
export function printHistory(history) {
|
|
54
|
+
const { snapshots } = history;
|
|
55
|
+
if (snapshots.length === 0) {
|
|
56
|
+
process.stdout.write('\n No snapshots recorded yet.\n\n');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
process.stdout.write('\n');
|
|
60
|
+
process.stdout.write(kleur.bold(` ${'#'.padEnd(HEADER_PAD.INDEX)} ${'Date'.padEnd(HEADER_PAD.DATE)} ${'Label'.padEnd(HEADER_PAD.LABEL)} ${'Score'.padEnd(HEADER_PAD.SCORE)} ${'Grade'.padEnd(HEADER_PAD.GRADE)} ${'Issues'.padEnd(HEADER_PAD.ISSUES)} ${'Delta'}\n`));
|
|
61
|
+
process.stdout.write(` ${'─'.repeat(HEADER_PAD.INDEX)} ${'─'.repeat(HEADER_PAD.DATE)} ${'─'.repeat(HEADER_PAD.LABEL)} ${'─'.repeat(HEADER_PAD.SCORE)} ${'─'.repeat(HEADER_PAD.GRADE)} ${'─'.repeat(HEADER_PAD.ISSUES)} ${'─'.repeat(HEADER_PAD.DELTA)}\n`);
|
|
62
|
+
for (let i = 0; i < snapshots.length; i++) {
|
|
63
|
+
const s = snapshots[i];
|
|
64
|
+
const date = new Date(s.timestamp).toLocaleString('en-US', {
|
|
65
|
+
year: 'numeric',
|
|
66
|
+
month: 'short',
|
|
67
|
+
day: '2-digit',
|
|
68
|
+
hour: '2-digit',
|
|
69
|
+
minute: '2-digit',
|
|
70
|
+
});
|
|
71
|
+
const deltaStr = formatDelta(s, i > 0 ? snapshots[i - 1] : null);
|
|
72
|
+
const gradeColored = colorGrade(s.grade, s.score);
|
|
73
|
+
process.stdout.write(` ${String(i + 1).padEnd(HEADER_PAD.INDEX)} ${date.padEnd(HEADER_PAD.DATE)} ${(s.label || '—').padEnd(HEADER_PAD.LABEL)} ${String(s.score).padEnd(HEADER_PAD.SCORE)} ${gradeColored.padEnd(HEADER_PAD.GRADE)} ${String(s.totalIssues).padEnd(HEADER_PAD.ISSUES)} ${deltaStr}\n`);
|
|
74
|
+
}
|
|
75
|
+
process.stdout.write('\n');
|
|
76
|
+
}
|
|
77
|
+
export function printSnapshotDiff(history, currentScore) {
|
|
78
|
+
const { snapshots } = history;
|
|
79
|
+
if (snapshots.length === 0) {
|
|
80
|
+
process.stdout.write('\n No previous snapshot to compare against.\n\n');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const last = snapshots[snapshots.length - 1];
|
|
84
|
+
const delta = currentScore - last.score;
|
|
85
|
+
const lastDate = new Date(last.timestamp).toLocaleString('en-US', {
|
|
86
|
+
year: 'numeric',
|
|
87
|
+
month: 'short',
|
|
88
|
+
day: '2-digit',
|
|
89
|
+
hour: '2-digit',
|
|
90
|
+
minute: '2-digit',
|
|
91
|
+
});
|
|
92
|
+
const lastLabel = last.label ? ` (${last.label})` : '';
|
|
93
|
+
process.stdout.write('\n');
|
|
94
|
+
process.stdout.write(` Last snapshot: ${kleur.bold(lastDate)}${lastLabel} — score ${kleur.bold(String(last.score))}\n`);
|
|
95
|
+
process.stdout.write(` Current score: ${kleur.bold(String(currentScore))}\n`);
|
|
96
|
+
process.stdout.write('\n');
|
|
97
|
+
if (delta > 0) {
|
|
98
|
+
process.stdout.write(` Delta: ${kleur.bold().red(`+${delta}`)} — technical debt increased\n`);
|
|
99
|
+
}
|
|
100
|
+
else if (delta < 0) {
|
|
101
|
+
process.stdout.write(` Delta: ${kleur.bold().green(String(delta))} — technical debt decreased\n`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
process.stdout.write(` Delta: ${kleur.gray('0')} — no change since last snapshot\n`);
|
|
105
|
+
}
|
|
106
|
+
process.stdout.write('\n');
|
|
107
|
+
}
|
|
108
|
+
function colorGrade(grade, score) {
|
|
109
|
+
if (score === 0)
|
|
110
|
+
return kleur.green(grade);
|
|
111
|
+
if (score < GRADE_THRESHOLDS.LOW)
|
|
112
|
+
return kleur.green(grade);
|
|
113
|
+
if (score < GRADE_THRESHOLDS.MODERATE)
|
|
114
|
+
return kleur.yellow(grade);
|
|
115
|
+
if (score < GRADE_THRESHOLDS.HIGH)
|
|
116
|
+
return kleur.red(grade);
|
|
117
|
+
return kleur.bold().red(grade);
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=snapshot.js.map
|