@eduardbar/drift 1.3.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 (168) 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/review-pr.yml +34 -41
  7. package/AGENTS.md +75 -251
  8. package/CHANGELOG.md +28 -0
  9. package/README.md +148 -41
  10. package/dist/benchmark.d.ts +1 -1
  11. package/dist/benchmark.js +71 -52
  12. package/dist/cli.js +243 -8
  13. package/dist/config.js +16 -2
  14. package/dist/diff.js +42 -50
  15. package/dist/doctor.d.ts +5 -0
  16. package/dist/doctor.js +133 -0
  17. package/dist/format.d.ts +17 -0
  18. package/dist/format.js +45 -0
  19. package/dist/guard-types.d.ts +57 -0
  20. package/dist/guard-types.js +2 -0
  21. package/dist/guard.d.ts +14 -0
  22. package/dist/guard.js +239 -0
  23. package/dist/index.d.ts +10 -3
  24. package/dist/index.js +4 -1
  25. package/dist/init.d.ts +15 -0
  26. package/dist/init.js +273 -0
  27. package/dist/map-cycles.d.ts +2 -0
  28. package/dist/map-cycles.js +34 -0
  29. package/dist/map-svg.d.ts +19 -0
  30. package/dist/map-svg.js +97 -0
  31. package/dist/map.js +78 -138
  32. package/dist/metrics.js +70 -55
  33. package/dist/output-metadata.d.ts +13 -0
  34. package/dist/output-metadata.js +17 -0
  35. package/dist/plugins-capabilities.d.ts +4 -0
  36. package/dist/plugins-capabilities.js +21 -0
  37. package/dist/plugins-messages.d.ts +10 -0
  38. package/dist/plugins-messages.js +16 -0
  39. package/dist/plugins-rules.d.ts +9 -0
  40. package/dist/plugins-rules.js +137 -0
  41. package/dist/plugins.d.ts +1 -1
  42. package/dist/plugins.js +45 -142
  43. package/dist/reporter-constants.d.ts +16 -0
  44. package/dist/reporter-constants.js +39 -0
  45. package/dist/reporter.d.ts +3 -3
  46. package/dist/reporter.js +35 -55
  47. package/dist/review.d.ts +2 -1
  48. package/dist/review.js +2 -1
  49. package/dist/rules/phase3-configurable.js +23 -15
  50. package/dist/saas/constants.d.ts +15 -0
  51. package/dist/saas/constants.js +48 -0
  52. package/dist/saas/dashboard.d.ts +8 -0
  53. package/dist/saas/dashboard.js +132 -0
  54. package/dist/saas/errors.d.ts +19 -0
  55. package/dist/saas/errors.js +37 -0
  56. package/dist/saas/helpers.d.ts +21 -0
  57. package/dist/saas/helpers.js +110 -0
  58. package/dist/saas/ingest.d.ts +3 -0
  59. package/dist/saas/ingest.js +249 -0
  60. package/dist/saas/organization.d.ts +5 -0
  61. package/dist/saas/organization.js +82 -0
  62. package/dist/saas/plan-change.d.ts +10 -0
  63. package/dist/saas/plan-change.js +15 -0
  64. package/dist/saas/store.d.ts +21 -0
  65. package/dist/saas/store.js +159 -0
  66. package/dist/saas/types.d.ts +191 -0
  67. package/dist/saas/types.js +2 -0
  68. package/dist/saas.d.ts +8 -218
  69. package/dist/saas.js +7 -761
  70. package/dist/sarif.d.ts +74 -0
  71. package/dist/sarif.js +122 -0
  72. package/dist/trust-advanced.d.ts +14 -0
  73. package/dist/trust-advanced.js +65 -0
  74. package/dist/trust-kpi-fs.d.ts +3 -0
  75. package/dist/trust-kpi-fs.js +141 -0
  76. package/dist/trust-kpi-parse.d.ts +7 -0
  77. package/dist/trust-kpi-parse.js +186 -0
  78. package/dist/trust-kpi-types.d.ts +16 -0
  79. package/dist/trust-kpi-types.js +2 -0
  80. package/dist/trust-kpi.d.ts +1 -3
  81. package/dist/trust-kpi.js +6 -266
  82. package/dist/trust-policy.d.ts +32 -0
  83. package/dist/trust-policy.js +160 -0
  84. package/dist/trust-render.d.ts +9 -0
  85. package/dist/trust-render.js +54 -0
  86. package/dist/trust-scoring.d.ts +9 -0
  87. package/dist/trust-scoring.js +208 -0
  88. package/dist/trust.d.ts +4 -32
  89. package/dist/trust.js +29 -432
  90. package/dist/types/app.d.ts +30 -0
  91. package/dist/types/app.js +2 -0
  92. package/dist/types/config.d.ts +25 -0
  93. package/dist/types/config.js +2 -0
  94. package/dist/types/core.d.ts +100 -0
  95. package/dist/types/core.js +2 -0
  96. package/dist/types/diff.d.ts +55 -0
  97. package/dist/types/diff.js +2 -0
  98. package/dist/types/plugin.d.ts +41 -0
  99. package/dist/types/plugin.js +2 -0
  100. package/dist/types/trust.d.ts +120 -0
  101. package/dist/types/trust.js +2 -0
  102. package/dist/types.d.ts +8 -365
  103. package/docs/release-notes-draft.md +40 -0
  104. package/docs/rules-catalog.md +49 -0
  105. package/docs/trust-core-release-checklist.md +37 -5
  106. package/package.json +3 -2
  107. package/packages/vscode-drift/src/code-actions.ts +1 -1
  108. package/schemas/drift-ai-output.v1.json +162 -0
  109. package/schemas/drift-report.v1.json +151 -0
  110. package/schemas/drift-trust.v1.json +131 -0
  111. package/scripts/smoke-repo.mjs +394 -0
  112. package/src/benchmark.ts +75 -53
  113. package/src/cli.ts +285 -13
  114. package/src/config.ts +19 -2
  115. package/src/diff.ts +57 -48
  116. package/src/doctor.ts +173 -0
  117. package/src/format.ts +81 -0
  118. package/src/guard-types.ts +64 -0
  119. package/src/guard.ts +324 -0
  120. package/src/index.ts +35 -0
  121. package/src/init.ts +298 -0
  122. package/src/map-cycles.ts +38 -0
  123. package/src/map-svg.ts +124 -0
  124. package/src/map.ts +111 -142
  125. package/src/metrics.ts +78 -59
  126. package/src/output-metadata.ts +30 -0
  127. package/src/plugins-capabilities.ts +36 -0
  128. package/src/plugins-messages.ts +35 -0
  129. package/src/plugins-rules.ts +296 -0
  130. package/src/plugins.ts +76 -283
  131. package/src/reporter-constants.ts +46 -0
  132. package/src/reporter.ts +64 -65
  133. package/src/review.ts +4 -2
  134. package/src/rules/phase3-configurable.ts +39 -26
  135. package/src/saas/constants.ts +56 -0
  136. package/src/saas/dashboard.ts +172 -0
  137. package/src/saas/errors.ts +45 -0
  138. package/src/saas/helpers.ts +140 -0
  139. package/src/saas/ingest.ts +278 -0
  140. package/src/saas/organization.ts +99 -0
  141. package/src/saas/plan-change.ts +19 -0
  142. package/src/saas/store.ts +172 -0
  143. package/src/saas/types.ts +216 -0
  144. package/src/saas.ts +49 -1031
  145. package/src/sarif.ts +232 -0
  146. package/src/trust-advanced.ts +99 -0
  147. package/src/trust-kpi-fs.ts +169 -0
  148. package/src/trust-kpi-parse.ts +219 -0
  149. package/src/trust-kpi-types.ts +19 -0
  150. package/src/trust-kpi.ts +8 -316
  151. package/src/trust-policy.ts +246 -0
  152. package/src/trust-render.ts +61 -0
  153. package/src/trust-scoring.ts +231 -0
  154. package/src/trust.ts +62 -576
  155. package/src/types/app.ts +30 -0
  156. package/src/types/config.ts +27 -0
  157. package/src/types/core.ts +105 -0
  158. package/src/types/diff.ts +61 -0
  159. package/src/types/plugin.ts +46 -0
  160. package/src/types/trust.ts +134 -0
  161. package/src/types.ts +78 -409
  162. package/tests/cli-sarif.test.ts +92 -0
  163. package/tests/format.test.ts +157 -0
  164. package/tests/new-features.test.ts +10 -2
  165. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  166. package/tests/sarif.test.ts +160 -0
  167. package/tests/trust-kpi.test.ts +31 -4
  168. package/tests/trust.test.ts +18 -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
@@ -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`
@@ -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
  }
@@ -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
+ }
@@ -0,0 +1,172 @@
1
+ import { resolve } from 'node:path'
2
+ import type { SaasPolicyOverrides, SaasQueryOptions, SaasSnapshot, SaasSummary } from './types.js'
3
+ import { DASHBOARD_BAR_MIN_WIDTH, DASHBOARD_BAR_UNIT, DASHBOARD_REPO_LIMIT } from './constants.js'
4
+ import { assertPermissionInStore, defaultSaasStorePath, loadStoreInternal, saveStore } from './store.js'
5
+ import {
6
+ DEFAULT_ORGANIZATION_ID,
7
+ computeRunsPerMonth,
8
+ computeUsersRegistered,
9
+ escapeHtml,
10
+ isRepoActive,
11
+ isWorkspaceActive,
12
+ matchesRepoScope,
13
+ matchesTenantScope,
14
+ matchesWorkspaceScope,
15
+ } from './helpers.js'
16
+
17
+ function assertSummaryReadPermission(store: ReturnType<typeof loadStoreInternal>, options?: SaasQueryOptions): void {
18
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId)
19
+ if (!options?.actorUserId && !shouldEnforceActorForScope) return
20
+
21
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID
22
+ assertPermissionInStore(store, {
23
+ operation: 'summary:read',
24
+ organizationId,
25
+ workspaceId: options?.workspaceId,
26
+ actorUserId: options?.actorUserId,
27
+ })
28
+ }
29
+
30
+ function buildWorkspaceStats(store: ReturnType<typeof loadStoreInternal>): Array<{
31
+ organizationId: string
32
+ id: string
33
+ runs: number
34
+ avgScore: number
35
+ lastRun: string
36
+ }> {
37
+ return Object.values(store.workspaces)
38
+ .map((workspace) => {
39
+ const snapshots = store.snapshots.filter((snapshot) => snapshot.organizationId === workspace.organizationId && snapshot.workspaceId === workspace.id)
40
+ const runs = snapshots.length
41
+ const avgScore = runs === 0 ? 0 : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
42
+ const lastRun = snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.createdAt ?? 'n/a'
43
+ return {
44
+ organizationId: workspace.organizationId,
45
+ id: workspace.id,
46
+ runs,
47
+ avgScore,
48
+ lastRun,
49
+ }
50
+ })
51
+ .sort((a, b) => b.avgScore - a.avgScore)
52
+ }
53
+
54
+ function buildRepoStats(store: ReturnType<typeof loadStoreInternal>): Array<{ workspaceId: string; name: string; runs: number; avgScore: number }> {
55
+ return Object.values(store.repos)
56
+ .map((repo) => {
57
+ const snapshots = store.snapshots.filter((snapshot) => snapshot.repoId === repo.id)
58
+ const runs = snapshots.length
59
+ const avgScore = runs === 0 ? 0 : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
60
+ return {
61
+ workspaceId: repo.workspaceId,
62
+ name: repo.name,
63
+ runs,
64
+ avgScore,
65
+ }
66
+ })
67
+ .sort((a, b) => b.avgScore - a.avgScore)
68
+ .slice(0, DASHBOARD_REPO_LIMIT)
69
+ }
70
+
71
+ function buildRunsRows(summary: SaasSummary): string {
72
+ return Object.entries(summary.runsPerMonth)
73
+ .sort(([a], [b]) => a.localeCompare(b))
74
+ .map(([month, count]) => {
75
+ const width = Math.max(DASHBOARD_BAR_MIN_WIDTH, count * DASHBOARD_BAR_UNIT)
76
+ return `<tr><td>${escapeHtml(month)}</td><td>${count}</td><td><div class="bar" style="width:${width}px"></div></td></tr>`
77
+ })
78
+ .join('')
79
+ }
80
+
81
+ function buildWorkspaceRows(workspaceStats: Array<{ organizationId: string; id: string; runs: number; avgScore: number; lastRun: string }>): string {
82
+ return workspaceStats
83
+ .map((workspace) => `<tr><td>${escapeHtml(workspace.organizationId)}</td><td>${escapeHtml(workspace.id)}</td><td>${workspace.runs}</td><td>${workspace.avgScore}</td><td>${escapeHtml(workspace.lastRun)}</td></tr>`)
84
+ .join('')
85
+ }
86
+
87
+ function buildRepoRows(repoStats: Array<{ workspaceId: string; name: string; runs: number; avgScore: number }>): string {
88
+ return repoStats
89
+ .map((repo) => `<tr><td>${escapeHtml(repo.workspaceId)}</td><td>${escapeHtml(repo.name)}</td><td>${repo.runs}</td><td>${repo.avgScore}</td></tr>`)
90
+ .join('')
91
+ }
92
+
93
+ function renderDashboardHtmlDocument(input: {
94
+ storeFile: string
95
+ summary: SaasSummary
96
+ runsRows: string
97
+ workspaceRows: string
98
+ repoRows: string
99
+ }): string {
100
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>drift cloud dashboard</title><style>:root { color-scheme: light; } body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: #f4f7fb; color: #0f172a; } main { max-width: 980px; margin: 0 auto; padding: 24px; } h1 { margin: 0 0 6px; } p.meta { margin: 0 0 20px; color: #475569; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; } .card { background: #ffffff; border-radius: 10px; padding: 14px; border: 1px solid #dbe3ef; } .card .label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; } .card .value { font-size: 26px; font-weight: 700; margin-top: 4px; } table { width: 100%; border-collapse: collapse; margin-top: 10px; background: #ffffff; border: 1px solid #dbe3ef; border-radius: 10px; overflow: hidden; } th, td { padding: 10px; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 14px; } th { background: #eef2f9; } .section { margin-top: 18px; } .bar { height: 10px; background: linear-gradient(90deg, #0ea5e9, #22c55e); border-radius: 999px; } .pill { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; font-weight: 600; } .pill.free { background: #dcfce7; color: #166534; } .pill.paid { background: #fee2e2; color: #991b1b; }</style></head><body><main><h1>drift cloud dashboard</h1><p class="meta">Store: ${escapeHtml(input.storeFile)}</p><div class="cards"><div class="card"><div class="label">Plan Phase</div><div class="value"><span class="pill ${input.summary.phase}">${input.summary.phase.toUpperCase()}</span></div></div><div class="card"><div class="label">Users</div><div class="value">${input.summary.usersRegistered}</div></div><div class="card"><div class="label">Active Workspaces</div><div class="value">${input.summary.workspacesActive}</div></div><div class="card"><div class="label">Active Repos</div><div class="value">${input.summary.reposActive}</div></div><div class="card"><div class="label">Snapshots</div><div class="value">${input.summary.totalSnapshots}</div></div><div class="card"><div class="label">Free Seats Left</div><div class="value">${input.summary.freeUsersRemaining}</div></div></div><section class="section"><h2>Runs Per Month</h2><table><thead><tr><th>Month</th><th>Runs</th><th>Trend</th></tr></thead><tbody>${input.runsRows || '<tr><td colspan="3">No runs yet</td></tr>'}</tbody></table></section><section class="section"><h2>Workspace Hotspots</h2><table><thead><tr><th>Organization</th><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead><tbody>${input.workspaceRows || '<tr><td colspan="5">No workspace data</td></tr>'}</tbody></table></section><section class="section"><h2>Repo Hotspots</h2><table><thead><tr><th>Workspace</th><th>Repo</th><th>Runs</th><th>Avg Score</th></tr></thead><tbody>${input.repoRows || '<tr><td colspan="4">No repo data</td></tr>'}</tbody></table></section></main></body></html>`
101
+ }
102
+
103
+ export function listSaasSnapshots(options?: SaasQueryOptions): SaasSnapshot[] {
104
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
105
+ const store = loadStoreInternal(storeFile, options?.policy)
106
+
107
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId)
108
+ if (options?.actorUserId || shouldEnforceActorForScope) {
109
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID
110
+ assertPermissionInStore(store, {
111
+ operation: 'snapshot:read',
112
+ organizationId,
113
+ workspaceId: options?.workspaceId,
114
+ actorUserId: options?.actorUserId,
115
+ })
116
+ }
117
+
118
+ saveStore(storeFile, store)
119
+ return store.snapshots
120
+ .filter((snapshot) => matchesTenantScope(snapshot, options))
121
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
122
+ }
123
+
124
+ export function getSaasSummary(options?: SaasQueryOptions): SaasSummary {
125
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
126
+ const store = loadStoreInternal(storeFile, options?.policy)
127
+
128
+ assertSummaryReadPermission(store, options)
129
+ saveStore(storeFile, store)
130
+
131
+ const scopedSnapshots = store.snapshots.filter((snapshot) => matchesTenantScope(snapshot, options))
132
+ const scopedWorkspaces = Object.values(store.workspaces).filter((workspace) => matchesWorkspaceScope(workspace, options))
133
+ const scopedRepos = Object.values(store.repos).filter((repo) => matchesRepoScope(repo, options))
134
+
135
+ const usersRegistered = computeUsersRegistered(store, scopedSnapshots, options)
136
+ const workspacesActive = scopedWorkspaces.filter((workspace) => isWorkspaceActive(workspace)).length
137
+ const reposActive = scopedRepos.filter((repo) => isRepoActive(repo)).length
138
+ const runsPerMonth = computeRunsPerMonth(scopedSnapshots)
139
+ const thresholdReached = usersRegistered >= store.policy.freeUserThreshold
140
+
141
+ return {
142
+ policy: store.policy,
143
+ usersRegistered,
144
+ workspacesActive,
145
+ reposActive,
146
+ runsPerMonth,
147
+ totalSnapshots: scopedSnapshots.length,
148
+ phase: thresholdReached ? 'paid' : 'free',
149
+ thresholdReached,
150
+ freeUsersRemaining: Math.max(0, store.policy.freeUserThreshold - usersRegistered),
151
+ }
152
+ }
153
+
154
+ export function generateSaasDashboardHtml(options?: { storeFile?: string; policy?: SaasPolicyOverrides }): string {
155
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
156
+ const store = loadStoreInternal(storeFile, options?.policy)
157
+ const summary = getSaasSummary(options)
158
+
159
+ const workspaceStats = buildWorkspaceStats(store)
160
+ const repoStats = buildRepoStats(store)
161
+ const runsRows = buildRunsRows(summary)
162
+ const workspaceRows = buildWorkspaceRows(workspaceStats)
163
+ const repoRows = buildRepoRows(repoStats)
164
+
165
+ return renderDashboardHtmlDocument({
166
+ storeFile,
167
+ summary,
168
+ runsRows,
169
+ workspaceRows,
170
+ repoRows,
171
+ })
172
+ }
@@ -0,0 +1,45 @@
1
+ import type { SaasOperation, SaasPermissionContext, SaasRole } from './types.js'
2
+
3
+ export class SaasPermissionError extends Error {
4
+ readonly code = 'SAAS_PERMISSION_DENIED'
5
+ readonly operation: SaasOperation
6
+ readonly organizationId: string
7
+ readonly workspaceId?: string
8
+ readonly actorUserId?: string
9
+ readonly requiredRole: SaasRole
10
+ readonly actorRole?: SaasRole
11
+
12
+ constructor(context: SaasPermissionContext, requiredRole: SaasRole, actorRole?: SaasRole) {
13
+ const actor = context.actorUserId ?? 'unknown-actor'
14
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : ''
15
+ const actualRole = actorRole ?? 'none'
16
+ super(
17
+ `Permission denied for operation '${context.operation}'. actor='${actor}' organization='${context.organizationId}'${workspaceSuffix} requiredRole='${requiredRole}' actualRole='${actualRole}'.`,
18
+ )
19
+ this.name = 'SaasPermissionError'
20
+ this.operation = context.operation
21
+ this.organizationId = context.organizationId
22
+ this.workspaceId = context.workspaceId
23
+ this.actorUserId = context.actorUserId
24
+ this.requiredRole = requiredRole
25
+ this.actorRole = actorRole
26
+ }
27
+ }
28
+
29
+ export class SaasActorRequiredError extends Error {
30
+ readonly code = 'SAAS_ACTOR_REQUIRED'
31
+ readonly operation: SaasOperation
32
+ readonly organizationId: string
33
+ readonly workspaceId?: string
34
+
35
+ constructor(context: SaasPermissionContext) {
36
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : ''
37
+ super(
38
+ `Actor is required for operation '${context.operation}'. organization='${context.organizationId}'${workspaceSuffix}.`,
39
+ )
40
+ this.name = 'SaasActorRequiredError'
41
+ this.operation = context.operation
42
+ this.organizationId = context.organizationId
43
+ this.workspaceId = context.workspaceId
44
+ }
45
+ }