@eduardbar/drift 1.0.0 → 1.2.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.
Files changed (105) hide show
  1. package/.github/actions/drift-scan/README.md +61 -0
  2. package/.github/actions/drift-scan/action.yml +65 -0
  3. package/.github/workflows/publish-vscode.yml +3 -1
  4. package/.github/workflows/review-pr.yml +61 -0
  5. package/AGENTS.md +53 -11
  6. package/README.md +106 -1
  7. package/dist/analyzer.d.ts +6 -2
  8. package/dist/analyzer.js +116 -3
  9. package/dist/badge.js +40 -22
  10. package/dist/ci.js +32 -18
  11. package/dist/cli.js +179 -6
  12. package/dist/diff.d.ts +0 -7
  13. package/dist/diff.js +26 -25
  14. package/dist/fix.d.ts +4 -0
  15. package/dist/fix.js +59 -47
  16. package/dist/git/trend.js +1 -0
  17. package/dist/git.d.ts +0 -9
  18. package/dist/git.js +25 -19
  19. package/dist/index.d.ts +7 -1
  20. package/dist/index.js +4 -0
  21. package/dist/map.d.ts +4 -0
  22. package/dist/map.js +191 -0
  23. package/dist/metrics.d.ts +4 -0
  24. package/dist/metrics.js +176 -0
  25. package/dist/plugins.d.ts +6 -0
  26. package/dist/plugins.js +74 -0
  27. package/dist/printer.js +20 -0
  28. package/dist/report.js +34 -0
  29. package/dist/reporter.js +85 -2
  30. package/dist/review.d.ts +15 -0
  31. package/dist/review.js +80 -0
  32. package/dist/rules/comments.d.ts +4 -0
  33. package/dist/rules/comments.js +45 -0
  34. package/dist/rules/complexity.d.ts +4 -0
  35. package/dist/rules/complexity.js +51 -0
  36. package/dist/rules/coupling.d.ts +4 -0
  37. package/dist/rules/coupling.js +19 -0
  38. package/dist/rules/magic.d.ts +4 -0
  39. package/dist/rules/magic.js +33 -0
  40. package/dist/rules/nesting.d.ts +5 -0
  41. package/dist/rules/nesting.js +82 -0
  42. package/dist/rules/phase0-basic.js +14 -7
  43. package/dist/rules/phase1-complexity.d.ts +6 -30
  44. package/dist/rules/phase1-complexity.js +7 -276
  45. package/dist/rules/phase2-crossfile.d.ts +0 -4
  46. package/dist/rules/phase2-crossfile.js +52 -39
  47. package/dist/rules/phase3-arch.d.ts +0 -8
  48. package/dist/rules/phase3-arch.js +26 -23
  49. package/dist/rules/phase3-configurable.d.ts +6 -0
  50. package/dist/rules/phase3-configurable.js +97 -0
  51. package/dist/rules/phase8-semantic.d.ts +0 -5
  52. package/dist/rules/phase8-semantic.js +30 -29
  53. package/dist/rules/promise.d.ts +4 -0
  54. package/dist/rules/promise.js +24 -0
  55. package/dist/saas.d.ts +83 -0
  56. package/dist/saas.js +321 -0
  57. package/dist/snapshot.d.ts +19 -0
  58. package/dist/snapshot.js +119 -0
  59. package/dist/types.d.ts +75 -0
  60. package/dist/utils.d.ts +2 -1
  61. package/dist/utils.js +1 -0
  62. package/docs/AGENTS.md +146 -0
  63. package/docs/PRD.md +157 -0
  64. package/package.json +1 -1
  65. package/packages/eslint-plugin-drift/src/index.ts +1 -1
  66. package/packages/vscode-drift/package.json +1 -1
  67. package/packages/vscode-drift/src/analyzer.ts +2 -0
  68. package/packages/vscode-drift/src/code-actions.ts +53 -0
  69. package/packages/vscode-drift/src/extension.ts +98 -63
  70. package/packages/vscode-drift/src/statusbar.ts +13 -5
  71. package/packages/vscode-drift/src/treeview.ts +2 -0
  72. package/src/analyzer.ts +144 -12
  73. package/src/badge.ts +38 -16
  74. package/src/ci.ts +38 -17
  75. package/src/cli.ts +206 -7
  76. package/src/diff.ts +36 -30
  77. package/src/fix.ts +77 -53
  78. package/src/git/trend.ts +3 -2
  79. package/src/git.ts +31 -22
  80. package/src/index.ts +31 -1
  81. package/src/map.ts +219 -0
  82. package/src/metrics.ts +200 -0
  83. package/src/plugins.ts +76 -0
  84. package/src/printer.ts +20 -0
  85. package/src/report.ts +35 -0
  86. package/src/reporter.ts +95 -2
  87. package/src/review.ts +98 -0
  88. package/src/rules/comments.ts +56 -0
  89. package/src/rules/complexity.ts +57 -0
  90. package/src/rules/coupling.ts +23 -0
  91. package/src/rules/magic.ts +38 -0
  92. package/src/rules/nesting.ts +88 -0
  93. package/src/rules/phase0-basic.ts +14 -7
  94. package/src/rules/phase1-complexity.ts +8 -302
  95. package/src/rules/phase2-crossfile.ts +68 -40
  96. package/src/rules/phase3-arch.ts +34 -30
  97. package/src/rules/phase3-configurable.ts +132 -0
  98. package/src/rules/phase8-semantic.ts +33 -29
  99. package/src/rules/promise.ts +29 -0
  100. package/src/saas.ts +433 -0
  101. package/src/snapshot.ts +175 -0
  102. package/src/types.ts +81 -1
  103. package/src/utils.ts +3 -1
  104. package/tests/new-features.test.ts +180 -0
  105. package/tests/saas-foundation.test.ts +107 -0
package/src/reporter.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { FileReport, DriftReport, DriftIssue, AIOutput, AIIssue } from './types.js'
2
2
  import { scoreToGradeText, severityIcon } from './utils.js'
3
+ import { computeRepoQuality, computeMaintenanceRisk } from './metrics.js'
3
4
 
4
5
  const FIX_SUGGESTIONS: Record<string, string> = {
5
6
  'large-file': 'Consider splitting this file into smaller modules with single responsibility',
@@ -25,6 +26,17 @@ const RULE_EFFORT: Record<string, 'low' | 'medium' | 'high'> = {
25
26
 
26
27
  const SEVERITY_ORDER: Record<string, number> = { error: 0, warning: 1, info: 2 }
27
28
  const EFFORT_ORDER: Record<string, number> = { low: 0, medium: 1, high: 2 }
29
+ const AI_SIGNAL_RULES = new Set([
30
+ 'over-commented',
31
+ 'hardcoded-config',
32
+ 'inconsistent-error-handling',
33
+ 'unnecessary-abstraction',
34
+ 'naming-inconsistency',
35
+ 'comment-contradiction',
36
+ 'promise-style-mix',
37
+ 'any-abuse',
38
+ 'ai-code-smell',
39
+ ])
28
40
 
29
41
  export function buildReport(targetPath: string, files: FileReport[]): DriftReport {
30
42
  const allIssues = files.flatMap((f) => f.issues)
@@ -39,10 +51,12 @@ export function buildReport(targetPath: string, files: FileReport[]): DriftRepor
39
51
  ? Math.round(files.reduce((sum, f) => sum + f.score, 0) / files.length)
40
52
  : 0
41
53
 
42
- return {
54
+ const sortedFiles = files.filter((f) => f.issues.length > 0).sort((a, b) => b.score - a.score)
55
+
56
+ const baseReport: DriftReport = {
43
57
  scannedAt: new Date().toISOString(),
44
58
  targetPath,
45
- files: files.filter((f) => f.issues.length > 0).sort((a, b) => b.score - a.score),
59
+ files: sortedFiles,
46
60
  totalIssues: allIssues.length,
47
61
  totalScore,
48
62
  totalFiles: files.length,
@@ -52,7 +66,31 @@ export function buildReport(targetPath: string, files: FileReport[]): DriftRepor
52
66
  infos: allIssues.filter((i) => i.severity === 'info').length,
53
67
  byRule,
54
68
  },
69
+ quality: {
70
+ overall: 100,
71
+ dimensions: {
72
+ architecture: 100,
73
+ complexity: 100,
74
+ 'ai-patterns': 100,
75
+ testing: 100,
76
+ },
77
+ },
78
+ maintenanceRisk: {
79
+ score: 0,
80
+ level: 'low',
81
+ hotspots: [],
82
+ signals: {
83
+ highComplexityFiles: 0,
84
+ filesWithoutNearbyTests: 0,
85
+ frequentChangeFiles: 0,
86
+ },
87
+ },
55
88
  }
89
+
90
+ baseReport.quality = computeRepoQuality(targetPath, files)
91
+ baseReport.maintenanceRisk = computeMaintenanceRisk(baseReport)
92
+
93
+ return baseReport
56
94
  }
57
95
 
58
96
  function formatHeader(report: DriftReport, grade: { badge: string }): string[] {
@@ -163,12 +201,62 @@ function buildRecommendedAction(priorityOrder: AIIssue[]): string {
163
201
  return 'Start with the highest priority issue and work through them in order.'
164
202
  }
165
203
 
204
+ function fileAILikelihood(fileIssues: DriftIssue[]): { score: number; triggers: string[] } {
205
+ if (fileIssues.length === 0) return { score: 0, triggers: [] }
206
+ const triggerCounts = new Map<string, number>()
207
+ for (const issue of fileIssues) {
208
+ if (!AI_SIGNAL_RULES.has(issue.rule)) continue
209
+ triggerCounts.set(issue.rule, (triggerCounts.get(issue.rule) ?? 0) + 1)
210
+ }
211
+ const triggerTotal = [...triggerCounts.values()].reduce((sum, count) => sum + count, 0)
212
+ const smellBoost = fileIssues.some((issue) => issue.rule === 'ai-code-smell') ? 20 : 0
213
+ const ratioScore = Math.round((triggerTotal / Math.max(fileIssues.length, 1)) * 100)
214
+ const score = Math.max(0, Math.min(100, ratioScore + smellBoost))
215
+ const triggers = [...triggerCounts.entries()]
216
+ .sort((a, b) => b[1] - a[1])
217
+ .slice(0, 4)
218
+ .map(([rule]) => rule)
219
+ return { score, triggers }
220
+ }
221
+
222
+ function computeAILikelihood(report: DriftReport): {
223
+ overall: number
224
+ files: Array<{ path: string; ai_likelihood: number; triggers: string[] }>
225
+ smellScore: number
226
+ } {
227
+ const suspected = report.files
228
+ .map((file) => {
229
+ const likelihood = fileAILikelihood(file.issues)
230
+ return {
231
+ path: file.path,
232
+ ai_likelihood: likelihood.score,
233
+ triggers: likelihood.triggers,
234
+ }
235
+ })
236
+ .filter((entry) => entry.ai_likelihood >= 35)
237
+ .sort((a, b) => b.ai_likelihood - a.ai_likelihood)
238
+
239
+ const overall = suspected.length === 0
240
+ ? 0
241
+ : Math.round(suspected.reduce((sum, entry) => sum + entry.ai_likelihood, 0) / suspected.length)
242
+
243
+ const smellCount = report.files.flatMap((file) => file.issues).filter((issue) => issue.rule === 'ai-code-smell').length
244
+ const smellScore = Math.min(100, smellCount * 15)
245
+
246
+ return {
247
+ overall,
248
+ files: suspected.slice(0, 10),
249
+ smellScore,
250
+ }
251
+ }
252
+
166
253
  export function formatAIOutput(report: DriftReport): AIOutput {
167
254
  const allIssues = collectAllIssues(report)
168
255
  const sortedIssues = sortIssues(allIssues)
169
256
  const priorityOrder = sortedIssues.map((item, i) => buildAIIssue(item, i + 1))
170
257
  const rulesDetected = [...new Set(allIssues.map((i) => i.issue.rule))]
171
258
  const grade = scoreToGradeText(report.totalScore)
259
+ const aiLikelihood = computeAILikelihood(report)
172
260
 
173
261
  return {
174
262
  summary: {
@@ -177,8 +265,13 @@ export function formatAIOutput(report: DriftReport): AIOutput {
177
265
  total_issues: report.totalIssues,
178
266
  files_affected: report.files.length,
179
267
  files_clean: report.totalFiles - report.files.length,
268
+ ai_likelihood: aiLikelihood.overall,
269
+ ai_code_smell_score: aiLikelihood.smellScore,
180
270
  },
271
+ files_suspected: aiLikelihood.files,
181
272
  priority_order: priorityOrder,
273
+ maintenance_risk: report.maintenanceRisk,
274
+ quality: report.quality,
182
275
  context_for_ai: {
183
276
  project_type: 'typescript',
184
277
  scan_path: report.targetPath,
package/src/review.ts ADDED
@@ -0,0 +1,98 @@
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
+ import type { DriftDiff } from './types.js'
8
+
9
+ export interface DriftReview {
10
+ baseRef: string
11
+ scannedAt: string
12
+ totalDelta: number
13
+ newIssues: number
14
+ resolvedIssues: number
15
+ status: 'clean' | 'improved' | 'regressed'
16
+ summary: string
17
+ markdown: string
18
+ diff: DriftDiff
19
+ }
20
+
21
+ export function formatReviewMarkdown(review: DriftReview): string {
22
+ const trendIcon = review.status === 'regressed' ? '⚠️' : review.status === 'improved' ? '✅' : 'ℹ️'
23
+ const topFiles = review.diff.files
24
+ .slice(0, 8)
25
+ .map((file) => {
26
+ const sign = file.scoreDelta > 0 ? '+' : ''
27
+ return `- \`${file.path}\`: ${file.scoreBefore} -> ${file.scoreAfter} (${sign}${file.scoreDelta}), +${file.newIssues.length} new / -${file.resolvedIssues.length} resolved`
28
+ })
29
+ .join('\n')
30
+
31
+ return [
32
+ '## drift review',
33
+ '',
34
+ `${trendIcon} ${review.summary}`,
35
+ '',
36
+ `- Base ref: \`${review.baseRef}\``,
37
+ `- Score delta: **${review.totalDelta >= 0 ? '+' : ''}${review.totalDelta}**`,
38
+ `- New issues: **${review.newIssues}**`,
39
+ `- Resolved issues: **${review.resolvedIssues}**`,
40
+ '',
41
+ '### File breakdown',
42
+ topFiles || '- No file-level deltas detected',
43
+ ].join('\n')
44
+ }
45
+
46
+ function getStatus(totalDelta: number, newIssues: number): 'clean' | 'improved' | 'regressed' {
47
+ if (totalDelta > 0 || newIssues > 0) return 'regressed'
48
+ if (totalDelta < 0) return 'improved'
49
+ return 'clean'
50
+ }
51
+
52
+ export async function generateReview(projectPath: string, baseRef: string): Promise<DriftReview> {
53
+ const resolvedPath = resolve(projectPath)
54
+ const config = await loadConfig(resolvedPath)
55
+
56
+ const currentFiles = analyzeProject(resolvedPath, config)
57
+ const currentReport = buildReport(resolvedPath, currentFiles)
58
+
59
+ let tempDir: string | undefined
60
+ try {
61
+ tempDir = extractFilesAtRef(resolvedPath, baseRef)
62
+ const baseFiles = analyzeProject(tempDir, config)
63
+ const baseReport = buildReport(tempDir, baseFiles)
64
+
65
+ const remappedBase = {
66
+ ...baseReport,
67
+ files: baseReport.files.map((file) => ({
68
+ ...file,
69
+ path: file.path.replace(tempDir!, resolvedPath),
70
+ })),
71
+ }
72
+
73
+ const diff = computeDiff(remappedBase, currentReport, baseRef)
74
+ const status = getStatus(diff.totalDelta, diff.newIssuesCount)
75
+ const summary = status === 'regressed'
76
+ ? `Drift regressed: +${diff.totalDelta} score and ${diff.newIssuesCount} new issue(s).`
77
+ : status === 'improved'
78
+ ? `Drift improved: ${diff.totalDelta} score delta and ${diff.resolvedIssuesCount} issue(s) resolved.`
79
+ : 'No drift changes detected against base ref.'
80
+
81
+ const review: DriftReview = {
82
+ baseRef,
83
+ scannedAt: new Date().toISOString(),
84
+ totalDelta: diff.totalDelta,
85
+ newIssues: diff.newIssuesCount,
86
+ resolvedIssues: diff.resolvedIssuesCount,
87
+ status,
88
+ summary,
89
+ markdown: '',
90
+ diff,
91
+ }
92
+
93
+ review.markdown = formatReviewMarkdown(review)
94
+ return review
95
+ } finally {
96
+ if (tempDir) cleanupTempDir(tempDir)
97
+ }
98
+ }
@@ -0,0 +1,56 @@
1
+ import { SourceFile } from 'ts-morph'
2
+ import type { DriftIssue } from '../types.js'
3
+ import { hasIgnoreComment } from './shared.js'
4
+
5
+ const TRIVIAL_COMMENT_PATTERNS = [
6
+ { comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
7
+ { comment: /\/\/\s*(increment|increase|add\s+1|plus\s+1)\b/i, code: /\+\+|(\+= ?1)\b/ },
8
+ { comment: /\/\/\s*(decrement|decrease|subtract\s+1|minus\s+1)\b/i, code: /--|(-= ?1)\b/ },
9
+ { comment: /\/\/\s*log\b/i, code: /console\.(log|warn|error)/ },
10
+ { comment: /\/\/\s*(set|assign)\b/i, code: /^\s*\w[\w.[\]]*\s*=(?!=)/ },
11
+ { comment: /\/\/\s*call\b/i, code: /^\s*\w[\w.]*\(/ },
12
+ { comment: /\/\/\s*(declare|define|create|initialize)\b/i, code: /^\s*(const|let|var)\b/ },
13
+ { comment: /\/\/\s*check\s+if\b/i, code: /^\s*if\s*\(/ },
14
+ { comment: /\/\/\s*(loop|iterate|for each|foreach)\b/i, code: /^\s*(for|while)\b/ },
15
+ { comment: /\/\/\s*import\b/i, code: /^\s*import\b/ },
16
+ ]
17
+
18
+ const SNIPPET_TRUNCATE = 60
19
+
20
+ function checkLineForContradiction(
21
+ commentLine: string,
22
+ nextLine: string,
23
+ lineNumber: number,
24
+ file: SourceFile,
25
+ ): DriftIssue | null {
26
+ for (const { comment, code } of TRIVIAL_COMMENT_PATTERNS) {
27
+ if (comment.test(commentLine) && code.test(nextLine)) {
28
+ if (hasIgnoreComment(file, lineNumber)) return null
29
+ return {
30
+ rule: 'comment-contradiction',
31
+ severity: 'warning',
32
+ message: `Comment restates what the code already says. AI documents the obvious instead of the why.`,
33
+ line: lineNumber,
34
+ column: 1,
35
+ snippet: `${commentLine.slice(0, SNIPPET_TRUNCATE)}\n${nextLine.trim().slice(0, SNIPPET_TRUNCATE)}`,
36
+ }
37
+ }
38
+ }
39
+ return null
40
+ }
41
+
42
+ export function detectCommentContradiction(file: SourceFile): DriftIssue[] {
43
+ const issues: DriftIssue[] = []
44
+ const lines = file.getFullText().split('\n')
45
+
46
+ for (let i = 0; i < lines.length - 1; i++) {
47
+ const commentLine = lines[i].trim()
48
+ const nextLine = lines[i + 1]
49
+ const issue = checkLineForContradiction(commentLine, nextLine, i + 1, file)
50
+ if (issue) {
51
+ issues.push(issue)
52
+ }
53
+ }
54
+
55
+ return issues
56
+ }
@@ -0,0 +1,57 @@
1
+ import { SourceFile, SyntaxKind } from 'ts-morph'
2
+ import type { DriftIssue } from '../types.js'
3
+ import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
4
+
5
+ const COMPLEXITY_THRESHOLD = 10
6
+
7
+ const INCREMENT_KINDS = [
8
+ SyntaxKind.IfStatement,
9
+ SyntaxKind.ForStatement,
10
+ SyntaxKind.ForInStatement,
11
+ SyntaxKind.ForOfStatement,
12
+ SyntaxKind.WhileStatement,
13
+ SyntaxKind.DoStatement,
14
+ SyntaxKind.CaseClause,
15
+ SyntaxKind.CatchClause,
16
+ SyntaxKind.ConditionalExpression,
17
+ SyntaxKind.AmpersandAmpersandToken,
18
+ SyntaxKind.BarBarToken,
19
+ SyntaxKind.QuestionQuestionToken,
20
+ ]
21
+
22
+ function getCyclomaticComplexity(fn: FunctionLike): number {
23
+ let complexity = 1
24
+
25
+ for (const kind of INCREMENT_KINDS) {
26
+ complexity += fn.getDescendantsOfKind(kind).length
27
+ }
28
+
29
+ return complexity
30
+ }
31
+
32
+ export function detectHighComplexity(file: SourceFile): DriftIssue[] {
33
+ const issues: DriftIssue[] = []
34
+ const fns: FunctionLike[] = [
35
+ ...file.getFunctions(),
36
+ ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
37
+ ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
38
+ ...file.getClasses().flatMap((c) => c.getMethods()),
39
+ ]
40
+
41
+ for (const fn of fns) {
42
+ const complexity = getCyclomaticComplexity(fn)
43
+ if (complexity > COMPLEXITY_THRESHOLD) {
44
+ const startLine = fn.getStartLineNumber()
45
+ if (hasIgnoreComment(file, startLine)) continue
46
+ issues.push({
47
+ rule: 'high-complexity',
48
+ severity: 'error',
49
+ message: `Cyclomatic complexity is ${complexity} (threshold: ${COMPLEXITY_THRESHOLD}). AI generates correct code, not simple code.`,
50
+ line: startLine,
51
+ column: fn.getStartLinePos(),
52
+ snippet: getSnippet(fn, file),
53
+ })
54
+ }
55
+ }
56
+ return issues
57
+ }
@@ -0,0 +1,23 @@
1
+ import { SourceFile } from 'ts-morph'
2
+ import type { DriftIssue } from '../types.js'
3
+
4
+ const COUPLING_THRESHOLD = 10
5
+
6
+ export function detectHighCoupling(file: SourceFile): DriftIssue[] {
7
+ const imports = file.getImportDeclarations()
8
+ const sources = new Set(imports.map((i) => i.getModuleSpecifierValue()))
9
+
10
+ if (sources.size > COUPLING_THRESHOLD) {
11
+ return [
12
+ {
13
+ rule: 'high-coupling',
14
+ severity: 'warning',
15
+ message: `File imports from ${sources.size} distinct modules (threshold: ${COUPLING_THRESHOLD}). High coupling makes refactoring dangerous.`,
16
+ line: 1,
17
+ column: 1,
18
+ snippet: `// ${sources.size} import sources`,
19
+ },
20
+ ]
21
+ }
22
+ return []
23
+ }
@@ -0,0 +1,38 @@
1
+ import { SourceFile, SyntaxKind } from 'ts-morph'
2
+ import type { DriftIssue } from '../types.js'
3
+ import { hasIgnoreComment, getSnippet } from './shared.js'
4
+
5
+ const ALLOWED_NUMBERS = new Set([0, 1, -1, 2, 100])
6
+
7
+ export function detectMagicNumbers(file: SourceFile): DriftIssue[] {
8
+ const issues: DriftIssue[] = []
9
+
10
+ for (const node of file.getDescendantsOfKind(SyntaxKind.NumericLiteral)) {
11
+ const value = Number(node.getLiteralValue())
12
+ if (ALLOWED_NUMBERS.has(value)) continue
13
+
14
+ const parent = node.getParent()
15
+ if (!parent) continue
16
+
17
+ const parentKind = parent.getKind()
18
+ if (
19
+ parentKind === SyntaxKind.VariableDeclaration ||
20
+ parentKind === SyntaxKind.PropertyAssignment ||
21
+ parentKind === SyntaxKind.EnumMember ||
22
+ parentKind === SyntaxKind.Parameter
23
+ ) continue
24
+
25
+ const line = node.getStartLineNumber()
26
+ if (hasIgnoreComment(file, line)) continue
27
+
28
+ issues.push({
29
+ rule: 'magic-number',
30
+ severity: 'info',
31
+ message: `Magic number ${value} used directly in logic. Extract to a named constant.`,
32
+ line,
33
+ column: node.getStartLinePos(),
34
+ snippet: getSnippet(node, file),
35
+ })
36
+ }
37
+ return issues
38
+ }
@@ -0,0 +1,88 @@
1
+ import { SourceFile, SyntaxKind, Node } from 'ts-morph'
2
+ import type { DriftIssue } from '../types.js'
3
+ import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
4
+
5
+ const NESTING_THRESHOLD = 3
6
+ const PARAMS_THRESHOLD = 4
7
+
8
+ const NESTING_KINDS = new Set([
9
+ SyntaxKind.IfStatement,
10
+ SyntaxKind.ForStatement,
11
+ SyntaxKind.ForInStatement,
12
+ SyntaxKind.ForOfStatement,
13
+ SyntaxKind.WhileStatement,
14
+ SyntaxKind.DoStatement,
15
+ SyntaxKind.TryStatement,
16
+ SyntaxKind.SwitchStatement,
17
+ ])
18
+
19
+ function getMaxNestingDepth(fn: FunctionLike): number {
20
+ let maxDepth = 0
21
+
22
+ function walk(node: Node, depth: number): void {
23
+ if (NESTING_KINDS.has(node.getKind())) {
24
+ depth++
25
+ if (depth > maxDepth) maxDepth = depth
26
+ }
27
+ for (const child of node.getChildren()) {
28
+ walk(child, depth)
29
+ }
30
+ }
31
+
32
+ walk(fn, 0)
33
+ return maxDepth
34
+ }
35
+
36
+ export function detectDeepNesting(file: SourceFile): DriftIssue[] {
37
+ const issues: DriftIssue[] = []
38
+ const fns: FunctionLike[] = [
39
+ ...file.getFunctions(),
40
+ ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
41
+ ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
42
+ ...file.getClasses().flatMap((c) => c.getMethods()),
43
+ ]
44
+
45
+ for (const fn of fns) {
46
+ const depth = getMaxNestingDepth(fn)
47
+ if (depth > NESTING_THRESHOLD) {
48
+ const startLine = fn.getStartLineNumber()
49
+ if (hasIgnoreComment(file, startLine)) continue
50
+ issues.push({
51
+ rule: 'deep-nesting',
52
+ severity: 'warning',
53
+ message: `Maximum nesting depth is ${depth} (threshold: ${NESTING_THRESHOLD}). Deep nesting is the #1 readability killer.`,
54
+ line: startLine,
55
+ column: fn.getStartLinePos(),
56
+ snippet: getSnippet(fn, file),
57
+ })
58
+ }
59
+ }
60
+ return issues
61
+ }
62
+
63
+ export function detectTooManyParams(file: SourceFile): DriftIssue[] {
64
+ const issues: DriftIssue[] = []
65
+ const fns: FunctionLike[] = [
66
+ ...file.getFunctions(),
67
+ ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
68
+ ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
69
+ ...file.getClasses().flatMap((c) => c.getMethods()),
70
+ ]
71
+
72
+ for (const fn of fns) {
73
+ const paramCount = fn.getParameters().length
74
+ if (paramCount > PARAMS_THRESHOLD) {
75
+ const startLine = fn.getStartLineNumber()
76
+ if (hasIgnoreComment(file, startLine)) continue
77
+ issues.push({
78
+ rule: 'too-many-params',
79
+ severity: 'warning',
80
+ message: `Function has ${paramCount} parameters (threshold: ${PARAMS_THRESHOLD}). AI avoids refactoring into options objects.`,
81
+ line: startLine,
82
+ column: fn.getStartLinePos(),
83
+ snippet: getSnippet(fn, file),
84
+ })
85
+ }
86
+ }
87
+ return issues
88
+ }
@@ -2,14 +2,19 @@ import { SourceFile, SyntaxKind } from 'ts-morph'
2
2
  import type { DriftIssue } from '../types.js'
3
3
  import { hasIgnoreComment, getSnippet, getFunctionLikeLines, type FunctionLike } from './shared.js'
4
4
 
5
+ const LARGE_FILE_THRESHOLD = 300
6
+ const LARGE_FUNCTION_THRESHOLD = 50
7
+ const SNIPPET_TRUNCATE_SHORT = 60
8
+ const SNIPPET_TRUNCATE_LONG = 120
9
+
5
10
  export function detectLargeFile(file: SourceFile): DriftIssue[] {
6
11
  const lineCount = file.getEndLineNumber()
7
- if (lineCount > 300) {
12
+ if (lineCount > LARGE_FILE_THRESHOLD) {
8
13
  return [
9
14
  {
10
15
  rule: 'large-file',
11
16
  severity: 'error',
12
- message: `File has ${lineCount} lines (threshold: 300). Large files are the #1 sign of AI-generated structural drift.`,
17
+ message: `File has ${lineCount} lines (threshold: ${LARGE_FILE_THRESHOLD}). Large files are the #1 sign of AI-generated structural drift.`,
13
18
  line: 1,
14
19
  column: 1,
15
20
  snippet: `// ${lineCount} lines total`,
@@ -31,12 +36,12 @@ export function detectLargeFunctions(file: SourceFile): DriftIssue[] {
31
36
  for (const fn of fns) {
32
37
  const lines = getFunctionLikeLines(fn)
33
38
  const startLine = fn.getStartLineNumber()
34
- if (lines > 50) {
39
+ if (lines > LARGE_FUNCTION_THRESHOLD) {
35
40
  if (hasIgnoreComment(file, startLine)) continue
36
41
  issues.push({
37
42
  rule: 'large-function',
38
43
  severity: 'error',
39
- message: `Function spans ${lines} lines (threshold: 50). AI tends to dump logic into single functions.`,
44
+ message: `Function spans ${lines} lines (threshold: ${LARGE_FUNCTION_THRESHOLD}). AI tends to dump logic into single functions.`,
40
45
  line: startLine,
41
46
  column: fn.getStartLinePos(),
42
47
  snippet: getSnippet(fn, file),
@@ -72,10 +77,10 @@ export function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
72
77
  issues.push({
73
78
  rule: 'debug-leftover',
74
79
  severity: 'warning',
75
- message: `Unresolved marker found: ${lineContent.trim().slice(0, 60)}`,
80
+ message: `Unresolved marker found: ${lineContent.trim().slice(0, SNIPPET_TRUNCATE_SHORT)}`,
76
81
  line: i + 1,
77
82
  column: 1,
78
- snippet: lineContent.trim().slice(0, 120),
83
+ snippet: lineContent.trim().slice(0, SNIPPET_TRUNCATE_LONG),
79
84
  })
80
85
  }
81
86
  })
@@ -156,11 +161,13 @@ export function detectCatchSwallow(file: SourceFile): DriftIssue[] {
156
161
  const block = catchClause.getBlock()
157
162
  const stmts = block.getStatements()
158
163
  if (stmts.length === 0) {
164
+ const line = catchClause.getStartLineNumber()
165
+ if (hasIgnoreComment(file, line)) continue
159
166
  issues.push({
160
167
  rule: 'catch-swallow',
161
168
  severity: 'warning',
162
169
  message: `Empty catch block silently swallows errors. Classic AI pattern to make code "not throw".`,
163
- line: catchClause.getStartLineNumber(),
170
+ line,
164
171
  column: catchClause.getStartLinePos(),
165
172
  snippet: getSnippet(catchClause, file),
166
173
  })