@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
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
const ARCH_RULES = new Set([
|
|
5
|
+
'circular-dependency',
|
|
6
|
+
'layer-violation',
|
|
7
|
+
'cross-boundary-import',
|
|
8
|
+
'controller-no-db',
|
|
9
|
+
'service-no-http',
|
|
10
|
+
]);
|
|
11
|
+
const COMPLEXITY_RULES = new Set([
|
|
12
|
+
'large-file',
|
|
13
|
+
'large-function',
|
|
14
|
+
'high-complexity',
|
|
15
|
+
'deep-nesting',
|
|
16
|
+
'too-many-params',
|
|
17
|
+
'max-function-lines',
|
|
18
|
+
]);
|
|
19
|
+
const AI_RULES = new Set([
|
|
20
|
+
'over-commented',
|
|
21
|
+
'hardcoded-config',
|
|
22
|
+
'inconsistent-error-handling',
|
|
23
|
+
'unnecessary-abstraction',
|
|
24
|
+
'naming-inconsistency',
|
|
25
|
+
'comment-contradiction',
|
|
26
|
+
'ai-code-smell',
|
|
27
|
+
]);
|
|
28
|
+
function clamp(value, min, max) {
|
|
29
|
+
return Math.max(min, Math.min(max, value));
|
|
30
|
+
}
|
|
31
|
+
function listFilesRecursively(root) {
|
|
32
|
+
if (!existsSync(root))
|
|
33
|
+
return [];
|
|
34
|
+
const out = [];
|
|
35
|
+
const stack = [root];
|
|
36
|
+
while (stack.length > 0) {
|
|
37
|
+
const current = stack.pop();
|
|
38
|
+
const entries = readdirSync(current);
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const full = join(current, entry);
|
|
41
|
+
const stat = statSync(full);
|
|
42
|
+
if (stat.isDirectory()) {
|
|
43
|
+
if (entry === 'node_modules' || entry === 'dist' || entry === '.git' || entry === '.next' || entry === 'build') {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
stack.push(full);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
out.push(full);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function hasNearbyTest(targetPath, filePath) {
|
|
56
|
+
const rel = relative(targetPath, filePath).replace(/\\/g, '/');
|
|
57
|
+
const noExt = rel.replace(/\.[^.]+$/, '');
|
|
58
|
+
const candidates = [
|
|
59
|
+
`${noExt}.test.ts`,
|
|
60
|
+
`${noExt}.test.tsx`,
|
|
61
|
+
`${noExt}.spec.ts`,
|
|
62
|
+
`${noExt}.spec.tsx`,
|
|
63
|
+
`${noExt}.test.js`,
|
|
64
|
+
`${noExt}.spec.js`,
|
|
65
|
+
];
|
|
66
|
+
return candidates.some((candidate) => existsSync(join(targetPath, candidate)));
|
|
67
|
+
}
|
|
68
|
+
function getCommitTouchCount(targetPath, filePath) {
|
|
69
|
+
try {
|
|
70
|
+
const rel = relative(targetPath, filePath).replace(/\\/g, '/');
|
|
71
|
+
const output = execSync(`git rev-list --count HEAD -- "${rel}"`, {
|
|
72
|
+
cwd: targetPath,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
75
|
+
}).trim();
|
|
76
|
+
return Number(output) || 0;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function qualityFromIssues(totalFiles, issues, rules) {
|
|
83
|
+
const count = issues.filter((issue) => rules.has(issue.rule)).length;
|
|
84
|
+
if (totalFiles === 0)
|
|
85
|
+
return 100;
|
|
86
|
+
return clamp(100 - Math.round((count / totalFiles) * 20), 0, 100);
|
|
87
|
+
}
|
|
88
|
+
export function computeRepoQuality(targetPath, files) {
|
|
89
|
+
const allIssues = files.flatMap((file) => file.issues);
|
|
90
|
+
const sourceFiles = files.filter((file) => !file.path.endsWith('package.json'));
|
|
91
|
+
const totalFiles = Math.max(sourceFiles.length, 1);
|
|
92
|
+
const testingCandidates = listFilesRecursively(targetPath).filter((filePath) => /\.(ts|tsx|js|jsx)$/.test(filePath) &&
|
|
93
|
+
!/\.test\.|\.spec\./.test(filePath) &&
|
|
94
|
+
!filePath.includes('node_modules'));
|
|
95
|
+
const withoutTests = testingCandidates.filter((filePath) => !hasNearbyTest(targetPath, filePath)).length;
|
|
96
|
+
const testing = testingCandidates.length === 0
|
|
97
|
+
? 100
|
|
98
|
+
: clamp(100 - Math.round((withoutTests / testingCandidates.length) * 100), 0, 100);
|
|
99
|
+
const dimensions = {
|
|
100
|
+
architecture: qualityFromIssues(totalFiles, allIssues, ARCH_RULES),
|
|
101
|
+
complexity: qualityFromIssues(totalFiles, allIssues, COMPLEXITY_RULES),
|
|
102
|
+
'ai-patterns': qualityFromIssues(totalFiles, allIssues, AI_RULES),
|
|
103
|
+
testing,
|
|
104
|
+
};
|
|
105
|
+
const overall = Math.round((dimensions.architecture +
|
|
106
|
+
dimensions.complexity +
|
|
107
|
+
dimensions['ai-patterns'] +
|
|
108
|
+
dimensions.testing) / 4);
|
|
109
|
+
return { overall, dimensions };
|
|
110
|
+
}
|
|
111
|
+
export function computeMaintenanceRisk(report) {
|
|
112
|
+
const allFiles = report.files;
|
|
113
|
+
const hotspots = allFiles
|
|
114
|
+
.map((file) => {
|
|
115
|
+
const complexityIssues = file.issues.filter((issue) => issue.rule === 'high-complexity' ||
|
|
116
|
+
issue.rule === 'deep-nesting' ||
|
|
117
|
+
issue.rule === 'large-function' ||
|
|
118
|
+
issue.rule === 'max-function-lines').length;
|
|
119
|
+
const changeFrequency = getCommitTouchCount(report.targetPath, file.path);
|
|
120
|
+
const hasTests = hasNearbyTest(report.targetPath, file.path);
|
|
121
|
+
const reasons = [];
|
|
122
|
+
let risk = 0;
|
|
123
|
+
if (complexityIssues > 0) {
|
|
124
|
+
risk += Math.min(40, complexityIssues * 10);
|
|
125
|
+
reasons.push('high complexity signals');
|
|
126
|
+
}
|
|
127
|
+
if (!hasTests) {
|
|
128
|
+
risk += 25;
|
|
129
|
+
reasons.push('no nearby tests');
|
|
130
|
+
}
|
|
131
|
+
if (changeFrequency >= 8) {
|
|
132
|
+
risk += 20;
|
|
133
|
+
reasons.push('frequently changed file');
|
|
134
|
+
}
|
|
135
|
+
if (file.score >= 50) {
|
|
136
|
+
risk += 15;
|
|
137
|
+
reasons.push('high drift score');
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
file: file.path,
|
|
141
|
+
driftScore: file.score,
|
|
142
|
+
complexityIssues,
|
|
143
|
+
hasNearbyTests: hasTests,
|
|
144
|
+
changeFrequency,
|
|
145
|
+
risk: clamp(risk, 0, 100),
|
|
146
|
+
reasons,
|
|
147
|
+
};
|
|
148
|
+
})
|
|
149
|
+
.filter((hotspot) => hotspot.risk > 0)
|
|
150
|
+
.sort((a, b) => b.risk - a.risk)
|
|
151
|
+
.slice(0, 10);
|
|
152
|
+
const highComplexityFiles = hotspots.filter((hotspot) => hotspot.complexityIssues > 0).length;
|
|
153
|
+
const filesWithoutNearbyTests = hotspots.filter((hotspot) => !hotspot.hasNearbyTests).length;
|
|
154
|
+
const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >= 8).length;
|
|
155
|
+
const score = hotspots.length === 0
|
|
156
|
+
? 0
|
|
157
|
+
: Math.round(hotspots.reduce((sum, hotspot) => sum + hotspot.risk, 0) / hotspots.length);
|
|
158
|
+
const level = score >= 75
|
|
159
|
+
? 'critical'
|
|
160
|
+
: score >= 55
|
|
161
|
+
? 'high'
|
|
162
|
+
: score >= 30
|
|
163
|
+
? 'medium'
|
|
164
|
+
: 'low';
|
|
165
|
+
return {
|
|
166
|
+
score,
|
|
167
|
+
level,
|
|
168
|
+
hotspots,
|
|
169
|
+
signals: {
|
|
170
|
+
highComplexityFiles,
|
|
171
|
+
filesWithoutNearbyTests,
|
|
172
|
+
frequentChangeFiles,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=metrics.js.map
|
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
function isPluginShape(value) {
|
|
6
|
+
if (!value || typeof value !== 'object')
|
|
7
|
+
return false;
|
|
8
|
+
const candidate = value;
|
|
9
|
+
if (typeof candidate.name !== 'string')
|
|
10
|
+
return false;
|
|
11
|
+
if (!Array.isArray(candidate.rules))
|
|
12
|
+
return false;
|
|
13
|
+
return candidate.rules.every((rule) => rule &&
|
|
14
|
+
typeof rule === 'object' &&
|
|
15
|
+
typeof rule.name === 'string' &&
|
|
16
|
+
typeof rule.detect === 'function');
|
|
17
|
+
}
|
|
18
|
+
function normalizePluginExport(mod) {
|
|
19
|
+
if (isPluginShape(mod))
|
|
20
|
+
return mod;
|
|
21
|
+
if (mod && typeof mod === 'object' && 'default' in mod) {
|
|
22
|
+
const maybeDefault = mod.default;
|
|
23
|
+
if (isPluginShape(maybeDefault))
|
|
24
|
+
return maybeDefault;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
function resolvePluginSpecifier(projectRoot, pluginId) {
|
|
29
|
+
if (pluginId.startsWith('.') || pluginId.startsWith('/')) {
|
|
30
|
+
const abs = isAbsolute(pluginId) ? pluginId : resolve(projectRoot, pluginId);
|
|
31
|
+
if (existsSync(abs))
|
|
32
|
+
return abs;
|
|
33
|
+
if (existsSync(`${abs}.js`))
|
|
34
|
+
return `${abs}.js`;
|
|
35
|
+
if (existsSync(`${abs}.cjs`))
|
|
36
|
+
return `${abs}.cjs`;
|
|
37
|
+
if (existsSync(`${abs}.mjs`))
|
|
38
|
+
return `${abs}.mjs`;
|
|
39
|
+
if (existsSync(`${abs}.ts`))
|
|
40
|
+
return `${abs}.ts`;
|
|
41
|
+
return abs;
|
|
42
|
+
}
|
|
43
|
+
return pluginId;
|
|
44
|
+
}
|
|
45
|
+
export function loadPlugins(projectRoot, pluginIds) {
|
|
46
|
+
if (!pluginIds || pluginIds.length === 0) {
|
|
47
|
+
return { plugins: [], errors: [] };
|
|
48
|
+
}
|
|
49
|
+
const loaded = [];
|
|
50
|
+
const errors = [];
|
|
51
|
+
for (const pluginId of pluginIds) {
|
|
52
|
+
const resolved = resolvePluginSpecifier(projectRoot, pluginId);
|
|
53
|
+
try {
|
|
54
|
+
const mod = require(resolved);
|
|
55
|
+
const plugin = normalizePluginExport(mod);
|
|
56
|
+
if (!plugin) {
|
|
57
|
+
errors.push({
|
|
58
|
+
pluginId,
|
|
59
|
+
message: `Invalid plugin contract in '${pluginId}'. Expected: { name, rules[] }`,
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
loaded.push({ id: pluginId, plugin });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
errors.push({
|
|
67
|
+
pluginId,
|
|
68
|
+
message: error instanceof Error ? error.message : String(error),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { plugins: loaded, errors };
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=plugins.js.map
|
package/dist/printer.js
CHANGED
|
@@ -111,6 +111,26 @@ function formatFixSuggestion(issue) {
|
|
|
111
111
|
'Pick one naming convention (camelCase for variables/functions, PascalCase for types)',
|
|
112
112
|
'Rename snake_case identifiers to camelCase to match TypeScript conventions',
|
|
113
113
|
],
|
|
114
|
+
'controller-no-db': [
|
|
115
|
+
'Move DB access to a service/repository and inject it into the controller',
|
|
116
|
+
'Keep controllers focused on transport and orchestration only',
|
|
117
|
+
],
|
|
118
|
+
'service-no-http': [
|
|
119
|
+
'Move HTTP concerns to adapters/clients and keep services framework-agnostic',
|
|
120
|
+
'Inject interfaces for outbound calls instead of calling fetch/express directly',
|
|
121
|
+
],
|
|
122
|
+
'max-function-lines': [
|
|
123
|
+
'Split the function into smaller units with clear responsibilities',
|
|
124
|
+
'Extract branch-heavy chunks into dedicated helpers',
|
|
125
|
+
],
|
|
126
|
+
'ai-code-smell': [
|
|
127
|
+
'Address the listed AI-smell signals in this file before adding more code',
|
|
128
|
+
'Prioritize consistency: naming, error handling, and abstraction level',
|
|
129
|
+
],
|
|
130
|
+
'plugin-error': [
|
|
131
|
+
'Fix or remove the failing plugin in drift.config.*',
|
|
132
|
+
'Validate plugin contract: export { name, rules[] } and detector functions',
|
|
133
|
+
],
|
|
114
134
|
};
|
|
115
135
|
return suggestions[issue.rule] ?? ['Review and fix manually'];
|
|
116
136
|
}
|
package/dist/report.js
CHANGED
|
@@ -638,6 +638,8 @@ export function generateHtmlReport(report) {
|
|
|
638
638
|
const projColor = scoreColor(report.totalScore);
|
|
639
639
|
const projLabel = scoreLabel(report.totalScore);
|
|
640
640
|
const projGrade = scoreGrade(report.totalScore);
|
|
641
|
+
const quality = report.quality;
|
|
642
|
+
const risk = report.maintenanceRisk;
|
|
641
643
|
const filesWithIssues = report.files.filter(f => f.issues.length > 0).length;
|
|
642
644
|
// ── Top rules for sidebar ──────────────────────────────────────────────
|
|
643
645
|
const topRules = Object.entries(report.summary.byRule)
|
|
@@ -648,6 +650,16 @@ export function generateHtmlReport(report) {
|
|
|
648
650
|
<span class="rule-name">${escapeHtml(rule)}</span>
|
|
649
651
|
<span class="rule-count">${count}</span>
|
|
650
652
|
</button>`).join('');
|
|
653
|
+
const hotspotsHtml = risk.hotspots.length === 0
|
|
654
|
+
? '<div class="empty-state" style="padding:0.8rem">No hotspots detected.</div>'
|
|
655
|
+
: risk.hotspots.slice(0, 5).map((hotspot) => `
|
|
656
|
+
<div class="issue-row" style="grid-template-columns: 62px 1fr;grid-template-rows:auto auto;">
|
|
657
|
+
<span class="issue-line">R${hotspot.risk}</span>
|
|
658
|
+
<div class="issue-rule-msg">
|
|
659
|
+
<span class="issue-rule">hotspot</span>
|
|
660
|
+
<span class="issue-msg">${escapeHtml(hotspot.file)} (${hotspot.reasons.join(', ')})</span>
|
|
661
|
+
</div>
|
|
662
|
+
</div>`).join('');
|
|
651
663
|
// ── File sections ──────────────────────────────────────────────────────
|
|
652
664
|
const fileSections = report.files
|
|
653
665
|
.filter(f => f.issues.length > 0)
|
|
@@ -755,6 +767,21 @@ export function generateHtmlReport(report) {
|
|
|
755
767
|
</div>
|
|
756
768
|
</div>
|
|
757
769
|
|
|
770
|
+
<div class="sidebar-block">
|
|
771
|
+
<div class="sidebar-label">Repo Quality</div>
|
|
772
|
+
<div style="font-size:1.1rem;font-weight:700;color:${scoreColor(100 - quality.overall)}">${quality.overall}/100</div>
|
|
773
|
+
<div style="font-size:0.72rem;color:var(--muted)">Architecture ${quality.dimensions.architecture} · Complexity ${quality.dimensions.complexity}</div>
|
|
774
|
+
<div style="font-size:0.72rem;color:var(--muted)">AI patterns ${quality.dimensions['ai-patterns']} · Testing ${quality.dimensions.testing}</div>
|
|
775
|
+
</div>
|
|
776
|
+
|
|
777
|
+
<div class="sidebar-block">
|
|
778
|
+
<div class="sidebar-label">Maintenance Risk</div>
|
|
779
|
+
<div style="font-size:1.1rem;font-weight:700;color:${scoreColor(risk.score)}">${risk.score}/100 (${risk.level.toUpperCase()})</div>
|
|
780
|
+
<div style="font-size:0.72rem;color:var(--muted)">High complexity: ${risk.signals.highComplexityFiles}</div>
|
|
781
|
+
<div style="font-size:0.72rem;color:var(--muted)">No tests: ${risk.signals.filesWithoutNearbyTests}</div>
|
|
782
|
+
<div style="font-size:0.72rem;color:var(--muted)">Frequent changes: ${risk.signals.frequentChangeFiles}</div>
|
|
783
|
+
</div>
|
|
784
|
+
|
|
758
785
|
<!-- Severity filters -->
|
|
759
786
|
<div class="sidebar-block">
|
|
760
787
|
<div class="sidebar-label">Severity</div>
|
|
@@ -804,6 +831,13 @@ export function generateHtmlReport(report) {
|
|
|
804
831
|
<div class="main-header">
|
|
805
832
|
<span id="issue-counter" style="color:var(--muted);font-size:0.75rem">Loading…</span>
|
|
806
833
|
</div>
|
|
834
|
+
<section class="file-section" open>
|
|
835
|
+
<summary>
|
|
836
|
+
<span class="file-name">Risk hotspots</span>
|
|
837
|
+
<span class="file-score" style="color:${scoreColor(risk.score)}">${risk.score}/100</span>
|
|
838
|
+
</summary>
|
|
839
|
+
<div class="issues-list">${hotspotsHtml}</div>
|
|
840
|
+
</section>
|
|
807
841
|
${fileSections || noIssues}
|
|
808
842
|
</main>
|
|
809
843
|
|
package/dist/reporter.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { scoreToGradeText, severityIcon } from './utils.js';
|
|
2
|
+
import { computeRepoQuality, computeMaintenanceRisk } from './metrics.js';
|
|
2
3
|
const FIX_SUGGESTIONS = {
|
|
3
4
|
'large-file': 'Consider splitting this file into smaller modules with single responsibility',
|
|
4
5
|
'large-function': 'Extract logic into smaller functions with descriptive names',
|
|
@@ -21,6 +22,17 @@ const RULE_EFFORT = {
|
|
|
21
22
|
};
|
|
22
23
|
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
23
24
|
const EFFORT_ORDER = { low: 0, medium: 1, high: 2 };
|
|
25
|
+
const AI_SIGNAL_RULES = new Set([
|
|
26
|
+
'over-commented',
|
|
27
|
+
'hardcoded-config',
|
|
28
|
+
'inconsistent-error-handling',
|
|
29
|
+
'unnecessary-abstraction',
|
|
30
|
+
'naming-inconsistency',
|
|
31
|
+
'comment-contradiction',
|
|
32
|
+
'promise-style-mix',
|
|
33
|
+
'any-abuse',
|
|
34
|
+
'ai-code-smell',
|
|
35
|
+
]);
|
|
24
36
|
export function buildReport(targetPath, files) {
|
|
25
37
|
const allIssues = files.flatMap((f) => f.issues);
|
|
26
38
|
const byRule = {};
|
|
@@ -30,10 +42,11 @@ export function buildReport(targetPath, files) {
|
|
|
30
42
|
const totalScore = files.length > 0
|
|
31
43
|
? Math.round(files.reduce((sum, f) => sum + f.score, 0) / files.length)
|
|
32
44
|
: 0;
|
|
33
|
-
|
|
45
|
+
const sortedFiles = files.filter((f) => f.issues.length > 0).sort((a, b) => b.score - a.score);
|
|
46
|
+
const baseReport = {
|
|
34
47
|
scannedAt: new Date().toISOString(),
|
|
35
48
|
targetPath,
|
|
36
|
-
files:
|
|
49
|
+
files: sortedFiles,
|
|
37
50
|
totalIssues: allIssues.length,
|
|
38
51
|
totalScore,
|
|
39
52
|
totalFiles: files.length,
|
|
@@ -43,7 +56,29 @@ export function buildReport(targetPath, files) {
|
|
|
43
56
|
infos: allIssues.filter((i) => i.severity === 'info').length,
|
|
44
57
|
byRule,
|
|
45
58
|
},
|
|
59
|
+
quality: {
|
|
60
|
+
overall: 100,
|
|
61
|
+
dimensions: {
|
|
62
|
+
architecture: 100,
|
|
63
|
+
complexity: 100,
|
|
64
|
+
'ai-patterns': 100,
|
|
65
|
+
testing: 100,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
maintenanceRisk: {
|
|
69
|
+
score: 0,
|
|
70
|
+
level: 'low',
|
|
71
|
+
hotspots: [],
|
|
72
|
+
signals: {
|
|
73
|
+
highComplexityFiles: 0,
|
|
74
|
+
filesWithoutNearbyTests: 0,
|
|
75
|
+
frequentChangeFiles: 0,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
46
78
|
};
|
|
79
|
+
baseReport.quality = computeRepoQuality(targetPath, files);
|
|
80
|
+
baseReport.maintenanceRisk = computeMaintenanceRisk(baseReport);
|
|
81
|
+
return baseReport;
|
|
47
82
|
}
|
|
48
83
|
function formatHeader(report, grade) {
|
|
49
84
|
return [
|
|
@@ -146,12 +181,55 @@ function buildRecommendedAction(priorityOrder) {
|
|
|
146
181
|
}
|
|
147
182
|
return 'Start with the highest priority issue and work through them in order.';
|
|
148
183
|
}
|
|
184
|
+
function fileAILikelihood(fileIssues) {
|
|
185
|
+
if (fileIssues.length === 0)
|
|
186
|
+
return { score: 0, triggers: [] };
|
|
187
|
+
const triggerCounts = new Map();
|
|
188
|
+
for (const issue of fileIssues) {
|
|
189
|
+
if (!AI_SIGNAL_RULES.has(issue.rule))
|
|
190
|
+
continue;
|
|
191
|
+
triggerCounts.set(issue.rule, (triggerCounts.get(issue.rule) ?? 0) + 1);
|
|
192
|
+
}
|
|
193
|
+
const triggerTotal = [...triggerCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
194
|
+
const smellBoost = fileIssues.some((issue) => issue.rule === 'ai-code-smell') ? 20 : 0;
|
|
195
|
+
const ratioScore = Math.round((triggerTotal / Math.max(fileIssues.length, 1)) * 100);
|
|
196
|
+
const score = Math.max(0, Math.min(100, ratioScore + smellBoost));
|
|
197
|
+
const triggers = [...triggerCounts.entries()]
|
|
198
|
+
.sort((a, b) => b[1] - a[1])
|
|
199
|
+
.slice(0, 4)
|
|
200
|
+
.map(([rule]) => rule);
|
|
201
|
+
return { score, triggers };
|
|
202
|
+
}
|
|
203
|
+
function computeAILikelihood(report) {
|
|
204
|
+
const suspected = report.files
|
|
205
|
+
.map((file) => {
|
|
206
|
+
const likelihood = fileAILikelihood(file.issues);
|
|
207
|
+
return {
|
|
208
|
+
path: file.path,
|
|
209
|
+
ai_likelihood: likelihood.score,
|
|
210
|
+
triggers: likelihood.triggers,
|
|
211
|
+
};
|
|
212
|
+
})
|
|
213
|
+
.filter((entry) => entry.ai_likelihood >= 35)
|
|
214
|
+
.sort((a, b) => b.ai_likelihood - a.ai_likelihood);
|
|
215
|
+
const overall = suspected.length === 0
|
|
216
|
+
? 0
|
|
217
|
+
: Math.round(suspected.reduce((sum, entry) => sum + entry.ai_likelihood, 0) / suspected.length);
|
|
218
|
+
const smellCount = report.files.flatMap((file) => file.issues).filter((issue) => issue.rule === 'ai-code-smell').length;
|
|
219
|
+
const smellScore = Math.min(100, smellCount * 15);
|
|
220
|
+
return {
|
|
221
|
+
overall,
|
|
222
|
+
files: suspected.slice(0, 10),
|
|
223
|
+
smellScore,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
149
226
|
export function formatAIOutput(report) {
|
|
150
227
|
const allIssues = collectAllIssues(report);
|
|
151
228
|
const sortedIssues = sortIssues(allIssues);
|
|
152
229
|
const priorityOrder = sortedIssues.map((item, i) => buildAIIssue(item, i + 1));
|
|
153
230
|
const rulesDetected = [...new Set(allIssues.map((i) => i.issue.rule))];
|
|
154
231
|
const grade = scoreToGradeText(report.totalScore);
|
|
232
|
+
const aiLikelihood = computeAILikelihood(report);
|
|
155
233
|
return {
|
|
156
234
|
summary: {
|
|
157
235
|
score: report.totalScore,
|
|
@@ -159,8 +237,13 @@ export function formatAIOutput(report) {
|
|
|
159
237
|
total_issues: report.totalIssues,
|
|
160
238
|
files_affected: report.files.length,
|
|
161
239
|
files_clean: report.totalFiles - report.files.length,
|
|
240
|
+
ai_likelihood: aiLikelihood.overall,
|
|
241
|
+
ai_code_smell_score: aiLikelihood.smellScore,
|
|
162
242
|
},
|
|
243
|
+
files_suspected: aiLikelihood.files,
|
|
163
244
|
priority_order: priorityOrder,
|
|
245
|
+
maintenance_risk: report.maintenanceRisk,
|
|
246
|
+
quality: report.quality,
|
|
164
247
|
context_for_ai: {
|
|
165
248
|
project_type: 'typescript',
|
|
166
249
|
scan_path: report.targetPath,
|
package/dist/review.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DriftDiff } from './types.js';
|
|
2
|
+
export interface DriftReview {
|
|
3
|
+
baseRef: string;
|
|
4
|
+
scannedAt: string;
|
|
5
|
+
totalDelta: number;
|
|
6
|
+
newIssues: number;
|
|
7
|
+
resolvedIssues: number;
|
|
8
|
+
status: 'clean' | 'improved' | 'regressed';
|
|
9
|
+
summary: string;
|
|
10
|
+
markdown: string;
|
|
11
|
+
diff: DriftDiff;
|
|
12
|
+
}
|
|
13
|
+
export declare function formatReviewMarkdown(review: DriftReview): string;
|
|
14
|
+
export declare function generateReview(projectPath: string, baseRef: string): Promise<DriftReview>;
|
|
15
|
+
//# sourceMappingURL=review.d.ts.map
|
package/dist/review.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { analyzeProject } from './analyzer.js';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { buildReport } from './reporter.js';
|
|
5
|
+
import { cleanupTempDir, extractFilesAtRef } from './git.js';
|
|
6
|
+
import { computeDiff } from './diff.js';
|
|
7
|
+
export function formatReviewMarkdown(review) {
|
|
8
|
+
const trendIcon = review.status === 'regressed' ? '⚠️' : review.status === 'improved' ? '✅' : 'ℹ️';
|
|
9
|
+
const topFiles = review.diff.files
|
|
10
|
+
.slice(0, 8)
|
|
11
|
+
.map((file) => {
|
|
12
|
+
const sign = file.scoreDelta > 0 ? '+' : '';
|
|
13
|
+
return `- \`${file.path}\`: ${file.scoreBefore} -> ${file.scoreAfter} (${sign}${file.scoreDelta}), +${file.newIssues.length} new / -${file.resolvedIssues.length} resolved`;
|
|
14
|
+
})
|
|
15
|
+
.join('\n');
|
|
16
|
+
return [
|
|
17
|
+
'## drift review',
|
|
18
|
+
'',
|
|
19
|
+
`${trendIcon} ${review.summary}`,
|
|
20
|
+
'',
|
|
21
|
+
`- Base ref: \`${review.baseRef}\``,
|
|
22
|
+
`- Score delta: **${review.totalDelta >= 0 ? '+' : ''}${review.totalDelta}**`,
|
|
23
|
+
`- New issues: **${review.newIssues}**`,
|
|
24
|
+
`- Resolved issues: **${review.resolvedIssues}**`,
|
|
25
|
+
'',
|
|
26
|
+
'### File breakdown',
|
|
27
|
+
topFiles || '- No file-level deltas detected',
|
|
28
|
+
].join('\n');
|
|
29
|
+
}
|
|
30
|
+
function getStatus(totalDelta, newIssues) {
|
|
31
|
+
if (totalDelta > 0 || newIssues > 0)
|
|
32
|
+
return 'regressed';
|
|
33
|
+
if (totalDelta < 0)
|
|
34
|
+
return 'improved';
|
|
35
|
+
return 'clean';
|
|
36
|
+
}
|
|
37
|
+
export async function generateReview(projectPath, baseRef) {
|
|
38
|
+
const resolvedPath = resolve(projectPath);
|
|
39
|
+
const config = await loadConfig(resolvedPath);
|
|
40
|
+
const currentFiles = analyzeProject(resolvedPath, config);
|
|
41
|
+
const currentReport = buildReport(resolvedPath, currentFiles);
|
|
42
|
+
let tempDir;
|
|
43
|
+
try {
|
|
44
|
+
tempDir = extractFilesAtRef(resolvedPath, baseRef);
|
|
45
|
+
const baseFiles = analyzeProject(tempDir, config);
|
|
46
|
+
const baseReport = buildReport(tempDir, baseFiles);
|
|
47
|
+
const remappedBase = {
|
|
48
|
+
...baseReport,
|
|
49
|
+
files: baseReport.files.map((file) => ({
|
|
50
|
+
...file,
|
|
51
|
+
path: file.path.replace(tempDir, resolvedPath),
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
const diff = computeDiff(remappedBase, currentReport, baseRef);
|
|
55
|
+
const status = getStatus(diff.totalDelta, diff.newIssuesCount);
|
|
56
|
+
const summary = status === 'regressed'
|
|
57
|
+
? `Drift regressed: +${diff.totalDelta} score and ${diff.newIssuesCount} new issue(s).`
|
|
58
|
+
: status === 'improved'
|
|
59
|
+
? `Drift improved: ${diff.totalDelta} score delta and ${diff.resolvedIssuesCount} issue(s) resolved.`
|
|
60
|
+
: 'No drift changes detected against base ref.';
|
|
61
|
+
const review = {
|
|
62
|
+
baseRef,
|
|
63
|
+
scannedAt: new Date().toISOString(),
|
|
64
|
+
totalDelta: diff.totalDelta,
|
|
65
|
+
newIssues: diff.newIssuesCount,
|
|
66
|
+
resolvedIssues: diff.resolvedIssuesCount,
|
|
67
|
+
status,
|
|
68
|
+
summary,
|
|
69
|
+
markdown: '',
|
|
70
|
+
diff,
|
|
71
|
+
};
|
|
72
|
+
review.markdown = formatReviewMarkdown(review);
|
|
73
|
+
return review;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
if (tempDir)
|
|
77
|
+
cleanupTempDir(tempDir);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=review.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { hasIgnoreComment } from './shared.js';
|
|
2
|
+
const TRIVIAL_COMMENT_PATTERNS = [
|
|
3
|
+
{ comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
|
|
4
|
+
{ comment: /\/\/\s*(increment|increase|add\s+1|plus\s+1)\b/i, code: /\+\+|(\+= ?1)\b/ },
|
|
5
|
+
{ comment: /\/\/\s*(decrement|decrease|subtract\s+1|minus\s+1)\b/i, code: /--|(-= ?1)\b/ },
|
|
6
|
+
{ comment: /\/\/\s*log\b/i, code: /console\.(log|warn|error)/ },
|
|
7
|
+
{ comment: /\/\/\s*(set|assign)\b/i, code: /^\s*\w[\w.[\]]*\s*=(?!=)/ },
|
|
8
|
+
{ comment: /\/\/\s*call\b/i, code: /^\s*\w[\w.]*\(/ },
|
|
9
|
+
{ comment: /\/\/\s*(declare|define|create|initialize)\b/i, code: /^\s*(const|let|var)\b/ },
|
|
10
|
+
{ comment: /\/\/\s*check\s+if\b/i, code: /^\s*if\s*\(/ },
|
|
11
|
+
{ comment: /\/\/\s*(loop|iterate|for each|foreach)\b/i, code: /^\s*(for|while)\b/ },
|
|
12
|
+
{ comment: /\/\/\s*import\b/i, code: /^\s*import\b/ },
|
|
13
|
+
];
|
|
14
|
+
const SNIPPET_TRUNCATE = 60;
|
|
15
|
+
function checkLineForContradiction(commentLine, nextLine, lineNumber, file) {
|
|
16
|
+
for (const { comment, code } of TRIVIAL_COMMENT_PATTERNS) {
|
|
17
|
+
if (comment.test(commentLine) && code.test(nextLine)) {
|
|
18
|
+
if (hasIgnoreComment(file, lineNumber))
|
|
19
|
+
return null;
|
|
20
|
+
return {
|
|
21
|
+
rule: 'comment-contradiction',
|
|
22
|
+
severity: 'warning',
|
|
23
|
+
message: `Comment restates what the code already says. AI documents the obvious instead of the why.`,
|
|
24
|
+
line: lineNumber,
|
|
25
|
+
column: 1,
|
|
26
|
+
snippet: `${commentLine.slice(0, SNIPPET_TRUNCATE)}\n${nextLine.trim().slice(0, SNIPPET_TRUNCATE)}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function detectCommentContradiction(file) {
|
|
33
|
+
const issues = [];
|
|
34
|
+
const lines = file.getFullText().split('\n');
|
|
35
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
36
|
+
const commentLine = lines[i].trim();
|
|
37
|
+
const nextLine = lines[i + 1];
|
|
38
|
+
const issue = checkLineForContradiction(commentLine, nextLine, i + 1, file);
|
|
39
|
+
if (issue) {
|
|
40
|
+
issues.push(issue);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return issues;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=comments.js.map
|