@eduardbar/drift 1.2.0 → 1.4.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 (195) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +60 -0
  3. package/.github/actions/drift-review/action.yml +131 -0
  4. package/.github/actions/drift-scan/README.md +28 -32
  5. package/.github/actions/drift-scan/action.yml +78 -14
  6. package/.github/workflows/publish-vscode.yml +3 -3
  7. package/.github/workflows/publish.yml +3 -3
  8. package/.github/workflows/review-pr.yml +94 -9
  9. package/AGENTS.md +75 -245
  10. package/CHANGELOG.md +28 -0
  11. package/README.md +308 -51
  12. package/ROADMAP.md +6 -5
  13. package/dist/analyzer.d.ts +2 -2
  14. package/dist/analyzer.js +420 -159
  15. package/dist/benchmark.d.ts +2 -0
  16. package/dist/benchmark.js +204 -0
  17. package/dist/cli.js +693 -67
  18. package/dist/config.js +16 -2
  19. package/dist/diff.js +66 -10
  20. package/dist/doctor.d.ts +5 -0
  21. package/dist/doctor.js +133 -0
  22. package/dist/format.d.ts +17 -0
  23. package/dist/format.js +45 -0
  24. package/dist/git.js +12 -0
  25. package/dist/guard-types.d.ts +57 -0
  26. package/dist/guard-types.js +2 -0
  27. package/dist/guard.d.ts +14 -0
  28. package/dist/guard.js +239 -0
  29. package/dist/index.d.ts +12 -3
  30. package/dist/index.js +6 -1
  31. package/dist/init.d.ts +15 -0
  32. package/dist/init.js +273 -0
  33. package/dist/map-cycles.d.ts +2 -0
  34. package/dist/map-cycles.js +34 -0
  35. package/dist/map-svg.d.ts +19 -0
  36. package/dist/map-svg.js +97 -0
  37. package/dist/map.js +78 -138
  38. package/dist/metrics.js +70 -55
  39. package/dist/output-metadata.d.ts +13 -0
  40. package/dist/output-metadata.js +17 -0
  41. package/dist/plugins-capabilities.d.ts +4 -0
  42. package/dist/plugins-capabilities.js +21 -0
  43. package/dist/plugins-messages.d.ts +10 -0
  44. package/dist/plugins-messages.js +16 -0
  45. package/dist/plugins-rules.d.ts +9 -0
  46. package/dist/plugins-rules.js +137 -0
  47. package/dist/plugins.d.ts +2 -1
  48. package/dist/plugins.js +80 -28
  49. package/dist/printer.js +4 -0
  50. package/dist/reporter-constants.d.ts +16 -0
  51. package/dist/reporter-constants.js +39 -0
  52. package/dist/reporter.d.ts +3 -3
  53. package/dist/reporter.js +35 -55
  54. package/dist/review.d.ts +2 -1
  55. package/dist/review.js +4 -3
  56. package/dist/rules/comments.js +2 -2
  57. package/dist/rules/complexity.js +2 -7
  58. package/dist/rules/nesting.js +3 -13
  59. package/dist/rules/phase0-basic.js +10 -10
  60. package/dist/rules/phase3-configurable.js +23 -15
  61. package/dist/rules/shared.d.ts +2 -0
  62. package/dist/rules/shared.js +27 -3
  63. package/dist/saas/constants.d.ts +15 -0
  64. package/dist/saas/constants.js +48 -0
  65. package/dist/saas/dashboard.d.ts +8 -0
  66. package/dist/saas/dashboard.js +132 -0
  67. package/dist/saas/errors.d.ts +19 -0
  68. package/dist/saas/errors.js +37 -0
  69. package/dist/saas/helpers.d.ts +21 -0
  70. package/dist/saas/helpers.js +110 -0
  71. package/dist/saas/ingest.d.ts +3 -0
  72. package/dist/saas/ingest.js +249 -0
  73. package/dist/saas/organization.d.ts +5 -0
  74. package/dist/saas/organization.js +82 -0
  75. package/dist/saas/plan-change.d.ts +10 -0
  76. package/dist/saas/plan-change.js +15 -0
  77. package/dist/saas/store.d.ts +21 -0
  78. package/dist/saas/store.js +159 -0
  79. package/dist/saas/types.d.ts +191 -0
  80. package/dist/saas/types.js +2 -0
  81. package/dist/saas.d.ts +8 -82
  82. package/dist/saas.js +7 -320
  83. package/dist/sarif.d.ts +74 -0
  84. package/dist/sarif.js +122 -0
  85. package/dist/trust-advanced.d.ts +14 -0
  86. package/dist/trust-advanced.js +65 -0
  87. package/dist/trust-kpi-fs.d.ts +3 -0
  88. package/dist/trust-kpi-fs.js +141 -0
  89. package/dist/trust-kpi-parse.d.ts +7 -0
  90. package/dist/trust-kpi-parse.js +186 -0
  91. package/dist/trust-kpi-types.d.ts +16 -0
  92. package/dist/trust-kpi-types.js +2 -0
  93. package/dist/trust-kpi.d.ts +7 -0
  94. package/dist/trust-kpi.js +185 -0
  95. package/dist/trust-policy.d.ts +32 -0
  96. package/dist/trust-policy.js +160 -0
  97. package/dist/trust-render.d.ts +9 -0
  98. package/dist/trust-render.js +54 -0
  99. package/dist/trust-scoring.d.ts +9 -0
  100. package/dist/trust-scoring.js +208 -0
  101. package/dist/trust.d.ts +37 -0
  102. package/dist/trust.js +168 -0
  103. package/dist/types/app.d.ts +30 -0
  104. package/dist/types/app.js +2 -0
  105. package/dist/types/config.d.ts +25 -0
  106. package/dist/types/config.js +2 -0
  107. package/dist/types/core.d.ts +100 -0
  108. package/dist/types/core.js +2 -0
  109. package/dist/types/diff.d.ts +55 -0
  110. package/dist/types/diff.js +2 -0
  111. package/dist/types/plugin.d.ts +41 -0
  112. package/dist/types/plugin.js +2 -0
  113. package/dist/types/trust.d.ts +120 -0
  114. package/dist/types/trust.js +2 -0
  115. package/dist/types.d.ts +8 -211
  116. package/docs/PRD.md +187 -109
  117. package/docs/plugin-contract.md +61 -0
  118. package/docs/release-notes-draft.md +40 -0
  119. package/docs/rules-catalog.md +49 -0
  120. package/docs/trust-core-release-checklist.md +87 -0
  121. package/package.json +6 -3
  122. package/packages/vscode-drift/src/code-actions.ts +1 -1
  123. package/schemas/drift-ai-output.v1.json +162 -0
  124. package/schemas/drift-report.v1.json +151 -0
  125. package/schemas/drift-trust.v1.json +131 -0
  126. package/scripts/smoke-repo.mjs +394 -0
  127. package/src/analyzer.ts +484 -155
  128. package/src/benchmark.ts +266 -0
  129. package/src/cli.ts +840 -85
  130. package/src/config.ts +19 -2
  131. package/src/diff.ts +84 -10
  132. package/src/doctor.ts +173 -0
  133. package/src/format.ts +81 -0
  134. package/src/git.ts +16 -0
  135. package/src/guard-types.ts +64 -0
  136. package/src/guard.ts +324 -0
  137. package/src/index.ts +83 -0
  138. package/src/init.ts +298 -0
  139. package/src/map-cycles.ts +38 -0
  140. package/src/map-svg.ts +124 -0
  141. package/src/map.ts +111 -142
  142. package/src/metrics.ts +78 -59
  143. package/src/output-metadata.ts +30 -0
  144. package/src/plugins-capabilities.ts +36 -0
  145. package/src/plugins-messages.ts +35 -0
  146. package/src/plugins-rules.ts +296 -0
  147. package/src/plugins.ts +148 -27
  148. package/src/printer.ts +4 -0
  149. package/src/reporter-constants.ts +46 -0
  150. package/src/reporter.ts +64 -65
  151. package/src/review.ts +6 -4
  152. package/src/rules/comments.ts +2 -2
  153. package/src/rules/complexity.ts +2 -7
  154. package/src/rules/nesting.ts +3 -13
  155. package/src/rules/phase0-basic.ts +11 -12
  156. package/src/rules/phase3-configurable.ts +39 -26
  157. package/src/rules/shared.ts +31 -3
  158. package/src/saas/constants.ts +56 -0
  159. package/src/saas/dashboard.ts +172 -0
  160. package/src/saas/errors.ts +45 -0
  161. package/src/saas/helpers.ts +140 -0
  162. package/src/saas/ingest.ts +278 -0
  163. package/src/saas/organization.ts +99 -0
  164. package/src/saas/plan-change.ts +19 -0
  165. package/src/saas/store.ts +172 -0
  166. package/src/saas/types.ts +216 -0
  167. package/src/saas.ts +49 -433
  168. package/src/sarif.ts +232 -0
  169. package/src/trust-advanced.ts +99 -0
  170. package/src/trust-kpi-fs.ts +169 -0
  171. package/src/trust-kpi-parse.ts +219 -0
  172. package/src/trust-kpi-types.ts +19 -0
  173. package/src/trust-kpi.ts +210 -0
  174. package/src/trust-policy.ts +246 -0
  175. package/src/trust-render.ts +61 -0
  176. package/src/trust-scoring.ts +231 -0
  177. package/src/trust.ts +260 -0
  178. package/src/types/app.ts +30 -0
  179. package/src/types/config.ts +27 -0
  180. package/src/types/core.ts +105 -0
  181. package/src/types/diff.ts +61 -0
  182. package/src/types/plugin.ts +46 -0
  183. package/src/types/trust.ts +134 -0
  184. package/src/types.ts +78 -238
  185. package/tests/cli-sarif.test.ts +92 -0
  186. package/tests/diff.test.ts +124 -0
  187. package/tests/format.test.ts +157 -0
  188. package/tests/new-features.test.ts +80 -1
  189. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  190. package/tests/plugins.test.ts +219 -0
  191. package/tests/rules.test.ts +23 -1
  192. package/tests/saas-foundation.test.ts +358 -1
  193. package/tests/sarif.test.ts +160 -0
  194. package/tests/trust-kpi.test.ts +147 -0
  195. package/tests/trust.test.ts +602 -0
package/src/reporter.ts CHANGED
@@ -1,71 +1,60 @@
1
- import type { FileReport, DriftReport, DriftIssue, AIOutput, AIIssue } from './types.js'
1
+ import type {
2
+ FileReport,
3
+ DriftReport,
4
+ DriftIssue,
5
+ AIOutput,
6
+ AIIssue,
7
+ AIOutputJson,
8
+ DriftReportJson,
9
+ } from './types.js'
2
10
  import { scoreToGradeText, severityIcon } from './utils.js'
3
11
  import { computeRepoQuality, computeMaintenanceRisk } from './metrics.js'
12
+ import { OUTPUT_SCHEMA, withOutputMetadata } from './output-metadata.js'
13
+ import {
14
+ AI_CODE_SMELL_BOOST,
15
+ AI_LIKELIHOOD_THRESHOLD,
16
+ AI_SIGNAL_RULES,
17
+ AI_SMELL_SCORE_MULTIPLIER,
18
+ AI_SUSPECTED_LIMIT,
19
+ AI_TRIGGER_LIMIT,
20
+ EFFORT_ORDER,
21
+ FIX_SUGGESTIONS,
22
+ RULE_EFFORT,
23
+ SEVERITY_ORDER,
24
+ type DriftIssueWithFile,
25
+ } from './reporter-constants.js'
4
26
 
5
- const FIX_SUGGESTIONS: Record<string, string> = {
6
- 'large-file': 'Consider splitting this file into smaller modules with single responsibility',
7
- 'large-function': 'Extract logic into smaller functions with descriptive names',
8
- 'debug-leftover': 'Remove this console.log or replace with proper logging library',
9
- 'dead-code': 'Remove unused import to keep code clean',
10
- 'duplicate-function-name': 'Consolidate with existing function or rename to clarify different behavior',
11
- 'any-abuse': "Replace 'any' with proper type definition",
12
- 'catch-swallow': 'Add error handling or logging in catch block',
13
- 'no-return-type': 'Add explicit return type for better type safety',
14
- }
15
-
16
- const RULE_EFFORT: Record<string, 'low' | 'medium' | 'high'> = {
17
- 'debug-leftover': 'low',
18
- 'dead-code': 'low',
19
- 'no-return-type': 'low',
20
- 'any-abuse': 'medium',
21
- 'catch-swallow': 'medium',
22
- 'large-file': 'high',
23
- 'large-function': 'high',
24
- 'duplicate-function-name': 'high',
25
- }
26
-
27
- const SEVERITY_ORDER: Record<string, number> = { error: 0, warning: 1, info: 2 }
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
- ])
40
-
41
- export function buildReport(targetPath: string, files: FileReport[]): DriftReport {
42
- const allIssues = files.flatMap((f) => f.issues)
27
+ function summarizeIssues(allIssues: DriftIssue[]): DriftReport['summary'] {
43
28
  const byRule: Record<string, number> = {}
44
29
 
45
30
  for (const issue of allIssues) {
46
31
  byRule[issue.rule] = (byRule[issue.rule] ?? 0) + 1
47
32
  }
48
33
 
49
- const totalScore =
50
- files.length > 0
51
- ? Math.round(files.reduce((sum, f) => sum + f.score, 0) / files.length)
52
- : 0
34
+ return {
35
+ errors: allIssues.filter((issue) => issue.severity === 'error').length,
36
+ warnings: allIssues.filter((issue) => issue.severity === 'warning').length,
37
+ infos: allIssues.filter((issue) => issue.severity === 'info').length,
38
+ byRule,
39
+ }
40
+ }
53
41
 
54
- const sortedFiles = files.filter((f) => f.issues.length > 0).sort((a, b) => b.score - a.score)
42
+ function calculateTotalScore(files: FileReport[]): number {
43
+ if (files.length === 0) return 0
44
+ return Math.round(files.reduce((sum, file) => sum + file.score, 0) / files.length)
45
+ }
55
46
 
56
- const baseReport: DriftReport = {
47
+ function baseReportDefaults(summary: DriftReport['summary'], targetPath: string, files: FileReport[]): DriftReportJson {
48
+ const filesWithIssues = files.filter((file) => file.issues.length > 0).sort((a, b) => b.score - a.score)
49
+
50
+ const report: DriftReport = {
57
51
  scannedAt: new Date().toISOString(),
58
52
  targetPath,
59
- files: sortedFiles,
60
- totalIssues: allIssues.length,
61
- totalScore,
53
+ files: filesWithIssues,
54
+ totalIssues: files.flatMap((file) => file.issues).length,
55
+ totalScore: calculateTotalScore(files),
62
56
  totalFiles: files.length,
63
- summary: {
64
- errors: allIssues.filter((i) => i.severity === 'error').length,
65
- warnings: allIssues.filter((i) => i.severity === 'warning').length,
66
- infos: allIssues.filter((i) => i.severity === 'info').length,
67
- byRule,
68
- },
57
+ summary,
69
58
  quality: {
70
59
  overall: 100,
71
60
  dimensions: {
@@ -87,6 +76,14 @@ export function buildReport(targetPath: string, files: FileReport[]): DriftRepor
87
76
  },
88
77
  }
89
78
 
79
+ return withOutputMetadata(report, OUTPUT_SCHEMA.report)
80
+ }
81
+
82
+ export function buildReport(targetPath: string, files: FileReport[]): DriftReportJson {
83
+ const allIssues = files.flatMap((f) => f.issues)
84
+ const summary = summarizeIssues(allIssues)
85
+ const baseReport = baseReportDefaults(summary, targetPath, files)
86
+
90
87
  baseReport.quality = computeRepoQuality(targetPath, files)
91
88
  baseReport.maintenanceRisk = computeMaintenanceRisk(baseReport)
92
89
 
@@ -158,8 +155,8 @@ export function formatMarkdown(report: DriftReport): string {
158
155
  return lines.join('\n')
159
156
  }
160
157
 
161
- function collectAllIssues(report: DriftReport): Array<{ file: string; issue: DriftIssue }> {
162
- const all: Array<{ file: string; issue: DriftIssue }> = []
158
+ function collectAllIssues(report: DriftReport): DriftIssueWithFile[] {
159
+ const all: DriftIssueWithFile[] = []
163
160
  for (const file of report.files) {
164
161
  for (const issue of file.issues) {
165
162
  all.push({ file: file.path, issue })
@@ -168,7 +165,7 @@ function collectAllIssues(report: DriftReport): Array<{ file: string; issue: Dri
168
165
  return all
169
166
  }
170
167
 
171
- function sortIssues(issues: Array<{ file: string; issue: DriftIssue }>): Array<{ file: string; issue: DriftIssue }> {
168
+ function sortIssues(issues: DriftIssueWithFile[]): DriftIssueWithFile[] {
172
169
  return issues.sort((a, b) => {
173
170
  const sevDiff = SEVERITY_ORDER[a.issue.severity] - SEVERITY_ORDER[b.issue.severity]
174
171
  if (sevDiff !== 0) return sevDiff
@@ -178,7 +175,7 @@ function sortIssues(issues: Array<{ file: string; issue: DriftIssue }>): Array<{
178
175
  })
179
176
  }
180
177
 
181
- function buildAIIssue(item: { file: string; issue: DriftIssue }, rank: number): AIIssue {
178
+ function buildAIIssue(item: DriftIssueWithFile, rank: number): AIIssue {
182
179
  return {
183
180
  rank,
184
181
  file: item.file,
@@ -209,12 +206,12 @@ function fileAILikelihood(fileIssues: DriftIssue[]): { score: number; triggers:
209
206
  triggerCounts.set(issue.rule, (triggerCounts.get(issue.rule) ?? 0) + 1)
210
207
  }
211
208
  const triggerTotal = [...triggerCounts.values()].reduce((sum, count) => sum + count, 0)
212
- const smellBoost = fileIssues.some((issue) => issue.rule === 'ai-code-smell') ? 20 : 0
209
+ const smellBoost = fileIssues.some((issue) => issue.rule === 'ai-code-smell') ? AI_CODE_SMELL_BOOST : 0
213
210
  const ratioScore = Math.round((triggerTotal / Math.max(fileIssues.length, 1)) * 100)
214
211
  const score = Math.max(0, Math.min(100, ratioScore + smellBoost))
215
212
  const triggers = [...triggerCounts.entries()]
216
213
  .sort((a, b) => b[1] - a[1])
217
- .slice(0, 4)
214
+ .slice(0, AI_TRIGGER_LIMIT)
218
215
  .map(([rule]) => rule)
219
216
  return { score, triggers }
220
217
  }
@@ -233,7 +230,7 @@ function computeAILikelihood(report: DriftReport): {
233
230
  triggers: likelihood.triggers,
234
231
  }
235
232
  })
236
- .filter((entry) => entry.ai_likelihood >= 35)
233
+ .filter((entry) => entry.ai_likelihood >= AI_LIKELIHOOD_THRESHOLD)
237
234
  .sort((a, b) => b.ai_likelihood - a.ai_likelihood)
238
235
 
239
236
  const overall = suspected.length === 0
@@ -241,16 +238,16 @@ function computeAILikelihood(report: DriftReport): {
241
238
  : Math.round(suspected.reduce((sum, entry) => sum + entry.ai_likelihood, 0) / suspected.length)
242
239
 
243
240
  const smellCount = report.files.flatMap((file) => file.issues).filter((issue) => issue.rule === 'ai-code-smell').length
244
- const smellScore = Math.min(100, smellCount * 15)
241
+ const smellScore = Math.min(100, smellCount * AI_SMELL_SCORE_MULTIPLIER)
245
242
 
246
243
  return {
247
244
  overall,
248
- files: suspected.slice(0, 10),
245
+ files: suspected.slice(0, AI_SUSPECTED_LIMIT),
249
246
  smellScore,
250
247
  }
251
248
  }
252
249
 
253
- export function formatAIOutput(report: DriftReport): AIOutput {
250
+ export function formatAIOutput(report: DriftReport): AIOutputJson {
254
251
  const allIssues = collectAllIssues(report)
255
252
  const sortedIssues = sortIssues(allIssues)
256
253
  const priorityOrder = sortedIssues.map((item, i) => buildAIIssue(item, i + 1))
@@ -258,7 +255,7 @@ export function formatAIOutput(report: DriftReport): AIOutput {
258
255
  const grade = scoreToGradeText(report.totalScore)
259
256
  const aiLikelihood = computeAILikelihood(report)
260
257
 
261
- return {
258
+ const output: AIOutput = {
262
259
  summary: {
263
260
  score: report.totalScore,
264
261
  grade: grade.label.toUpperCase(),
@@ -279,4 +276,6 @@ export function formatAIOutput(report: DriftReport): AIOutput {
279
276
  recommended_action: buildRecommendedAction(priorityOrder),
280
277
  },
281
278
  }
279
+
280
+ return withOutputMetadata(output, OUTPUT_SCHEMA.ai)
282
281
  }
package/src/review.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { resolve } from 'node:path'
1
+ import { relative, resolve } from 'node:path'
2
2
  import { analyzeProject } from './analyzer.js'
3
3
  import { loadConfig } from './config.js'
4
4
  import { buildReport } from './reporter.js'
@@ -6,7 +6,7 @@ import { cleanupTempDir, extractFilesAtRef } from './git.js'
6
6
  import { computeDiff } from './diff.js'
7
7
  import type { DriftDiff } from './types.js'
8
8
 
9
- export interface DriftReview {
9
+ interface DriftReview {
10
10
  baseRef: string
11
11
  scannedAt: string
12
12
  totalDelta: number
@@ -18,10 +18,12 @@ export interface DriftReview {
18
18
  diff: DriftDiff
19
19
  }
20
20
 
21
+ const REVIEW_TOP_FILES_LIMIT = 8
22
+
21
23
  export function formatReviewMarkdown(review: DriftReview): string {
22
24
  const trendIcon = review.status === 'regressed' ? '⚠️' : review.status === 'improved' ? '✅' : 'ℹ️'
23
25
  const topFiles = review.diff.files
24
- .slice(0, 8)
26
+ .slice(0, REVIEW_TOP_FILES_LIMIT)
25
27
  .map((file) => {
26
28
  const sign = file.scoreDelta > 0 ? '+' : ''
27
29
  return `- \`${file.path}\`: ${file.scoreBefore} -> ${file.scoreAfter} (${sign}${file.scoreDelta}), +${file.newIssues.length} new / -${file.resolvedIssues.length} resolved`
@@ -66,7 +68,7 @@ export async function generateReview(projectPath: string, baseRef: string): Prom
66
68
  ...baseReport,
67
69
  files: baseReport.files.map((file) => ({
68
70
  ...file,
69
- path: file.path.replace(tempDir!, resolvedPath),
71
+ path: resolve(resolvedPath, relative(tempDir!, file.path)),
70
72
  })),
71
73
  }
72
74
 
@@ -1,6 +1,6 @@
1
1
  import { SourceFile } from 'ts-morph'
2
2
  import type { DriftIssue } from '../types.js'
3
- import { hasIgnoreComment } from './shared.js'
3
+ import { hasIgnoreComment, getFileLines } from './shared.js'
4
4
 
5
5
  const TRIVIAL_COMMENT_PATTERNS = [
6
6
  { comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
@@ -41,7 +41,7 @@ function checkLineForContradiction(
41
41
 
42
42
  export function detectCommentContradiction(file: SourceFile): DriftIssue[] {
43
43
  const issues: DriftIssue[] = []
44
- const lines = file.getFullText().split('\n')
44
+ const lines = getFileLines(file)
45
45
 
46
46
  for (let i = 0; i < lines.length - 1; i++) {
47
47
  const commentLine = lines[i].trim()
@@ -1,6 +1,6 @@
1
1
  import { SourceFile, SyntaxKind } from 'ts-morph'
2
2
  import type { DriftIssue } from '../types.js'
3
- import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
3
+ import { hasIgnoreComment, getSnippet, collectFunctionLikes, type FunctionLike } from './shared.js'
4
4
 
5
5
  const COMPLEXITY_THRESHOLD = 10
6
6
 
@@ -31,12 +31,7 @@ function getCyclomaticComplexity(fn: FunctionLike): number {
31
31
 
32
32
  export function detectHighComplexity(file: SourceFile): DriftIssue[] {
33
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
- ]
34
+ const fns: FunctionLike[] = collectFunctionLikes(file)
40
35
 
41
36
  for (const fn of fns) {
42
37
  const complexity = getCyclomaticComplexity(fn)
@@ -1,6 +1,6 @@
1
1
  import { SourceFile, SyntaxKind, Node } from 'ts-morph'
2
2
  import type { DriftIssue } from '../types.js'
3
- import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
3
+ import { hasIgnoreComment, getSnippet, collectFunctionLikes, type FunctionLike } from './shared.js'
4
4
 
5
5
  const NESTING_THRESHOLD = 3
6
6
  const PARAMS_THRESHOLD = 4
@@ -35,12 +35,7 @@ function getMaxNestingDepth(fn: FunctionLike): number {
35
35
 
36
36
  export function detectDeepNesting(file: SourceFile): DriftIssue[] {
37
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
- ]
38
+ const fns: FunctionLike[] = collectFunctionLikes(file)
44
39
 
45
40
  for (const fn of fns) {
46
41
  const depth = getMaxNestingDepth(fn)
@@ -62,12 +57,7 @@ export function detectDeepNesting(file: SourceFile): DriftIssue[] {
62
57
 
63
58
  export function detectTooManyParams(file: SourceFile): DriftIssue[] {
64
59
  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
- ]
60
+ const fns: FunctionLike[] = collectFunctionLikes(file)
71
61
 
72
62
  for (const fn of fns) {
73
63
  const paramCount = fn.getParameters().length
@@ -1,6 +1,6 @@
1
1
  import { SourceFile, SyntaxKind } from 'ts-morph'
2
2
  import type { DriftIssue } from '../types.js'
3
- import { hasIgnoreComment, getSnippet, getFunctionLikeLines, type FunctionLike } from './shared.js'
3
+ import { hasIgnoreComment, getSnippet, getFunctionLikeLines, collectFunctionLikes, getFileLines } from './shared.js'
4
4
 
5
5
  const LARGE_FILE_THRESHOLD = 300
6
6
  const LARGE_FUNCTION_THRESHOLD = 50
@@ -26,12 +26,7 @@ export function detectLargeFile(file: SourceFile): DriftIssue[] {
26
26
 
27
27
  export function detectLargeFunctions(file: SourceFile): DriftIssue[] {
28
28
  const issues: DriftIssue[] = []
29
- const fns: FunctionLike[] = [
30
- ...file.getFunctions(),
31
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
32
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
33
- ...file.getClasses().flatMap((c) => c.getMethods()),
34
- ]
29
+ const fns = collectFunctionLikes(file)
35
30
 
36
31
  for (const fn of fns) {
37
32
  const lines = getFunctionLikeLines(fn)
@@ -70,7 +65,7 @@ export function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
70
65
  }
71
66
  }
72
67
 
73
- const lines = file.getFullText().split('\n')
68
+ const lines = getFileLines(file)
74
69
  lines.forEach((lineContent, i) => {
75
70
  if (/\/\/\s*(TODO|FIXME|HACK|XXX|TEMP)\b/i.test(lineContent)) {
76
71
  if (hasIgnoreComment(file, i + 1)) return
@@ -90,14 +85,18 @@ export function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
90
85
 
91
86
  export function detectDeadCode(file: SourceFile): DriftIssue[] {
92
87
  const issues: DriftIssue[] = []
88
+ const identifierCounts = new Map<string, number>()
89
+
90
+ for (const id of file.getDescendantsOfKind(SyntaxKind.Identifier)) {
91
+ const text = id.getText()
92
+ identifierCounts.set(text, (identifierCounts.get(text) ?? 0) + 1)
93
+ }
93
94
 
94
95
  for (const imp of file.getImportDeclarations()) {
95
96
  for (const named of imp.getNamedImports()) {
96
97
  const name = named.getName()
97
- const refs = file.getDescendantsOfKind(SyntaxKind.Identifier).filter(
98
- (id) => id.getText() === name && id !== named.getNameNode()
99
- )
100
- if (refs.length === 0) {
98
+ const refsCount = Math.max(0, (identifierCounts.get(name) ?? 0) - 1)
99
+ if (refsCount === 0) {
101
100
  issues.push({
102
101
  rule: 'dead-code',
103
102
  severity: 'warning',
@@ -23,12 +23,14 @@ const HTTP_IMPORT_PATTERNS = [
23
23
 
24
24
  function isControllerFile(filePath: string): boolean {
25
25
  const normalized = filePath.replace(/\\/g, '/').toLowerCase()
26
- return normalized.includes('/controller/') || normalized.includes('/controllers/') || normalized.endsWith('controller.ts') || normalized.endsWith('controller.js')
26
+ const segments = normalized.split('/')
27
+ return segments.includes('controller') || segments.includes('controllers') || normalized.endsWith('controller.ts') || normalized.endsWith('controller.js')
27
28
  }
28
29
 
29
30
  function isServiceFile(filePath: string): boolean {
30
31
  const normalized = filePath.replace(/\\/g, '/').toLowerCase()
31
- return normalized.includes('/service/') || normalized.includes('/services/') || normalized.endsWith('service.ts') || normalized.endsWith('service.js')
32
+ const segments = normalized.split('/')
33
+ return segments.includes('service') || segments.includes('services') || normalized.endsWith('service.ts') || normalized.endsWith('service.js')
32
34
  }
33
35
 
34
36
  function createIssue(rule: string, message: string, line: number, snippet: string): DriftIssue {
@@ -100,33 +102,44 @@ export function detectMaxFunctionLines(file: SourceFile, config?: DriftConfig):
100
102
 
101
103
  const issues: DriftIssue[] = []
102
104
 
105
+ collectFunctionLineIssues(file, maxLines, issues)
106
+ collectMethodLineIssues(file, maxLines, issues)
107
+
108
+ return issues
109
+ }
110
+
111
+ function countBodyLines(
112
+ body: ReturnType<import('ts-morph').FunctionDeclaration['getBody']> | ReturnType<import('ts-morph').MethodDeclaration['getBody']>,
113
+ ): number {
114
+ if (!body) return 0
115
+ return body.getEndLineNumber() - body.getStartLineNumber() - 1
116
+ }
117
+
118
+ function collectFunctionLineIssues(file: SourceFile, maxLines: number, issues: DriftIssue[]): void {
103
119
  for (const fn of file.getFunctions()) {
104
- const body = fn.getBody()
105
- if (!body) continue
106
- const lines = body.getEndLineNumber() - body.getStartLineNumber() - 1
107
- if (lines > maxLines) {
108
- issues.push(createIssue(
109
- 'max-function-lines',
110
- `Function '${fn.getName() ?? '(anonymous)'}' has ${lines} lines (max: ${maxLines}).`,
111
- fn.getStartLineNumber(),
112
- fn.getName() ?? '(anonymous)',
113
- ))
114
- }
120
+ const lines = countBodyLines(fn.getBody())
121
+ if (lines <= maxLines) continue
122
+
123
+ const functionName = fn.getName() ?? '(anonymous)'
124
+ issues.push(createIssue(
125
+ 'max-function-lines',
126
+ `Function '${functionName}' has ${lines} lines (max: ${maxLines}).`,
127
+ fn.getStartLineNumber(),
128
+ functionName,
129
+ ))
115
130
  }
131
+ }
116
132
 
133
+ function collectMethodLineIssues(file: SourceFile, maxLines: number, issues: DriftIssue[]): void {
117
134
  for (const method of file.getDescendantsOfKind(SyntaxKind.MethodDeclaration)) {
118
- const body = method.getBody()
119
- if (!body) continue
120
- const lines = body.getEndLineNumber() - body.getStartLineNumber() - 1
121
- if (lines > maxLines) {
122
- issues.push(createIssue(
123
- 'max-function-lines',
124
- `Method '${method.getName()}' has ${lines} lines (max: ${maxLines}).`,
125
- method.getStartLineNumber(),
126
- method.getName(),
127
- ))
128
- }
135
+ const lines = countBodyLines(method.getBody())
136
+ if (lines <= maxLines) continue
137
+
138
+ issues.push(createIssue(
139
+ 'max-function-lines',
140
+ `Method '${method.getName()}' has ${lines} lines (max: ${maxLines}).`,
141
+ method.getStartLineNumber(),
142
+ method.getName(),
143
+ ))
129
144
  }
130
-
131
- return issues
132
145
  }
@@ -5,12 +5,40 @@ import {
5
5
  ArrowFunction,
6
6
  FunctionExpression,
7
7
  MethodDeclaration,
8
+ SyntaxKind,
8
9
  } from 'ts-morph'
9
10
 
10
11
  export type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
11
12
 
12
- export function hasIgnoreComment(file: SourceFile, line: number): boolean {
13
+ const fileLinesCache = new WeakMap<SourceFile, string[]>()
14
+ const functionLikesCache = new WeakMap<SourceFile, FunctionLike[]>()
15
+
16
+ export function getFileLines(file: SourceFile): string[] {
17
+ const cached = fileLinesCache.get(file)
18
+ if (cached) return cached
19
+
13
20
  const lines = file.getFullText().split('\n')
21
+ fileLinesCache.set(file, lines)
22
+ return lines
23
+ }
24
+
25
+ export function collectFunctionLikes(file: SourceFile): FunctionLike[] {
26
+ const cached = functionLikesCache.get(file)
27
+ if (cached) return cached
28
+
29
+ const fns: FunctionLike[] = [
30
+ ...file.getFunctions(),
31
+ ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
32
+ ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
33
+ ...file.getClasses().flatMap((c) => c.getMethods()),
34
+ ]
35
+
36
+ functionLikesCache.set(file, fns)
37
+ return fns
38
+ }
39
+
40
+ export function hasIgnoreComment(file: SourceFile, line: number): boolean {
41
+ const lines = getFileLines(file)
14
42
  const currentLine = lines[line - 1] ?? ''
15
43
  const prevLine = lines[line - 2] ?? ''
16
44
 
@@ -20,13 +48,13 @@ export function hasIgnoreComment(file: SourceFile, line: number): boolean {
20
48
  }
21
49
 
22
50
  export function isFileIgnored(file: SourceFile): boolean {
23
- const firstLines = file.getFullText().split('\n').slice(0, 10).join('\n') // drift-ignore
51
+ const firstLines = getFileLines(file).slice(0, 10).join('\n') // drift-ignore
24
52
  return /\/\/\s*drift-ignore-file\b/.test(firstLines)
25
53
  }
26
54
 
27
55
  export function getSnippet(node: Node, file: SourceFile): string {
28
56
  const startLine = node.getStartLineNumber()
29
- const lines = file.getFullText().split('\n')
57
+ const lines = getFileLines(file)
30
58
  return lines
31
59
  .slice(Math.max(0, startLine - 1), startLine + 1)
32
60
  .join('\n')
@@ -0,0 +1,56 @@
1
+ import type { SaasOperation, SaasPlan, SaasPolicy, SaasRole } from './types.js'
2
+
3
+ export const STORE_VERSION = 3
4
+ export const ACTIVE_WINDOW_DAYS = 30
5
+ export const DEFAULT_ORGANIZATION_ID = 'default-org'
6
+ const HOURS_PER_DAY = 24
7
+ const MINUTES_PER_HOUR = 60
8
+ const SECONDS_PER_MINUTE = 60
9
+ const MILLISECONDS_PER_SECOND = 1000
10
+ const RANDOM_ID_RADIX = 16
11
+ const RANDOM_ID_START = 2
12
+ const RANDOM_ID_END = 10
13
+
14
+ export const DASHBOARD_REPO_LIMIT = 15
15
+ export const DASHBOARD_BAR_UNIT = 8
16
+ export const DASHBOARD_BAR_MIN_WIDTH = 8
17
+
18
+ export const VALID_ROLES: SaasRole[] = ['owner', 'member', 'viewer']
19
+ export const VALID_PLANS: SaasPlan[] = ['free', 'sponsor', 'team', 'business']
20
+
21
+ export const ROLE_PRIORITY: Record<SaasRole, number> = {
22
+ viewer: 1,
23
+ member: 2,
24
+ owner: 3,
25
+ }
26
+
27
+ export const REQUIRED_ROLE_BY_OPERATION: Record<SaasOperation, SaasRole> = {
28
+ 'snapshot:write': 'member',
29
+ 'snapshot:read': 'viewer',
30
+ 'summary:read': 'viewer',
31
+ 'billing:write': 'owner',
32
+ 'billing:read': 'viewer',
33
+ }
34
+
35
+ export const DEFAULT_SAAS_POLICY: SaasPolicy = {
36
+ freeUserThreshold: 7500,
37
+ maxRunsPerWorkspacePerMonth: 500,
38
+ maxReposPerWorkspace: 20,
39
+ retentionDays: 90,
40
+ strictActorEnforcement: false,
41
+ maxWorkspacesPerOrganizationByPlan: {
42
+ free: 20,
43
+ sponsor: 50,
44
+ team: 200,
45
+ business: 1000,
46
+ },
47
+ }
48
+
49
+ export function daysAgo(days: number): number {
50
+ const now = Date.now()
51
+ return now - days * HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND
52
+ }
53
+
54
+ export function createRandomId(prefix: string): string {
55
+ return `${prefix}-${Math.random().toString(RANDOM_ID_RADIX).slice(RANDOM_ID_START, RANDOM_ID_END)}`
56
+ }