@eduardbar/drift 0.4.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -9,6 +9,26 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Added
13
+
14
+ - **Phase 6: Static HTML report + README badge** — output commands for visibility
15
+ - `drift report [path]` — generates a self-contained `drift-report.html` with score, per-file breakdown, collapsible issue list, and fix suggestions
16
+ - `drift badge [path]` — generates a `badge.svg` with the current drift score for your README
17
+ - `drift ci [path]` — emits GitHub Actions workflow annotations inline on PR diffs and writes a step summary; supports `--min-score` to gate PRs
18
+
19
+ ---
20
+
21
+ ## [0.5.0] — 2026-02-24
22
+
23
+ ### Added
24
+
25
+ - **Phase 5: AI authorship heuristics** — 5 new rules that detect patterns AI code generators produce
26
+ - `over-commented` (info, weight 4): functions where comment density ≥ 40% — AI over-documents the obvious
27
+ - `hardcoded-config` (warning, weight 10): hardcoded URLs, IPs, or connection strings instead of env vars
28
+ - `inconsistent-error-handling` (warning, weight 8): mixed `try/catch` and `.catch()` patterns in the same file
29
+ - `unnecessary-abstraction` (warning, weight 7): single-method interfaces or abstract classes never reused
30
+ - `naming-inconsistency` (warning, weight 6): mixed camelCase and snake_case identifiers in the same scope
31
+
12
32
  ---
13
33
 
14
34
  ## [0.4.0] — 2026-02-23
package/README.md CHANGED
@@ -105,6 +105,40 @@ drift diff --json # Output raw JSON diff
105
105
 
106
106
  Shows score delta, new issues introduced, and issues resolved per file.
107
107
 
108
+ ### `drift report [path]`
109
+
110
+ Generate a self-contained `drift-report.html` — open in any browser:
111
+
112
+ ```bash
113
+ drift report # scan current directory
114
+ drift report ./src # scan specific path
115
+ ```
116
+
117
+ No server needed. The file embeds all styles and data inline.
118
+
119
+ ### `drift badge [path]`
120
+
121
+ Generate a `badge.svg` with the current score for your README:
122
+
123
+ ```bash
124
+ drift badge # writes badge.svg to current directory
125
+ drift badge ./src # scan specific path
126
+ ```
127
+
128
+ Drop the generated file in your repo and reference it as a local badge.
129
+
130
+ ### `drift ci [path]`
131
+
132
+ Emit GitHub Actions annotations and step summary:
133
+
134
+ ```bash
135
+ drift ci # scan current directory
136
+ drift ci ./src # scan specific path
137
+ drift ci --min-score 60 # exit code 1 if score exceeds threshold
138
+ ```
139
+
140
+ Outputs inline annotations visible in the PR diff. Use `--min-score` to gate merges.
141
+
108
142
  ### AI Integration
109
143
 
110
144
  Use `--ai` to get structured output that LLMs can consume:
@@ -151,6 +185,11 @@ npx @eduardbar/drift scan ./src --fix
151
185
  | `magic-number` | info | Numeric literals used directly in logic — extract to named constants |
152
186
  | `layer-violation` | error | Layer imports a layer it's not allowed to (requires `drift.config.ts`) |
153
187
  | `cross-boundary-import` | warning | Module imports from another module outside allowed boundaries (requires `drift.config.ts`) |
188
+ | `over-commented` | info | Functions where comments exceed 40% of lines — AI over-documents the obvious |
189
+ | `hardcoded-config` | warning | Hardcoded URLs, IPs, or connection strings — AI skips environment variables |
190
+ | `inconsistent-error-handling` | warning | Mixed `try/catch` and `.catch()` in the same file — AI combines styles randomly |
191
+ | `unnecessary-abstraction` | warning | Single-method interfaces or abstract classes with no reuse — AI over-engineers |
192
+ | `naming-inconsistency` | warning | Mixed camelCase and snake_case in the same scope — AI forgets project conventions |
154
193
 
155
194
  ---
156
195
 
@@ -178,9 +217,21 @@ Without a config file, these two rules are silently skipped. All other rules run
178
217
 
179
218
  ---
180
219
 
181
- ## ⚙️ CI Integration
220
+ ## ⚙️ CI / GitHub Actions
221
+
222
+ Add drift to your PR workflow to gate on score and get inline annotations:
223
+
224
+ ```yaml
225
+ - name: Run drift
226
+ run: npx @eduardbar/drift ci --min-score 60
227
+ ```
228
+
229
+ This will:
230
+ - Emit inline annotations on the exact lines with issues (visible in the PR diff)
231
+ - Write a summary to the GitHub Actions step summary
232
+ - Exit with code 1 if the score exceeds the threshold
182
233
 
183
- Drop this into your GitHub Actions workflow to block merges when drift exceeds your threshold:
234
+ If you only need a pass/fail gate without annotations, `scan` works too:
184
235
 
185
236
  ```yaml
186
237
  - name: Check for vibe coding drift
package/dist/analyzer.js CHANGED
@@ -28,6 +28,12 @@ const RULE_WEIGHTS = {
28
28
  // Phase 3b/c: layer and module boundary enforcement (require drift.config.ts)
29
29
  'layer-violation': { severity: 'error', weight: 16 },
30
30
  'cross-boundary-import': { severity: 'warning', weight: 10 },
31
+ // Phase 5: AI authorship heuristics
32
+ 'over-commented': { severity: 'info', weight: 4 },
33
+ 'hardcoded-config': { severity: 'warning', weight: 10 },
34
+ 'inconsistent-error-handling': { severity: 'warning', weight: 8 },
35
+ 'unnecessary-abstraction': { severity: 'warning', weight: 7 },
36
+ 'naming-inconsistency': { severity: 'warning', weight: 6 },
31
37
  };
32
38
  function hasIgnoreComment(file, line) {
33
39
  const lines = file.getFullText().split('\n');
@@ -509,6 +515,268 @@ function detectCommentContradiction(file) {
509
515
  return issues;
510
516
  }
511
517
  // ---------------------------------------------------------------------------
518
+ // Phase 5: AI authorship heuristics
519
+ // ---------------------------------------------------------------------------
520
+ function detectOverCommented(file) {
521
+ const issues = [];
522
+ for (const fn of file.getFunctions()) {
523
+ const body = fn.getBody();
524
+ if (!body)
525
+ continue;
526
+ const bodyText = body.getText();
527
+ const lines = bodyText.split('\n');
528
+ const totalLines = lines.length;
529
+ if (totalLines < 6)
530
+ continue;
531
+ let commentLines = 0;
532
+ for (const line of lines) {
533
+ const trimmed = line.trim();
534
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
535
+ commentLines++;
536
+ }
537
+ }
538
+ const ratio = commentLines / totalLines;
539
+ if (ratio >= 0.4) {
540
+ issues.push({
541
+ rule: 'over-commented',
542
+ severity: 'info',
543
+ message: `Function has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
544
+ line: fn.getStartLineNumber(),
545
+ column: fn.getStartLinePos(),
546
+ snippet: fn.getName() ? `function ${fn.getName()}` : '(anonymous function)',
547
+ });
548
+ }
549
+ }
550
+ for (const cls of file.getClasses()) {
551
+ for (const method of cls.getMethods()) {
552
+ const body = method.getBody();
553
+ if (!body)
554
+ continue;
555
+ const bodyText = body.getText();
556
+ const lines = bodyText.split('\n');
557
+ const totalLines = lines.length;
558
+ if (totalLines < 6)
559
+ continue;
560
+ let commentLines = 0;
561
+ for (const line of lines) {
562
+ const trimmed = line.trim();
563
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
564
+ commentLines++;
565
+ }
566
+ }
567
+ const ratio = commentLines / totalLines;
568
+ if (ratio >= 0.4) {
569
+ issues.push({
570
+ rule: 'over-commented',
571
+ severity: 'info',
572
+ message: `Method '${method.getName()}' has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
573
+ line: method.getStartLineNumber(),
574
+ column: method.getStartLinePos(),
575
+ snippet: `${cls.getName()}.${method.getName()}`,
576
+ });
577
+ }
578
+ }
579
+ }
580
+ return issues;
581
+ }
582
+ function detectHardcodedConfig(file) {
583
+ const issues = [];
584
+ const CONFIG_PATTERNS = [
585
+ { pattern: /^https?:\/\//i, label: 'HTTP/HTTPS URL' },
586
+ { pattern: /^wss?:\/\//i, label: 'WebSocket URL' },
587
+ { pattern: /^mongodb(\+srv)?:\/\//i, label: 'MongoDB connection string' },
588
+ { pattern: /^postgres(?:ql)?:\/\//i, label: 'PostgreSQL connection string' },
589
+ { pattern: /^mysql:\/\//i, label: 'MySQL connection string' },
590
+ { pattern: /^redis:\/\//i, label: 'Redis connection string' },
591
+ { pattern: /^amqps?:\/\//i, label: 'AMQP connection string' },
592
+ { pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, label: 'IP address' },
593
+ { pattern: /^:[0-9]{2,5}$/, label: 'Port number in string' },
594
+ { pattern: /^\/[a-z]/i, label: 'Absolute file path' },
595
+ { pattern: /localhost(:[0-9]+)?/i, label: 'localhost reference' },
596
+ ];
597
+ const filePath = file.getFilePath().replace(/\\/g, '/');
598
+ if (filePath.includes('.test.') || filePath.includes('.spec.') || filePath.includes('__tests__')) {
599
+ return issues;
600
+ }
601
+ for (const node of file.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
602
+ const value = node.getLiteralValue();
603
+ if (!value || value.length < 4)
604
+ continue;
605
+ const parent = node.getParent();
606
+ if (!parent)
607
+ continue;
608
+ const parentKind = parent.getKindName();
609
+ if (parentKind === 'ImportDeclaration' ||
610
+ parentKind === 'ExportDeclaration' ||
611
+ (parentKind === 'CallExpression' && parent.getText().startsWith('import(')))
612
+ continue;
613
+ for (const { pattern, label } of CONFIG_PATTERNS) {
614
+ if (pattern.test(value)) {
615
+ issues.push({
616
+ rule: 'hardcoded-config',
617
+ severity: 'warning',
618
+ message: `Hardcoded ${label} detected. AI skips environment variables — extract to process.env or a config module.`,
619
+ line: node.getStartLineNumber(),
620
+ column: node.getStartLinePos(),
621
+ snippet: value.length > 60 ? value.slice(0, 60) + '...' : value,
622
+ });
623
+ break;
624
+ }
625
+ }
626
+ }
627
+ return issues;
628
+ }
629
+ function detectInconsistentErrorHandling(file) {
630
+ const issues = [];
631
+ let hasTryCatch = false;
632
+ let hasDotCatch = false;
633
+ let hasThenErrorHandler = false;
634
+ let firstLine = 0;
635
+ // Detectar try/catch
636
+ const tryCatches = file.getDescendantsOfKind(SyntaxKind.TryStatement);
637
+ if (tryCatches.length > 0) {
638
+ hasTryCatch = true;
639
+ firstLine = firstLine || tryCatches[0].getStartLineNumber();
640
+ }
641
+ // Detectar .catch(handler) en call expressions
642
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
643
+ const expr = call.getExpression();
644
+ if (expr.getKindName() === 'PropertyAccessExpression') {
645
+ const propAccess = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
646
+ const propName = propAccess.getName();
647
+ if (propName === 'catch') {
648
+ // Verificar que tiene al menos un argumento (handler real, no .catch() vacío)
649
+ if (call.getArguments().length > 0) {
650
+ hasDotCatch = true;
651
+ if (!firstLine)
652
+ firstLine = call.getStartLineNumber();
653
+ }
654
+ }
655
+ // Detectar .then(onFulfilled, onRejected) — segundo argumento = error handler
656
+ if (propName === 'then' && call.getArguments().length >= 2) {
657
+ hasThenErrorHandler = true;
658
+ if (!firstLine)
659
+ firstLine = call.getStartLineNumber();
660
+ }
661
+ }
662
+ }
663
+ const stylesUsed = [hasTryCatch, hasDotCatch, hasThenErrorHandler].filter(Boolean).length;
664
+ if (stylesUsed >= 2) {
665
+ const styles = [];
666
+ if (hasTryCatch)
667
+ styles.push('try/catch');
668
+ if (hasDotCatch)
669
+ styles.push('.catch()');
670
+ if (hasThenErrorHandler)
671
+ styles.push('.then(_, handler)');
672
+ issues.push({
673
+ rule: 'inconsistent-error-handling',
674
+ severity: 'warning',
675
+ message: `Mixed error handling styles: ${styles.join(', ')}. AI uses whatever pattern it saw last — pick one and stick to it.`,
676
+ line: firstLine || 1,
677
+ column: 1,
678
+ snippet: styles.join(' + '),
679
+ });
680
+ }
681
+ return issues;
682
+ }
683
+ function detectUnnecessaryAbstraction(file) {
684
+ const issues = [];
685
+ const fileText = file.getFullText();
686
+ // Interfaces con un solo método
687
+ for (const iface of file.getInterfaces()) {
688
+ const methods = iface.getMethods();
689
+ const properties = iface.getProperties();
690
+ // Solo reportar si tiene exactamente 1 método y 0 propiedades (abstracción pura de comportamiento)
691
+ if (methods.length !== 1 || properties.length !== 0)
692
+ continue;
693
+ const ifaceName = iface.getName();
694
+ // Contar cuántas veces aparece el nombre en el archivo (excluyendo la declaración misma)
695
+ const usageCount = (fileText.match(new RegExp(`\\b${ifaceName}\\b`, 'g')) ?? []).length;
696
+ // La declaración misma cuenta como 1 uso, implementaciones cuentan como 1 cada una
697
+ // Si usageCount <= 2 (declaración + 1 uso), es candidata a innecesaria
698
+ if (usageCount <= 2) {
699
+ issues.push({
700
+ rule: 'unnecessary-abstraction',
701
+ severity: 'warning',
702
+ message: `Interface '${ifaceName}' has 1 method and is used only once. AI creates abstractions preemptively — YAGNI.`,
703
+ line: iface.getStartLineNumber(),
704
+ column: iface.getStartLinePos(),
705
+ snippet: `interface ${ifaceName} { ${methods[0].getName()}(...) }`,
706
+ });
707
+ }
708
+ }
709
+ // Clases abstractas con un solo método abstracto y sin implementaciones en el archivo
710
+ for (const cls of file.getClasses()) {
711
+ if (!cls.isAbstract())
712
+ continue;
713
+ const abstractMethods = cls.getMethods().filter(m => m.isAbstract());
714
+ const concreteMethods = cls.getMethods().filter(m => !m.isAbstract());
715
+ if (abstractMethods.length !== 1 || concreteMethods.length !== 0)
716
+ continue;
717
+ const clsName = cls.getName() ?? '';
718
+ const usageCount = (fileText.match(new RegExp(`\\b${clsName}\\b`, 'g')) ?? []).length;
719
+ if (usageCount <= 2) {
720
+ issues.push({
721
+ rule: 'unnecessary-abstraction',
722
+ severity: 'warning',
723
+ message: `Abstract class '${clsName}' has 1 abstract method and is extended nowhere in this file. AI over-engineers single-use code.`,
724
+ line: cls.getStartLineNumber(),
725
+ column: cls.getStartLinePos(),
726
+ snippet: `abstract class ${clsName}`,
727
+ });
728
+ }
729
+ }
730
+ return issues;
731
+ }
732
+ function detectNamingInconsistency(file) {
733
+ const issues = [];
734
+ const isCamelCase = (name) => /^[a-z][a-zA-Z0-9]*$/.test(name) && /[A-Z]/.test(name);
735
+ const isSnakeCase = (name) => /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(name);
736
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
737
+ function checkFunction(fn) {
738
+ const vars = fn.getVariableDeclarations();
739
+ if (vars.length < 3)
740
+ return; // muy pocas vars para ser significativo
741
+ let camelCount = 0;
742
+ let snakeCount = 0;
743
+ const snakeExamples = [];
744
+ const camelExamples = [];
745
+ for (const v of vars) {
746
+ const name = v.getName();
747
+ if (isCamelCase(name)) {
748
+ camelCount++;
749
+ if (camelExamples.length < 2)
750
+ camelExamples.push(name);
751
+ }
752
+ else if (isSnakeCase(name)) {
753
+ snakeCount++;
754
+ if (snakeExamples.length < 2)
755
+ snakeExamples.push(name);
756
+ }
757
+ }
758
+ if (camelCount >= 1 && snakeCount >= 1) {
759
+ issues.push({
760
+ rule: 'naming-inconsistency',
761
+ severity: 'warning',
762
+ message: `Mixed naming conventions: camelCase (${camelExamples.join(', ')}) and snake_case (${snakeExamples.join(', ')}) in the same scope. AI mixes conventions from different training examples.`,
763
+ line: fn.getStartLineNumber(),
764
+ column: fn.getStartLinePos(),
765
+ snippet: `camelCase: ${camelExamples[0]} / snake_case: ${snakeExamples[0]}`,
766
+ });
767
+ }
768
+ }
769
+ for (const fn of file.getFunctions()) {
770
+ checkFunction(fn);
771
+ }
772
+ for (const cls of file.getClasses()) {
773
+ for (const method of cls.getMethods()) {
774
+ checkFunction(method);
775
+ }
776
+ }
777
+ return issues;
778
+ }
779
+ // ---------------------------------------------------------------------------
512
780
  // Score
513
781
  // ---------------------------------------------------------------------------
514
782
  function calculateScore(issues) {
@@ -547,6 +815,12 @@ export function analyzeFile(file) {
547
815
  // Stubs now implemented
548
816
  ...detectMagicNumbers(file),
549
817
  ...detectCommentContradiction(file),
818
+ // Phase 5: AI authorship heuristics
819
+ ...detectOverCommented(file),
820
+ ...detectHardcodedConfig(file),
821
+ ...detectInconsistentErrorHandling(file),
822
+ ...detectUnnecessaryAbstraction(file),
823
+ ...detectNamingInconsistency(file),
550
824
  ];
551
825
  return {
552
826
  path: file.getFilePath(),
@@ -0,0 +1,2 @@
1
+ export declare function generateBadge(score: number): string;
2
+ //# sourceMappingURL=badge.d.ts.map
package/dist/badge.js ADDED
@@ -0,0 +1,57 @@
1
+ const LEFT_WIDTH = 47;
2
+ const CHAR_WIDTH = 7;
3
+ const PADDING = 16;
4
+ function scoreColor(score) {
5
+ if (score < 20)
6
+ return '#4c1';
7
+ if (score < 45)
8
+ return '#dfb317';
9
+ if (score < 70)
10
+ return '#fe7d37';
11
+ return '#e05d44';
12
+ }
13
+ function scoreLabel(score) {
14
+ if (score < 20)
15
+ return 'LOW';
16
+ if (score < 45)
17
+ return 'MODERATE';
18
+ if (score < 70)
19
+ return 'HIGH';
20
+ return 'CRITICAL';
21
+ }
22
+ function rightWidth(text) {
23
+ return text.length * CHAR_WIDTH + PADDING;
24
+ }
25
+ export function generateBadge(score) {
26
+ const valueText = `${score} ${scoreLabel(score)}`;
27
+ const color = scoreColor(score);
28
+ const rWidth = rightWidth(valueText);
29
+ const totalWidth = LEFT_WIDTH + rWidth;
30
+ const leftCenterX = LEFT_WIDTH / 2;
31
+ const rightCenterX = LEFT_WIDTH + rWidth / 2;
32
+ // shields.io pattern: font-size="110" + scale(.1) = effective 11px
33
+ // all X/Y coords are ×10
34
+ const leftTextWidth = (LEFT_WIDTH - 10) * 10;
35
+ const rightTextWidth = (rWidth - PADDING) * 10;
36
+ return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${totalWidth}" height="20">
37
+ <linearGradient id="s" x2="0" y2="100%">
38
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
39
+ <stop offset="1" stop-opacity=".1"/>
40
+ </linearGradient>
41
+ <clipPath id="r">
42
+ <rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
43
+ </clipPath>
44
+ <g clip-path="url(#r)">
45
+ <rect width="${LEFT_WIDTH}" height="20" fill="#555"/>
46
+ <rect x="${LEFT_WIDTH}" width="${rWidth}" height="20" fill="${color}"/>
47
+ <rect width="${totalWidth}" height="20" fill="url(#s)"/>
48
+ </g>
49
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
50
+ <text x="${leftCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
51
+ <text x="${leftCenterX * 10}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
52
+ <text x="${rightCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
53
+ <text x="${rightCenterX * 10}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
54
+ </g>
55
+ </svg>`;
56
+ }
57
+ //# sourceMappingURL=badge.js.map
package/dist/ci.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { DriftReport } from './types.js';
2
+ export declare function emitCIAnnotations(report: DriftReport): void;
3
+ export declare function printCISummary(report: DriftReport): void;
4
+ //# sourceMappingURL=ci.d.ts.map
package/dist/ci.js ADDED
@@ -0,0 +1,85 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { relative } from 'node:path';
3
+ function encodeMessage(msg) {
4
+ return msg
5
+ .replace(/%/g, '%25')
6
+ .replace(/\r/g, '%0D')
7
+ .replace(/\n/g, '%0A')
8
+ .replace(/:/g, '%3A')
9
+ .replace(/,/g, '%2C');
10
+ }
11
+ function severityToAnnotation(s) {
12
+ if (s === 'error')
13
+ return 'error';
14
+ if (s === 'warning')
15
+ return 'warning';
16
+ return 'notice';
17
+ }
18
+ function scoreLabel(score) {
19
+ if (score >= 80)
20
+ return 'A';
21
+ if (score >= 60)
22
+ return 'B';
23
+ if (score >= 40)
24
+ return 'C';
25
+ if (score >= 20)
26
+ return 'D';
27
+ return 'F';
28
+ }
29
+ export function emitCIAnnotations(report) {
30
+ for (const file of report.files) {
31
+ for (const issue of file.issues) {
32
+ const level = severityToAnnotation(issue.severity);
33
+ const relPath = relative(process.cwd(), file.path).replace(/\\/g, '/');
34
+ const msg = encodeMessage(`[drift/${issue.rule}] ${issue.message}`);
35
+ const line = issue.line ?? 1;
36
+ const col = issue.column ?? 1;
37
+ process.stdout.write(`::${level} file=${relPath},line=${line},col=${col}::${msg}\n`);
38
+ }
39
+ }
40
+ }
41
+ export function printCISummary(report) {
42
+ const summaryPath = process.env['GITHUB_STEP_SUMMARY'];
43
+ if (!summaryPath)
44
+ return;
45
+ const score = report.totalScore;
46
+ const grade = scoreLabel(score);
47
+ let errors = 0;
48
+ let warnings = 0;
49
+ let info = 0;
50
+ for (const file of report.files) {
51
+ for (const issue of file.issues) {
52
+ if (issue.severity === 'error')
53
+ errors++;
54
+ else if (issue.severity === 'warning')
55
+ warnings++;
56
+ else
57
+ info++;
58
+ }
59
+ }
60
+ const sorted = [...report.files]
61
+ .sort((a, b) => b.issues.length - a.issues.length)
62
+ .slice(0, 10);
63
+ const rows = sorted
64
+ .map((f) => {
65
+ const relPath = relative(process.cwd(), f.path).replace(/\\/g, '/');
66
+ return `| ${relPath} | ${f.score} | ${f.issues.length} |`;
67
+ })
68
+ .join('\n');
69
+ const md = [
70
+ '## drift scan results',
71
+ '',
72
+ `**Score:** ${score}/100 — Grade **${grade}**`,
73
+ '',
74
+ '### Top files by issue count',
75
+ '',
76
+ '| File | Score | Issues |',
77
+ '|------|-------|--------|',
78
+ rows,
79
+ '',
80
+ `**Total issues:** ${errors} errors, ${warnings} warnings, ${info} info`,
81
+ '',
82
+ ].join('\n');
83
+ writeFileSync(summaryPath, md, { flag: 'a' });
84
+ }
85
+ //# sourceMappingURL=ci.js.map
package/dist/cli.js CHANGED
@@ -8,11 +8,14 @@ import { printConsole, printDiff } from './printer.js';
8
8
  import { loadConfig } from './config.js';
9
9
  import { extractFilesAtRef, cleanupTempDir } from './git.js';
10
10
  import { computeDiff } from './diff.js';
11
+ import { generateHtmlReport } from './report.js';
12
+ import { generateBadge } from './badge.js';
13
+ import { emitCIAnnotations, printCISummary } from './ci.js';
11
14
  const program = new Command();
12
15
  program
13
16
  .name('drift')
14
17
  .description('Detect silent technical debt left by AI-generated code')
15
- .version('0.1.0');
18
+ .version('0.6.0');
16
19
  program
17
20
  .command('scan [path]', { isDefault: true })
18
21
  .description('Scan a directory for vibe coding drift')
@@ -95,5 +98,53 @@ program
95
98
  cleanupTempDir(tempDir);
96
99
  }
97
100
  });
101
+ program
102
+ .command('report [path]')
103
+ .description('Generate a self-contained HTML report')
104
+ .option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
105
+ .action(async (targetPath, options) => {
106
+ const resolvedPath = resolve(targetPath ?? '.');
107
+ process.stderr.write(`\nScanning ${resolvedPath}...\n`);
108
+ const config = await loadConfig(resolvedPath);
109
+ const files = analyzeProject(resolvedPath, config);
110
+ process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
111
+ const report = buildReport(resolvedPath, files);
112
+ const html = generateHtmlReport(report);
113
+ const outPath = resolve(options.output);
114
+ writeFileSync(outPath, html, 'utf8');
115
+ process.stderr.write(` Report saved to ${outPath}\n\n`);
116
+ });
117
+ program
118
+ .command('badge [path]')
119
+ .description('Generate a badge.svg with the current drift score')
120
+ .option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
121
+ .action(async (targetPath, options) => {
122
+ const resolvedPath = resolve(targetPath ?? '.');
123
+ process.stderr.write(`\nScanning ${resolvedPath}...\n`);
124
+ const config = await loadConfig(resolvedPath);
125
+ const files = analyzeProject(resolvedPath, config);
126
+ const report = buildReport(resolvedPath, files);
127
+ const svg = generateBadge(report.totalScore);
128
+ const outPath = resolve(options.output);
129
+ writeFileSync(outPath, svg, 'utf8');
130
+ process.stderr.write(` Badge saved to ${outPath}\n`);
131
+ process.stderr.write(` Score: ${report.totalScore}/100\n\n`);
132
+ });
133
+ program
134
+ .command('ci [path]')
135
+ .description('Emit GitHub Actions annotations and step summary')
136
+ .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
137
+ .action(async (targetPath, options) => {
138
+ const resolvedPath = resolve(targetPath ?? '.');
139
+ const config = await loadConfig(resolvedPath);
140
+ const files = analyzeProject(resolvedPath, config);
141
+ const report = buildReport(resolvedPath, files);
142
+ emitCIAnnotations(report);
143
+ printCISummary(report);
144
+ const minScore = Number(options.minScore);
145
+ if (minScore > 0 && report.totalScore > minScore) {
146
+ process.exit(1);
147
+ }
148
+ });
98
149
  program.parse();
99
150
  //# sourceMappingURL=cli.js.map
package/dist/printer.js CHANGED
@@ -91,6 +91,26 @@ function formatFixSuggestion(issue) {
91
91
  'Or add the module to allowedExternalImports in drift.config.ts if this is intentional',
92
92
  'Consider using dependency injection or an event bus to decouple the modules',
93
93
  ],
94
+ 'over-commented': [
95
+ 'Remove comments that restate what the code already expresses clearly',
96
+ 'Keep only comments that explain WHY, not WHAT — prefer self-documenting names',
97
+ ],
98
+ 'hardcoded-config': [
99
+ 'Move the value to an environment variable: process.env.YOUR_VAR',
100
+ 'Or extract it to a config file / constants module imported at the top',
101
+ ],
102
+ 'inconsistent-error-handling': [
103
+ 'Pick one style (async/await + try/catch is preferred) and apply it consistently',
104
+ 'Avoid mixing .then()/.catch() with await in the same file',
105
+ ],
106
+ 'unnecessary-abstraction': [
107
+ 'Inline the abstraction if it has only one implementation and is never reused',
108
+ 'Or document why the extension point exists (e.g., future plugin system)',
109
+ ],
110
+ 'naming-inconsistency': [
111
+ 'Pick one naming convention (camelCase for variables/functions, PascalCase for types)',
112
+ 'Rename snake_case identifiers to camelCase to match TypeScript conventions',
113
+ ],
94
114
  };
95
115
  return suggestions[issue.rule] ?? ['Review and fix manually'];
96
116
  }
@@ -0,0 +1,3 @@
1
+ import { DriftReport } from './types.js';
2
+ export declare function generateHtmlReport(report: DriftReport): string;
3
+ //# sourceMappingURL=report.d.ts.map