@eduardbar/drift 0.6.0 → 0.8.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.
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "eslint-plugin-drift",
3
+ "version": "0.1.0",
4
+ "description": "ESLint plugin that exposes drift's technical debt rules for ESLint 9 flat config",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "keywords": [
16
+ "eslint",
17
+ "eslintplugin",
18
+ "eslint-plugin",
19
+ "drift",
20
+ "technical-debt",
21
+ "ai",
22
+ "typescript"
23
+ ],
24
+ "author": "eduardbar",
25
+ "license": "MIT",
26
+ "homepage": "https://github.com/eduardbar/drift#readme",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/eduardbar/drift.git"
30
+ },
31
+ "peerDependencies": {
32
+ "eslint": ">=9.0.0"
33
+ },
34
+ "dependencies": {
35
+ "@eduardbar/drift": "^0.7.0",
36
+ "ts-morph": "^27.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^25.0.0",
40
+ "eslint": "^9.0.0",
41
+ "typescript": "^5.9.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc",
45
+ "prepublishOnly": "npm run build"
46
+ }
47
+ }
@@ -0,0 +1,170 @@
1
+ import { analyzeFile } from '@eduardbar/drift'
2
+ import type { FileReport } from '@eduardbar/drift'
3
+ import { Project } from 'ts-morph'
4
+ import type { Rule } from 'eslint'
5
+
6
+ // Tipos auxiliares
7
+ type RuleType = 'problem' | 'suggestion' | 'layout'
8
+
9
+ // Mapeo rule → descripción legible
10
+ const RULE_DOCS: Record<string, { type: RuleType; description: string }> = {
11
+ 'large-file': { type: 'problem', description: 'Files over 300 lines — AI dumps everything into one place' },
12
+ 'large-function': { type: 'problem', description: 'Functions over 50 lines — AI avoids splitting logic' },
13
+ 'duplicate-function-name': { type: 'problem', description: 'Near-identical function names — AI regenerates instead of reusing' },
14
+ 'high-complexity': { type: 'problem', description: 'Cyclomatic complexity > 10 — AI generates correct code, not simple code' },
15
+ 'circular-dependency': { type: 'problem', description: 'Circular import chains between modules' },
16
+ 'layer-violation': { type: 'problem', description: 'Import from a prohibited architectural layer (requires drift.config.ts)' },
17
+ 'debug-leftover': { type: 'suggestion', description: 'console.log, TODO, FIXME comments left in production code' },
18
+ 'dead-code': { type: 'suggestion', description: 'Unused imports — AI imports more than it uses' },
19
+ 'any-abuse': { type: 'suggestion', description: 'Explicit any type — AI defaults to any when it cannot infer' },
20
+ 'catch-swallow': { type: 'suggestion', description: 'Empty catch blocks — AI makes code not throw' },
21
+ 'comment-contradiction': { type: 'suggestion', description: 'Comments that restate what the code already says' },
22
+ 'deep-nesting': { type: 'suggestion', description: 'Nesting depth > 3 — unreadable control flow' },
23
+ 'too-many-params': { type: 'suggestion', description: 'Functions with more than 4 parameters' },
24
+ 'high-coupling': { type: 'suggestion', description: 'Files importing from more than 10 modules' },
25
+ 'promise-style-mix': { type: 'suggestion', description: 'async/await and .then() mixed in the same file' },
26
+ 'unused-export': { type: 'suggestion', description: 'Named exports never imported anywhere in the project' },
27
+ 'dead-file': { type: 'suggestion', description: 'Files never imported by any other file' },
28
+ 'unused-dependency': { type: 'suggestion', description: 'Packages in package.json never imported in source code' },
29
+ 'cross-boundary-import': { type: 'suggestion', description: 'Module imports from another module outside allowed boundaries' },
30
+ 'hardcoded-config': { type: 'suggestion', description: 'Hardcoded URLs, IPs, or connection strings — AI skips env vars' },
31
+ 'inconsistent-error-handling': { type: 'suggestion', description: 'Mixed try/catch and .catch() patterns in the same file' },
32
+ 'unnecessary-abstraction': { type: 'suggestion', description: 'Single-method interfaces or abstract classes never reused' },
33
+ 'naming-inconsistency': { type: 'suggestion', description: 'Mixed camelCase and snake_case in the same scope' },
34
+ 'no-return-type': { type: 'suggestion', description: 'Missing explicit return types on functions' },
35
+ 'magic-number': { type: 'suggestion', description: 'Numeric literals used directly in logic' },
36
+ 'over-commented': { type: 'suggestion', description: 'Functions where comments exceed 40% of lines' },
37
+ }
38
+
39
+ // ts-morph Project singleton — reutilizado para todos los archivos en un lint run
40
+ const project = new Project({
41
+ skipAddingFilesFromTsConfig: true,
42
+ compilerOptions: { allowJs: true },
43
+ })
44
+
45
+ // Cache de FileReport por filename para no llamar analyzeFile 26 veces por archivo
46
+ const cache = new Map<string, FileReport>()
47
+
48
+ function getFileReport(filename: string): FileReport {
49
+ if (cache.has(filename)) return cache.get(filename)!
50
+
51
+ // Obtener o agregar el SourceFile al Project
52
+ let sourceFile = project.getSourceFile(filename)
53
+ if (!sourceFile) {
54
+ sourceFile = project.addSourceFileAtPath(filename)
55
+ }
56
+
57
+ const report = analyzeFile(sourceFile)
58
+ cache.set(filename, report)
59
+
60
+ // Evitar memory leak en watch mode — mantener máximo 100 entradas
61
+ if (cache.size > 100) {
62
+ const firstKey = cache.keys().next().value
63
+ if (firstKey !== undefined) cache.delete(firstKey)
64
+ }
65
+
66
+ return report
67
+ }
68
+
69
+ // Crea una regla ESLint para una regla drift específica
70
+ function createRule(ruleName: string): Rule.RuleModule {
71
+ const doc = RULE_DOCS[ruleName] ?? { type: 'suggestion' as RuleType, description: ruleName }
72
+
73
+ return {
74
+ meta: {
75
+ type: doc.type,
76
+ docs: {
77
+ description: doc.description,
78
+ url: `https://github.com/eduardbar/drift#${ruleName}`,
79
+ },
80
+ schema: [],
81
+ messages: {
82
+ issue: '{{ message }}',
83
+ },
84
+ },
85
+ create(context) {
86
+ return {
87
+ 'Program:exit'() {
88
+ const filename = context.filename
89
+ if (!filename.endsWith('.ts') && !filename.endsWith('.tsx')) return
90
+ if (filename.includes('node_modules')) return
91
+
92
+ try {
93
+ const fileReport = getFileReport(filename)
94
+ for (const issue of fileReport.issues) {
95
+ if (issue.rule !== ruleName) continue
96
+ const col = issue.column > 0 ? issue.column - 1 : 0
97
+ context.report({
98
+ loc: {
99
+ start: { line: issue.line, column: col },
100
+ end: { line: issue.line, column: col + 1 },
101
+ },
102
+ messageId: 'issue',
103
+ data: { message: issue.message },
104
+ })
105
+ }
106
+ } catch {
107
+ // Archivo no parseable por ts-morph — silenciar
108
+ }
109
+ },
110
+ }
111
+ },
112
+ }
113
+ }
114
+
115
+ // Objeto con todas las reglas
116
+ const rules: Record<string, Rule.RuleModule> = Object.fromEntries(
117
+ Object.keys(RULE_DOCS).map(name => [name, createRule(name)])
118
+ )
119
+
120
+ // Plugin object
121
+ const plugin = {
122
+ meta: {
123
+ name: 'eslint-plugin-drift',
124
+ version: '0.1.0',
125
+ },
126
+ rules,
127
+ configs: {} as Record<string, unknown>,
128
+ }
129
+
130
+ // Config recommended — todas las reglas en su severidad canónica de drift
131
+ Object.assign(plugin.configs, {
132
+ recommended: [
133
+ {
134
+ plugins: { drift: plugin },
135
+ rules: {
136
+ // errors
137
+ 'drift/large-file': 'error',
138
+ 'drift/large-function': 'error',
139
+ 'drift/duplicate-function-name': 'error',
140
+ 'drift/high-complexity': 'error',
141
+ 'drift/circular-dependency': 'error',
142
+ 'drift/layer-violation': 'error',
143
+ // warnings
144
+ 'drift/debug-leftover': 'warn',
145
+ 'drift/dead-code': 'warn',
146
+ 'drift/any-abuse': 'warn',
147
+ 'drift/catch-swallow': 'warn',
148
+ 'drift/comment-contradiction': 'warn',
149
+ 'drift/deep-nesting': 'warn',
150
+ 'drift/too-many-params': 'warn',
151
+ 'drift/high-coupling': 'warn',
152
+ 'drift/promise-style-mix': 'warn',
153
+ 'drift/unused-export': 'warn',
154
+ 'drift/dead-file': 'warn',
155
+ 'drift/unused-dependency': 'warn',
156
+ 'drift/cross-boundary-import': 'warn',
157
+ 'drift/hardcoded-config': 'warn',
158
+ 'drift/inconsistent-error-handling': 'warn',
159
+ 'drift/unnecessary-abstraction': 'warn',
160
+ 'drift/naming-inconsistency': 'warn',
161
+ // info → ESLint no tiene "info", mapeamos a "warn"
162
+ 'drift/no-return-type': 'warn',
163
+ 'drift/magic-number': 'warn',
164
+ 'drift/over-commented': 'warn',
165
+ },
166
+ },
167
+ ],
168
+ })
169
+
170
+ export default plugin
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "sourceMap": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }
package/src/analyzer.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs'
2
+ import * as crypto from 'node:crypto'
2
3
  import * as path from 'node:path'
3
4
  import {
4
5
  Project,
@@ -13,7 +14,7 @@ import {
13
14
  import type { DriftIssue, FileReport, DriftConfig, LayerDefinition, ModuleBoundary } from './types.js'
14
15
 
15
16
  // Rules and their drift score weight
16
- const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
17
+ export const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
17
18
  'large-file': { severity: 'error', weight: 20 },
18
19
  'large-function': { severity: 'error', weight: 15 },
19
20
  'debug-leftover': { severity: 'warning', weight: 10 },
@@ -45,6 +46,8 @@ const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: n
45
46
  'inconsistent-error-handling': { severity: 'warning', weight: 8 },
46
47
  'unnecessary-abstraction': { severity: 'warning', weight: 7 },
47
48
  'naming-inconsistency': { severity: 'warning', weight: 6 },
49
+ // Phase 8: semantic duplication
50
+ 'semantic-duplication': { severity: 'warning', weight: 12 },
48
51
  }
49
52
 
50
53
  type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
@@ -873,6 +876,123 @@ function calculateScore(issues: DriftIssue[]): number {
873
876
  return Math.min(100, raw)
874
877
  }
875
878
 
879
+ // ---------------------------------------------------------------------------
880
+ // Phase 8: Semantic duplication — AST fingerprinting helpers
881
+ // ---------------------------------------------------------------------------
882
+
883
+ type FunctionLikeNode = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
884
+
885
+ /** Normalize a function body to a canonical string (Type-2 clone detection).
886
+ * Variable names, parameter names, and numeric/string literals are replaced
887
+ * with canonical tokens so that two functions with identical logic but
888
+ * different identifiers produce the same fingerprint.
889
+ */
890
+ function normalizeFunctionBody(fn: FunctionLikeNode): string {
891
+ // Build a substitution map: localName → canonical token
892
+ const subst = new Map<string, string>()
893
+
894
+ // Map parameters first
895
+ for (const [i, param] of fn.getParameters().entries()) {
896
+ const name = param.getName()
897
+ if (name && name !== '_') subst.set(name, `P${i}`)
898
+ }
899
+
900
+ // Map locally declared variables (VariableDeclaration)
901
+ let varIdx = 0
902
+ fn.forEachDescendant(node => {
903
+ if (node.getKind() === SyntaxKind.VariableDeclaration) {
904
+ const nameNode = (node as import('ts-morph').VariableDeclaration).getNameNode()
905
+ // Support destructuring — getNameNode() may be a BindingPattern
906
+ if (nameNode.getKind() === SyntaxKind.Identifier) {
907
+ const name = nameNode.getText()
908
+ if (!subst.has(name)) subst.set(name, `V${varIdx++}`)
909
+ }
910
+ }
911
+ })
912
+
913
+ function serializeNode(node: Node): string {
914
+ const kind = node.getKindName()
915
+
916
+ switch (node.getKind()) {
917
+ case SyntaxKind.Identifier: {
918
+ const text = node.getText()
919
+ return subst.get(text) ?? text // external refs (Math, console) kept as-is
920
+ }
921
+ case SyntaxKind.NumericLiteral:
922
+ return 'NL'
923
+ case SyntaxKind.StringLiteral:
924
+ case SyntaxKind.NoSubstitutionTemplateLiteral:
925
+ return 'SL'
926
+ case SyntaxKind.TrueKeyword:
927
+ return 'TRUE'
928
+ case SyntaxKind.FalseKeyword:
929
+ return 'FALSE'
930
+ case SyntaxKind.NullKeyword:
931
+ return 'NULL'
932
+ }
933
+
934
+ const children = node.getChildren()
935
+ if (children.length === 0) return kind
936
+
937
+ const childStr = children.map(serializeNode).join('|')
938
+ return `${kind}(${childStr})`
939
+ }
940
+
941
+ const body = fn.getBody()
942
+ if (!body) return ''
943
+ return serializeNode(body)
944
+ }
945
+
946
+ /** Return a SHA-256 fingerprint for a function body (normalized). */
947
+ function fingerprintFunction(fn: FunctionLikeNode): string {
948
+ const normalized = normalizeFunctionBody(fn)
949
+ return crypto.createHash('sha256').update(normalized).digest('hex')
950
+ }
951
+
952
+ /** Return all function-like nodes from a SourceFile that are worth comparing:
953
+ * - At least MIN_LINES lines in their body
954
+ * - Not test helpers (describe/it/test/beforeEach/afterEach)
955
+ */
956
+ const MIN_LINES = 8
957
+
958
+ function collectFunctions(sf: SourceFile): Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> {
959
+ const results: Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> = []
960
+
961
+ const kinds = [
962
+ SyntaxKind.FunctionDeclaration,
963
+ SyntaxKind.FunctionExpression,
964
+ SyntaxKind.ArrowFunction,
965
+ SyntaxKind.MethodDeclaration,
966
+ ] as const
967
+
968
+ for (const kind of kinds) {
969
+ for (const node of sf.getDescendantsOfKind(kind)) {
970
+ const body = (node as FunctionLikeNode).getBody()
971
+ if (!body) continue
972
+
973
+ const start = body.getStartLineNumber()
974
+ const end = body.getEndLineNumber()
975
+ if (end - start + 1 < MIN_LINES) continue
976
+
977
+ // Skip test-framework helpers
978
+ const name = node.getKind() === SyntaxKind.FunctionDeclaration
979
+ ? (node as FunctionDeclaration).getName() ?? '<anonymous>'
980
+ : node.getKind() === SyntaxKind.MethodDeclaration
981
+ ? (node as MethodDeclaration).getName()
982
+ : '<anonymous>'
983
+
984
+ if (['describe', 'it', 'test', 'beforeEach', 'afterEach', 'beforeAll', 'afterAll'].includes(name)) continue
985
+
986
+ const pos = node.getStart()
987
+ const lineInfo = sf.getLineAndColumnAtPos(pos)
988
+
989
+ results.push({ fn: node as FunctionLikeNode, name, line: lineInfo.line, col: lineInfo.column })
990
+ }
991
+ }
992
+
993
+ return results
994
+ }
995
+
876
996
  // ---------------------------------------------------------------------------
877
997
  // Public API
878
998
  // ---------------------------------------------------------------------------
@@ -1284,5 +1404,48 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1284
1404
  }
1285
1405
  }
1286
1406
 
1407
+ // ── Phase 8: semantic-duplication ────────────────────────────────────────
1408
+ // Build a fingerprint → [{filePath, fnName, line, col}] map across all files
1409
+ const fingerprintMap = new Map<string, Array<{ filePath: string; name: string; line: number; col: number }>>()
1410
+
1411
+ for (const sf of sourceFiles) {
1412
+ const sfPath = sf.getFilePath()
1413
+ for (const { fn, name, line, col } of collectFunctions(sf)) {
1414
+ const fp = fingerprintFunction(fn)
1415
+ if (!fingerprintMap.has(fp)) fingerprintMap.set(fp, [])
1416
+ fingerprintMap.get(fp)!.push({ filePath: sfPath, name, line, col })
1417
+ }
1418
+ }
1419
+
1420
+ // For each fingerprint with 2+ functions: report each as a duplicate of the others
1421
+ for (const [, entries] of fingerprintMap) {
1422
+ if (entries.length < 2) continue
1423
+
1424
+ for (const entry of entries) {
1425
+ const report = reportByPath.get(entry.filePath)
1426
+ if (!report) continue
1427
+
1428
+ // Build the "duplicated in" list (all other locations)
1429
+ const others = entries
1430
+ .filter(e => e !== entry)
1431
+ .map(e => {
1432
+ const rel = path.relative(targetPath, e.filePath).replace(/\\/g, '/')
1433
+ return `${rel}:${e.line} (${e.name})`
1434
+ })
1435
+ .join(', ')
1436
+
1437
+ const weight = RULE_WEIGHTS['semantic-duplication']?.weight ?? 12
1438
+ report.issues.push({
1439
+ rule: 'semantic-duplication',
1440
+ severity: 'warning',
1441
+ message: `Function '${entry.name}' is semantically identical to: ${others}`,
1442
+ line: entry.line,
1443
+ column: entry.col,
1444
+ snippet: `function ${entry.name} — duplicated in ${entries.length - 1} other location${entries.length > 2 ? 's' : ''}`,
1445
+ })
1446
+ report.score = Math.min(100, report.score + weight)
1447
+ }
1448
+ }
1449
+
1287
1450
  return reports
1288
1451
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { analyzeProject, analyzeFile } from './analyzer.js'
1
+ export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js'
2
2
  export { buildReport, formatMarkdown } from './reporter.js'
3
3
  export { computeDiff } from './diff.js'
4
- export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff } from './types.js'
4
+ export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig } from './types.js'
package/src/printer.ts CHANGED
@@ -118,66 +118,66 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
118
118
  }
119
119
 
120
120
  export function printConsole(report: DriftReport, options?: { showFix?: boolean }): void {
121
- const sep = kleur.gray(' ' + '─'.repeat(50))
122
-
123
- console.log()
124
- console.log(kleur.bold().white(' drift') + kleur.gray(' — vibe coding debt detector'))
125
- console.log(sep)
126
- console.log()
127
-
128
- const grade = scoreToGrade(report.totalScore)
129
- const scoreColor = report.totalScore === 0
130
- ? kleur.green
131
- : report.totalScore < 45
132
- ? kleur.yellow
133
- : kleur.red
134
-
135
- const bar = scoreBar(report.totalScore)
136
- console.log(
137
- ` Score ${kleur.gray(bar)} ${scoreColor().bold(String(report.totalScore))}/100 ${grade.badge}`
138
- )
139
-
140
- const cleanFiles = report.totalFiles - report.files.length
141
- console.log(
142
- kleur.gray(
143
- ` ${report.files.length} file(s) with issues · ${report.summary.errors} errors · ${report.summary.warnings} warnings · ${report.summary.infos} info · ${cleanFiles} files clean`
144
- )
145
- )
146
- console.log()
147
-
148
- // Top issues in header
149
- const topRules = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1]).slice(0, 3)
150
- if (topRules.length > 0) {
151
- const parts = topRules.map(([rule, count]) => `${kleur.cyan(rule)} ${kleur.gray(`×${count}`)}`)
152
- console.log(` Top issues: ${parts.join(kleur.gray(' · '))}`)
153
- console.log()
154
- }
155
-
156
- console.log(sep)
157
- console.log()
158
-
159
- if (report.files.length === 0) {
160
- console.log(kleur.green(' No drift detected. Clean codebase.'))
161
- console.log()
162
- return
163
- }
164
-
165
- for (const file of report.files) {
166
- const rel = file.path.replace(report.targetPath, '').replace(/^[\\/]/, '')
167
- console.log(
168
- kleur.bold().white(` ${rel}`) +
169
- kleur.gray(` (score ${file.score}/100)`)
170
- )
171
-
172
- for (const issue of file.issues) {
173
- const icon = severityIcon(issue.severity)
174
- const colorFn = (s: string) =>
175
- issue.severity === 'error'
176
- ? kleur.red(s)
177
- : issue.severity === 'warning'
178
- ? kleur.yellow(s)
179
- : kleur.cyan(s)
180
-
121
+ const sep = kleur.gray(' ' + '─'.repeat(50))
122
+
123
+ console.log()
124
+ console.log(kleur.bold().white(' drift') + kleur.gray(' — vibe coding debt detector'))
125
+ console.log(sep)
126
+ console.log()
127
+
128
+ const grade = scoreToGrade(report.totalScore)
129
+ const scoreColor = report.totalScore === 0
130
+ ? kleur.green
131
+ : report.totalScore < 45
132
+ ? kleur.yellow
133
+ : kleur.red
134
+
135
+ const bar = scoreBar(report.totalScore)
136
+ console.log(
137
+ ` Score ${kleur.gray(bar)} ${scoreColor().bold(String(report.totalScore))}/100 ${grade.badge}`
138
+ )
139
+
140
+ const cleanFiles = report.totalFiles - report.files.length
141
+ console.log(
142
+ kleur.gray(
143
+ ` ${report.files.length} file(s) with issues · ${report.summary.errors} errors · ${report.summary.warnings} warnings · ${report.summary.infos} info · ${cleanFiles} files clean`
144
+ )
145
+ )
146
+ console.log()
147
+
148
+ // Top issues in header
149
+ const topRules = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1]).slice(0, 3)
150
+ if (topRules.length > 0) {
151
+ const parts = topRules.map(([rule, count]) => `${kleur.cyan(rule)} ${kleur.gray(`×${count}`)}`)
152
+ console.log(` Top issues: ${parts.join(kleur.gray(' · '))}`)
153
+ console.log()
154
+ }
155
+
156
+ console.log(sep)
157
+ console.log()
158
+
159
+ if (report.files.length === 0) {
160
+ console.log(kleur.green(' No drift detected. Clean codebase.'))
161
+ console.log()
162
+ return
163
+ }
164
+
165
+ for (const file of report.files) {
166
+ const rel = file.path.replace(report.targetPath, '').replace(/^[\\/]/, '')
167
+ console.log(
168
+ kleur.bold().white(` ${rel}`) +
169
+ kleur.gray(` (score ${file.score}/100)`)
170
+ )
171
+
172
+ for (const issue of file.issues) {
173
+ const icon = severityIcon(issue.severity)
174
+ const colorFn = (s: string) =>
175
+ issue.severity === 'error'
176
+ ? kleur.red(s)
177
+ : issue.severity === 'warning'
178
+ ? kleur.yellow(s)
179
+ : kleur.cyan(s)
180
+
181
181
  console.log(
182
182
  ` ${colorFn(icon)} ` +
183
183
  kleur.gray(`L${issue.line}`) +
package/src/types.ts CHANGED
@@ -1,18 +1,18 @@
1
- export interface DriftIssue {
2
- rule: string
3
- severity: 'error' | 'warning' | 'info'
4
- message: string
5
- line: number
6
- column: number
7
- snippet: string
8
- }
9
-
10
- export interface FileReport {
11
- path: string
12
- issues: DriftIssue[]
13
- score: number // 0–100, higher = more drift
14
- }
15
-
1
+ export interface DriftIssue {
2
+ rule: string
3
+ severity: 'error' | 'warning' | 'info'
4
+ message: string
5
+ line: number
6
+ column: number
7
+ snippet: string
8
+ }
9
+
10
+ export interface FileReport {
11
+ path: string
12
+ issues: DriftIssue[]
13
+ score: number // 0–100, higher = more drift
14
+ }
15
+
16
16
  export interface DriftReport {
17
17
  scannedAt: string
18
18
  targetPath: string
package/src/utils.ts CHANGED
@@ -1,35 +1,35 @@
1
- import kleur from 'kleur'
2
- import type { DriftIssue } from './types.js'
3
-
4
- export interface Grade {
5
- badge: string
6
- label: string
7
- }
8
-
9
- export function scoreToGrade(score: number): Grade {
10
- if (score === 0) return { badge: kleur.green('CLEAN'), label: 'clean' }
11
- if (score < 20) return { badge: kleur.green('LOW'), label: 'low' }
12
- if (score < 45) return { badge: kleur.yellow('MODERATE'), label: 'moderate' }
13
- if (score < 70) return { badge: kleur.red('HIGH'), label: 'high' }
14
- return { badge: kleur.bold().red('CRITICAL'), label: 'critical' }
15
- }
16
-
17
- export function scoreToGradeText(score: number): Grade {
18
- if (score === 0) return { badge: '✦ CLEAN', label: 'clean' }
19
- if (score < 20) return { badge: '◎ LOW', label: 'low' }
20
- if (score < 45) return { badge: '◈ MODERATE', label: 'moderate' }
21
- if (score < 70) return { badge: '◉ HIGH', label: 'high' }
22
- return { badge: '⬡ CRITICAL', label: 'critical' }
23
- }
24
-
25
- export function severityIcon(s: DriftIssue['severity']): string {
26
- if (s === 'error') return '✖'
27
- if (s === 'warning') return '▲'
28
- return '◦'
29
- }
30
-
31
- export function scoreBar(score: number, width = 20): string {
32
- const filled = Math.round((score / 100) * width)
33
- const empty = width - filled
34
- return '█'.repeat(filled) + '░'.repeat(empty)
35
- }
1
+ import kleur from 'kleur'
2
+ import type { DriftIssue } from './types.js'
3
+
4
+ export interface Grade {
5
+ badge: string
6
+ label: string
7
+ }
8
+
9
+ export function scoreToGrade(score: number): Grade {
10
+ if (score === 0) return { badge: kleur.green('CLEAN'), label: 'clean' }
11
+ if (score < 20) return { badge: kleur.green('LOW'), label: 'low' }
12
+ if (score < 45) return { badge: kleur.yellow('MODERATE'), label: 'moderate' }
13
+ if (score < 70) return { badge: kleur.red('HIGH'), label: 'high' }
14
+ return { badge: kleur.bold().red('CRITICAL'), label: 'critical' }
15
+ }
16
+
17
+ export function scoreToGradeText(score: number): Grade {
18
+ if (score === 0) return { badge: '✦ CLEAN', label: 'clean' }
19
+ if (score < 20) return { badge: '◎ LOW', label: 'low' }
20
+ if (score < 45) return { badge: '◈ MODERATE', label: 'moderate' }
21
+ if (score < 70) return { badge: '◉ HIGH', label: 'high' }
22
+ return { badge: '⬡ CRITICAL', label: 'critical' }
23
+ }
24
+
25
+ export function severityIcon(s: DriftIssue['severity']): string {
26
+ if (s === 'error') return '✖'
27
+ if (s === 'warning') return '▲'
28
+ return '◦'
29
+ }
30
+
31
+ export function scoreBar(score: number, width = 20): string {
32
+ const filled = Math.round((score / 100) * width)
33
+ const empty = width - filled
34
+ return '█'.repeat(filled) + '░'.repeat(empty)
35
+ }