@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.
- package/.gga +50 -0
- package/.github/actions/drift-review/README.md +60 -0
- package/.github/actions/drift-review/action.yml +131 -0
- package/.github/actions/drift-scan/README.md +28 -32
- package/.github/actions/drift-scan/action.yml +78 -14
- package/.github/workflows/review-pr.yml +34 -41
- package/AGENTS.md +75 -251
- package/CHANGELOG.md +28 -0
- package/README.md +148 -41
- package/dist/benchmark.d.ts +1 -1
- package/dist/benchmark.js +71 -52
- package/dist/cli.js +243 -8
- package/dist/config.js +16 -2
- package/dist/diff.js +42 -50
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +133 -0
- package/dist/format.d.ts +17 -0
- package/dist/format.js +45 -0
- package/dist/guard-types.d.ts +57 -0
- package/dist/guard-types.js +2 -0
- package/dist/guard.d.ts +14 -0
- package/dist/guard.js +239 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +4 -1
- package/dist/init.d.ts +15 -0
- package/dist/init.js +273 -0
- package/dist/map-cycles.d.ts +2 -0
- package/dist/map-cycles.js +34 -0
- package/dist/map-svg.d.ts +19 -0
- package/dist/map-svg.js +97 -0
- package/dist/map.js +78 -138
- package/dist/metrics.js +70 -55
- package/dist/output-metadata.d.ts +13 -0
- package/dist/output-metadata.js +17 -0
- package/dist/plugins-capabilities.d.ts +4 -0
- package/dist/plugins-capabilities.js +21 -0
- package/dist/plugins-messages.d.ts +10 -0
- package/dist/plugins-messages.js +16 -0
- package/dist/plugins-rules.d.ts +9 -0
- package/dist/plugins-rules.js +137 -0
- package/dist/plugins.d.ts +1 -1
- package/dist/plugins.js +45 -142
- package/dist/reporter-constants.d.ts +16 -0
- package/dist/reporter-constants.js +39 -0
- package/dist/reporter.d.ts +3 -3
- package/dist/reporter.js +35 -55
- package/dist/review.d.ts +2 -1
- package/dist/review.js +2 -1
- package/dist/rules/phase3-configurable.js +23 -15
- package/dist/saas/constants.d.ts +15 -0
- package/dist/saas/constants.js +48 -0
- package/dist/saas/dashboard.d.ts +8 -0
- package/dist/saas/dashboard.js +132 -0
- package/dist/saas/errors.d.ts +19 -0
- package/dist/saas/errors.js +37 -0
- package/dist/saas/helpers.d.ts +21 -0
- package/dist/saas/helpers.js +110 -0
- package/dist/saas/ingest.d.ts +3 -0
- package/dist/saas/ingest.js +249 -0
- package/dist/saas/organization.d.ts +5 -0
- package/dist/saas/organization.js +82 -0
- package/dist/saas/plan-change.d.ts +10 -0
- package/dist/saas/plan-change.js +15 -0
- package/dist/saas/store.d.ts +21 -0
- package/dist/saas/store.js +159 -0
- package/dist/saas/types.d.ts +191 -0
- package/dist/saas/types.js +2 -0
- package/dist/saas.d.ts +8 -218
- package/dist/saas.js +7 -761
- package/dist/sarif.d.ts +74 -0
- package/dist/sarif.js +122 -0
- package/dist/trust-advanced.d.ts +14 -0
- package/dist/trust-advanced.js +65 -0
- package/dist/trust-kpi-fs.d.ts +3 -0
- package/dist/trust-kpi-fs.js +141 -0
- package/dist/trust-kpi-parse.d.ts +7 -0
- package/dist/trust-kpi-parse.js +186 -0
- package/dist/trust-kpi-types.d.ts +16 -0
- package/dist/trust-kpi-types.js +2 -0
- package/dist/trust-kpi.d.ts +1 -3
- package/dist/trust-kpi.js +6 -266
- package/dist/trust-policy.d.ts +32 -0
- package/dist/trust-policy.js +160 -0
- package/dist/trust-render.d.ts +9 -0
- package/dist/trust-render.js +54 -0
- package/dist/trust-scoring.d.ts +9 -0
- package/dist/trust-scoring.js +208 -0
- package/dist/trust.d.ts +4 -32
- package/dist/trust.js +29 -432
- package/dist/types/app.d.ts +30 -0
- package/dist/types/app.js +2 -0
- package/dist/types/config.d.ts +25 -0
- package/dist/types/config.js +2 -0
- package/dist/types/core.d.ts +100 -0
- package/dist/types/core.js +2 -0
- package/dist/types/diff.d.ts +55 -0
- package/dist/types/diff.js +2 -0
- package/dist/types/plugin.d.ts +41 -0
- package/dist/types/plugin.js +2 -0
- package/dist/types/trust.d.ts +120 -0
- package/dist/types/trust.js +2 -0
- package/dist/types.d.ts +8 -365
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +37 -5
- package/package.json +3 -2
- package/packages/vscode-drift/src/code-actions.ts +1 -1
- package/schemas/drift-ai-output.v1.json +162 -0
- package/schemas/drift-report.v1.json +151 -0
- package/schemas/drift-trust.v1.json +131 -0
- package/scripts/smoke-repo.mjs +394 -0
- package/src/benchmark.ts +75 -53
- package/src/cli.ts +285 -13
- package/src/config.ts +19 -2
- package/src/diff.ts +57 -48
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +35 -0
- package/src/init.ts +298 -0
- package/src/map-cycles.ts +38 -0
- package/src/map-svg.ts +124 -0
- package/src/map.ts +111 -142
- package/src/metrics.ts +78 -59
- package/src/output-metadata.ts +30 -0
- package/src/plugins-capabilities.ts +36 -0
- package/src/plugins-messages.ts +35 -0
- package/src/plugins-rules.ts +296 -0
- package/src/plugins.ts +76 -283
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +4 -2
- package/src/rules/phase3-configurable.ts +39 -26
- package/src/saas/constants.ts +56 -0
- package/src/saas/dashboard.ts +172 -0
- package/src/saas/errors.ts +45 -0
- package/src/saas/helpers.ts +140 -0
- package/src/saas/ingest.ts +278 -0
- package/src/saas/organization.ts +99 -0
- package/src/saas/plan-change.ts +19 -0
- package/src/saas/store.ts +172 -0
- package/src/saas/types.ts +216 -0
- package/src/saas.ts +49 -1031
- package/src/sarif.ts +232 -0
- package/src/trust-advanced.ts +99 -0
- package/src/trust-kpi-fs.ts +169 -0
- package/src/trust-kpi-parse.ts +219 -0
- package/src/trust-kpi-types.ts +19 -0
- package/src/trust-kpi.ts +8 -316
- package/src/trust-policy.ts +246 -0
- package/src/trust-render.ts +61 -0
- package/src/trust-scoring.ts +231 -0
- package/src/trust.ts +62 -576
- package/src/types/app.ts +30 -0
- package/src/types/config.ts +27 -0
- package/src/types/core.ts +105 -0
- package/src/types/diff.ts +61 -0
- package/src/types/plugin.ts +46 -0
- package/src/types/trust.ts +134 -0
- package/src/types.ts +78 -409
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +10 -2
- package/tests/phase1-init-doctor-guard.test.ts +199 -0
- package/tests/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +31 -4
- package/tests/trust.test.ts +18 -0
package/src/reporter.ts
CHANGED
|
@@ -1,71 +1,60 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
60
|
-
totalIssues:
|
|
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):
|
|
162
|
-
const all:
|
|
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:
|
|
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:
|
|
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') ?
|
|
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,
|
|
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 >=
|
|
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 *
|
|
241
|
+
const smellScore = Math.min(100, smellCount * AI_SMELL_SCORE_MULTIPLIER)
|
|
245
242
|
|
|
246
243
|
return {
|
|
247
244
|
overall,
|
|
248
|
-
files: suspected.slice(0,
|
|
245
|
+
files: suspected.slice(0, AI_SUSPECTED_LIMIT),
|
|
249
246
|
smellScore,
|
|
250
247
|
}
|
|
251
248
|
}
|
|
252
249
|
|
|
253
|
-
export function formatAIOutput(report: DriftReport):
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|