distyll 0.1.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/CONTRIBUTING.md +159 -0
- package/POSTMORTEM.json +60 -0
- package/README.md +218 -0
- package/SETUP.md +79 -0
- package/action.yml +37 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +115 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +153 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/ci.d.ts +7 -0
- package/dist/commands/ci.d.ts.map +1 -0
- package/dist/commands/ci.js +101 -0
- package/dist/commands/ci.js.map +1 -0
- package/dist/commands/diff.d.ts +10 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/diff.js +95 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/fingerprint.d.ts +2 -0
- package/dist/commands/fingerprint.d.ts.map +1 -0
- package/dist/commands/fingerprint.js +77 -0
- package/dist/commands/fingerprint.js.map +1 -0
- package/dist/commands/hook.d.ts +3 -0
- package/dist/commands/hook.d.ts.map +1 -0
- package/dist/commands/hook.js +110 -0
- package/dist/commands/hook.js.map +1 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +75 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +100 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +133 -0
- package/dist/errors.js.map +1 -0
- package/dist/fingerprint/analyzer.d.ts +3 -0
- package/dist/fingerprint/analyzer.d.ts.map +1 -0
- package/dist/fingerprint/analyzer.js +230 -0
- package/dist/fingerprint/analyzer.js.map +1 -0
- package/dist/fingerprint/comparator.d.ts +4 -0
- package/dist/fingerprint/comparator.d.ts.map +1 -0
- package/dist/fingerprint/comparator.js +78 -0
- package/dist/fingerprint/comparator.js.map +1 -0
- package/dist/fingerprint/profile.d.ts +5 -0
- package/dist/fingerprint/profile.d.ts.map +1 -0
- package/dist/fingerprint/profile.js +68 -0
- package/dist/fingerprint/profile.js.map +1 -0
- package/dist/fixes/index.d.ts +12 -0
- package/dist/fixes/index.d.ts.map +1 -0
- package/dist/fixes/index.js +42 -0
- package/dist/fixes/index.js.map +1 -0
- package/dist/fixes/single-use-wrapper.d.ts +8 -0
- package/dist/fixes/single-use-wrapper.d.ts.map +1 -0
- package/dist/fixes/single-use-wrapper.js +54 -0
- package/dist/fixes/single-use-wrapper.js.map +1 -0
- package/dist/fixes/unnecessary-try-catch.d.ts +8 -0
- package/dist/fixes/unnecessary-try-catch.d.ts.map +1 -0
- package/dist/fixes/unnecessary-try-catch.js +37 -0
- package/dist/fixes/unnecessary-try-catch.js.map +1 -0
- package/dist/fixes/unused-imports.d.ts +7 -0
- package/dist/fixes/unused-imports.d.ts.map +1 -0
- package/dist/fixes/unused-imports.js +41 -0
- package/dist/fixes/unused-imports.js.map +1 -0
- package/dist/fixes/verbose-comments.d.ts +7 -0
- package/dist/fixes/verbose-comments.d.ts.map +1 -0
- package/dist/fixes/verbose-comments.js +29 -0
- package/dist/fixes/verbose-comments.js.map +1 -0
- package/dist/formatter.d.ts +4 -0
- package/dist/formatter.d.ts.map +1 -0
- package/dist/formatter.js +72 -0
- package/dist/formatter.js.map +1 -0
- package/dist/git.d.ts +22 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +130 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/languages/index.d.ts +8 -0
- package/dist/languages/index.d.ts.map +1 -0
- package/dist/languages/index.js +50 -0
- package/dist/languages/index.js.map +1 -0
- package/dist/languages/javascript.d.ts +6 -0
- package/dist/languages/javascript.d.ts.map +1 -0
- package/dist/languages/javascript.js +39 -0
- package/dist/languages/javascript.js.map +1 -0
- package/dist/languages/python.d.ts +6 -0
- package/dist/languages/python.d.ts.map +1 -0
- package/dist/languages/python.js +50 -0
- package/dist/languages/python.js.map +1 -0
- package/dist/parser.d.ts +8 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +55 -0
- package/dist/parser.js.map +1 -0
- package/dist/reporters/github.d.ts +4 -0
- package/dist/reporters/github.d.ts.map +1 -0
- package/dist/reporters/github.js +70 -0
- package/dist/reporters/github.js.map +1 -0
- package/dist/reporters/terminal.d.ts +4 -0
- package/dist/reporters/terminal.d.ts.map +1 -0
- package/dist/reporters/terminal.js +59 -0
- package/dist/reporters/terminal.js.map +1 -0
- package/dist/rules/dead-code-paths.d.ts +3 -0
- package/dist/rules/dead-code-paths.d.ts.map +1 -0
- package/dist/rules/dead-code-paths.js +57 -0
- package/dist/rules/dead-code-paths.js.map +1 -0
- package/dist/rules/excessive-comments.d.ts +3 -0
- package/dist/rules/excessive-comments.d.ts.map +1 -0
- package/dist/rules/excessive-comments.js +86 -0
- package/dist/rules/excessive-comments.js.map +1 -0
- package/dist/rules/hallucinated-imports.d.ts +3 -0
- package/dist/rules/hallucinated-imports.d.ts.map +1 -0
- package/dist/rules/hallucinated-imports.js +228 -0
- package/dist/rules/hallucinated-imports.js.map +1 -0
- package/dist/rules/index.d.ts +4 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +34 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/magic-values.d.ts +3 -0
- package/dist/rules/magic-values.d.ts.map +1 -0
- package/dist/rules/magic-values.js +168 -0
- package/dist/rules/magic-values.js.map +1 -0
- package/dist/rules/near-duplicate-functions.d.ts +3 -0
- package/dist/rules/near-duplicate-functions.d.ts.map +1 -0
- package/dist/rules/near-duplicate-functions.js +78 -0
- package/dist/rules/near-duplicate-functions.js.map +1 -0
- package/dist/rules/over-defensive-nulls.d.ts +3 -0
- package/dist/rules/over-defensive-nulls.d.ts.map +1 -0
- package/dist/rules/over-defensive-nulls.js +129 -0
- package/dist/rules/over-defensive-nulls.js.map +1 -0
- package/dist/rules/redundant-else-return.d.ts +3 -0
- package/dist/rules/redundant-else-return.d.ts.map +1 -0
- package/dist/rules/redundant-else-return.js +57 -0
- package/dist/rules/redundant-else-return.js.map +1 -0
- package/dist/rules/single-option-object.d.ts +3 -0
- package/dist/rules/single-option-object.d.ts.map +1 -0
- package/dist/rules/single-option-object.js +88 -0
- package/dist/rules/single-option-object.js.map +1 -0
- package/dist/rules/single-use-wrapper.d.ts +3 -0
- package/dist/rules/single-use-wrapper.d.ts.map +1 -0
- package/dist/rules/single-use-wrapper.js +172 -0
- package/dist/rules/single-use-wrapper.js.map +1 -0
- package/dist/rules/unnecessary-try-catch.d.ts +3 -0
- package/dist/rules/unnecessary-try-catch.d.ts.map +1 -0
- package/dist/rules/unnecessary-try-catch.js +116 -0
- package/dist/rules/unnecessary-try-catch.js.map +1 -0
- package/dist/rules/unused-imports.d.ts +3 -0
- package/dist/rules/unused-imports.d.ts.map +1 -0
- package/dist/rules/unused-imports.js +103 -0
- package/dist/rules/unused-imports.js.map +1 -0
- package/dist/rules/verbose-comments.d.ts +3 -0
- package/dist/rules/verbose-comments.d.ts.map +1 -0
- package/dist/rules/verbose-comments.js +100 -0
- package/dist/rules/verbose-comments.js.map +1 -0
- package/dist/scanner.d.ts +11 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +196 -0
- package/dist/scanner.js.map +1 -0
- package/dist/scorer.d.ts +3 -0
- package/dist/scorer.d.ts.map +1 -0
- package/dist/scorer.js +23 -0
- package/dist/scorer.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/hn_post.md +13 -0
- package/marketing/COMPETITIVE_ANALYSIS.md +62 -0
- package/marketing/EMAIL_ANNOUNCEMENT.md +91 -0
- package/marketing/LANDING_PAGE_COPY.md +123 -0
- package/marketing/LAUNCH_POST.md +68 -0
- package/marketing/PRODUCT_HUNT.md +39 -0
- package/marketing/TWITTER_THREAD.md +70 -0
- package/package.json +44 -0
- package/producthunt.md +52 -0
- package/reddit_post.md +39 -0
- package/site/favicon.svg +10 -0
- package/site/index.html +281 -0
- package/site/script.js +82 -0
- package/site/style.css +516 -0
- package/src/cache.ts +114 -0
- package/src/cli.ts +169 -0
- package/src/commands/ci.ts +111 -0
- package/src/commands/diff.ts +108 -0
- package/src/commands/fingerprint.ts +47 -0
- package/src/commands/hook.ts +85 -0
- package/src/commands/init.ts +42 -0
- package/src/config.ts +75 -0
- package/src/errors.ts +105 -0
- package/src/fingerprint/analyzer.ts +214 -0
- package/src/fingerprint/comparator.ts +93 -0
- package/src/fingerprint/profile.ts +32 -0
- package/src/fixes/index.ts +58 -0
- package/src/fixes/single-use-wrapper.ts +60 -0
- package/src/fixes/unnecessary-try-catch.ts +43 -0
- package/src/fixes/unused-imports.ts +53 -0
- package/src/fixes/verbose-comments.ts +35 -0
- package/src/formatter.ts +79 -0
- package/src/git.ts +115 -0
- package/src/index.ts +15 -0
- package/src/languages/index.ts +50 -0
- package/src/languages/javascript.ts +36 -0
- package/src/languages/python.ts +47 -0
- package/src/parser.ts +52 -0
- package/src/reporters/github.ts +75 -0
- package/src/reporters/terminal.ts +67 -0
- package/src/rules/dead-code-paths.ts +62 -0
- package/src/rules/excessive-comments.ts +94 -0
- package/src/rules/hallucinated-imports.ts +195 -0
- package/src/rules/index.ts +32 -0
- package/src/rules/magic-values.ts +167 -0
- package/src/rules/near-duplicate-functions.ts +89 -0
- package/src/rules/over-defensive-nulls.ts +137 -0
- package/src/rules/redundant-else-return.ts +61 -0
- package/src/rules/single-option-object.ts +97 -0
- package/src/rules/single-use-wrapper.ts +184 -0
- package/src/rules/unnecessary-try-catch.ts +121 -0
- package/src/rules/unused-imports.ts +115 -0
- package/src/rules/verbose-comments.ts +105 -0
- package/src/scanner.ts +184 -0
- package/src/scorer.ts +26 -0
- package/src/types.ts +70 -0
- package/tests/commands/diff.test.ts +107 -0
- package/tests/config.test.ts +69 -0
- package/tests/e2e.test.ts +163 -0
- package/tests/edge-cases.test.ts +167 -0
- package/tests/fingerprint/analyzer.test.ts +131 -0
- package/tests/fixes/unnecessary-try-catch.test.ts +62 -0
- package/tests/git.test.ts +79 -0
- package/tests/rules/hallucinated-imports.test.ts +59 -0
- package/tests/rules/near-duplicate-functions.test.ts +90 -0
- package/tests/rules/unnecessary-try-catch.test.ts +81 -0
- package/tests/scanner.test.ts +88 -0
- package/tsconfig.json +20 -0
- package/twitter_thread.md +46 -0
- package/vitest.config.ts +7 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
|
|
3
|
+
export class DistyllError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
message: string,
|
|
6
|
+
public readonly hint?: string
|
|
7
|
+
) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'DistyllError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class NotAGitRepoError extends DistyllError {
|
|
14
|
+
constructor() {
|
|
15
|
+
super(
|
|
16
|
+
'Not a git repository.',
|
|
17
|
+
'Run this command from inside a git repo, or use "distyll scan" instead.'
|
|
18
|
+
);
|
|
19
|
+
this.name = 'NotAGitRepoError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class UnsupportedLanguageError extends DistyllError {
|
|
24
|
+
constructor(filePath: string) {
|
|
25
|
+
super(
|
|
26
|
+
`Unsupported language for file: ${filePath}`,
|
|
27
|
+
'Distyll currently supports JavaScript, TypeScript, and Python files.'
|
|
28
|
+
);
|
|
29
|
+
this.name = 'UnsupportedLanguageError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class FileAccessError extends DistyllError {
|
|
34
|
+
constructor(filePath: string, reason: string) {
|
|
35
|
+
super(
|
|
36
|
+
`Cannot read file: ${filePath} (${reason})`,
|
|
37
|
+
'Check file permissions and ensure the file exists.'
|
|
38
|
+
);
|
|
39
|
+
this.name = 'FileAccessError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a buffer contains binary content (null bytes in first 1024 bytes).
|
|
45
|
+
*/
|
|
46
|
+
export function isBinaryBuffer(buffer: Buffer): boolean {
|
|
47
|
+
const checkLength = Math.min(buffer.length, 1024);
|
|
48
|
+
for (let i = 0; i < checkLength; i++) {
|
|
49
|
+
if (buffer[i] === 0) return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Safely read a source file, returning null for binary/empty/inaccessible files.
|
|
56
|
+
* When verbose is true, logs skip reasons to stderr.
|
|
57
|
+
*/
|
|
58
|
+
export function safeReadFile(
|
|
59
|
+
filePath: string,
|
|
60
|
+
verbose = false
|
|
61
|
+
): { source: string; buffer: Buffer } | null {
|
|
62
|
+
let buffer: Buffer;
|
|
63
|
+
try {
|
|
64
|
+
buffer = fs.readFileSync(filePath);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (verbose) {
|
|
67
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
68
|
+
if (code === 'EACCES') {
|
|
69
|
+
console.error(` [skip] ${filePath}: permission denied`);
|
|
70
|
+
} else if (code === 'ENOENT') {
|
|
71
|
+
console.error(` [skip] ${filePath}: file not found`);
|
|
72
|
+
} else {
|
|
73
|
+
console.error(` [skip] ${filePath}: ${(err as Error).message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (buffer.length === 0) {
|
|
80
|
+
if (verbose) console.error(` [skip] ${filePath}: empty file`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isBinaryBuffer(buffer)) {
|
|
85
|
+
if (verbose) console.error(` [skip] ${filePath}: binary file`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { source: buffer.toString('utf-8'), buffer };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Format an error for user-facing display.
|
|
94
|
+
*/
|
|
95
|
+
export function formatError(err: unknown): string {
|
|
96
|
+
if (err instanceof DistyllError) {
|
|
97
|
+
let msg = `Error: ${err.message}`;
|
|
98
|
+
if (err.hint) msg += `\nHint: ${err.hint}`;
|
|
99
|
+
return msg;
|
|
100
|
+
}
|
|
101
|
+
if (err instanceof Error) {
|
|
102
|
+
return `Error: ${err.message}`;
|
|
103
|
+
}
|
|
104
|
+
return `Error: ${String(err)}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { parse, detectLanguage, walkTree, findNodes } from '../parser';
|
|
3
|
+
import { resolveFiles } from '../scanner';
|
|
4
|
+
import type { StyleProfile, Language } from '../types';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
|
|
7
|
+
interface FileMetrics {
|
|
8
|
+
functionLengths: number[];
|
|
9
|
+
identifierNames: string[];
|
|
10
|
+
commentLines: number;
|
|
11
|
+
codeLines: number;
|
|
12
|
+
nestingDepths: number[];
|
|
13
|
+
tryCatchCount: number;
|
|
14
|
+
importCount: number;
|
|
15
|
+
loc: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function countLines(text: string): number {
|
|
19
|
+
if (text.length === 0) return 0;
|
|
20
|
+
let count = 1;
|
|
21
|
+
for (let i = 0; i < text.length; i++) {
|
|
22
|
+
if (text[i] === '\n') count++;
|
|
23
|
+
}
|
|
24
|
+
return count;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getNestingDepth(node: Parser.SyntaxNode): number {
|
|
28
|
+
let depth = 0;
|
|
29
|
+
let current: Parser.SyntaxNode | null = node.parent;
|
|
30
|
+
const blockTypes = new Set([
|
|
31
|
+
'statement_block', 'block', 'if_statement', 'for_statement',
|
|
32
|
+
'while_statement', 'for_in_statement', 'switch_statement',
|
|
33
|
+
'try_statement', 'with_statement',
|
|
34
|
+
]);
|
|
35
|
+
while (current) {
|
|
36
|
+
if (blockTypes.has(current.type)) depth++;
|
|
37
|
+
current = current.parent;
|
|
38
|
+
}
|
|
39
|
+
return depth;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const FUNCTION_TYPES = new Set([
|
|
43
|
+
'function_declaration', 'method_definition', 'arrow_function',
|
|
44
|
+
'function', 'function_definition',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const IMPORT_TYPES = new Set([
|
|
48
|
+
'import_statement', 'import_declaration',
|
|
49
|
+
'import_from_statement',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function classifyName(name: string): 'camelCase' | 'snake_case' | 'PascalCase' | 'other' {
|
|
53
|
+
if (/^[A-Z][a-zA-Z0-9]*$/.test(name)) return 'PascalCase';
|
|
54
|
+
if (/^[a-z][a-zA-Z0-9]*$/.test(name) && /[A-Z]/.test(name)) return 'camelCase';
|
|
55
|
+
if (/^[a-z][a-z0-9_]*$/.test(name) && name.includes('_')) return 'snake_case';
|
|
56
|
+
if (/^[a-z][a-z0-9]*$/.test(name)) return 'camelCase'; // single-word lowercase defaults to camelCase
|
|
57
|
+
return 'other';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function analyzeFileAST(tree: Parser.Tree, source: string): FileMetrics {
|
|
61
|
+
const metrics: FileMetrics = {
|
|
62
|
+
functionLengths: [],
|
|
63
|
+
identifierNames: [],
|
|
64
|
+
commentLines: 0,
|
|
65
|
+
codeLines: 0,
|
|
66
|
+
nestingDepths: [],
|
|
67
|
+
tryCatchCount: 0,
|
|
68
|
+
importCount: 0,
|
|
69
|
+
loc: countLines(source),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
walkTree(tree.rootNode, (node) => {
|
|
73
|
+
// Function lengths
|
|
74
|
+
if (FUNCTION_TYPES.has(node.type)) {
|
|
75
|
+
const body = node.childForFieldName('body');
|
|
76
|
+
if (body) {
|
|
77
|
+
const fnLines = countLines(body.text);
|
|
78
|
+
metrics.functionLengths.push(fnLines);
|
|
79
|
+
metrics.nestingDepths.push(getNestingDepth(body));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Identifier names (function declarations and variable declarations)
|
|
84
|
+
if (node.type === 'function_declaration' || node.type === 'function_definition') {
|
|
85
|
+
const nameNode = node.childForFieldName('name');
|
|
86
|
+
if (nameNode) metrics.identifierNames.push(nameNode.text);
|
|
87
|
+
}
|
|
88
|
+
if (node.type === 'variable_declarator') {
|
|
89
|
+
const nameNode = node.childForFieldName('name');
|
|
90
|
+
if (nameNode && nameNode.type === 'identifier') {
|
|
91
|
+
metrics.identifierNames.push(nameNode.text);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Comments
|
|
96
|
+
if (node.type === 'comment') {
|
|
97
|
+
metrics.commentLines += countLines(node.text);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Try-catch
|
|
101
|
+
if (node.type === 'try_statement') {
|
|
102
|
+
metrics.tryCatchCount++;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Imports
|
|
106
|
+
if (IMPORT_TYPES.has(node.type)) {
|
|
107
|
+
metrics.importCount++;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Code lines = total lines minus comment lines (rough estimate)
|
|
112
|
+
metrics.codeLines = Math.max(1, metrics.loc - metrics.commentLines);
|
|
113
|
+
|
|
114
|
+
return metrics;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function median(values: number[]): number {
|
|
118
|
+
if (values.length === 0) return 0;
|
|
119
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
120
|
+
const mid = Math.floor(sorted.length / 2);
|
|
121
|
+
if (sorted.length % 2 === 0) {
|
|
122
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
123
|
+
}
|
|
124
|
+
return sorted[mid];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function average(values: number[]): number {
|
|
128
|
+
if (values.length === 0) return 0;
|
|
129
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function analyzeCodebase(paths: string[]): Promise<StyleProfile> {
|
|
133
|
+
const files = await resolveFiles(paths);
|
|
134
|
+
const allMetrics: FileMetrics[] = [];
|
|
135
|
+
|
|
136
|
+
for (const filePath of files) {
|
|
137
|
+
const language = detectLanguage(filePath);
|
|
138
|
+
if (!language) continue;
|
|
139
|
+
|
|
140
|
+
let source: string;
|
|
141
|
+
try {
|
|
142
|
+
const buffer = fs.readFileSync(filePath);
|
|
143
|
+
if (buffer.length === 0) continue;
|
|
144
|
+
// Skip binary files
|
|
145
|
+
const checkLength = Math.min(buffer.length, 1024);
|
|
146
|
+
let isBinary = false;
|
|
147
|
+
for (let i = 0; i < checkLength; i++) {
|
|
148
|
+
if (buffer[i] === 0) { isBinary = true; break; }
|
|
149
|
+
}
|
|
150
|
+
if (isBinary) continue;
|
|
151
|
+
source = buffer.toString('utf-8');
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let tree;
|
|
157
|
+
try {
|
|
158
|
+
tree = parse(source, language);
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
allMetrics.push(analyzeFileAST(tree, source));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return buildProfile(allMetrics, files.length);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buildProfile(allMetrics: FileMetrics[], fileCount: number): StyleProfile {
|
|
170
|
+
const allFunctionLengths = allMetrics.flatMap((m) => m.functionLengths);
|
|
171
|
+
const allIdentifiers = allMetrics.flatMap((m) => m.identifierNames);
|
|
172
|
+
const allNestingDepths = allMetrics.flatMap((m) => m.nestingDepths);
|
|
173
|
+
|
|
174
|
+
const totalCommentLines = allMetrics.reduce((s, m) => s + m.commentLines, 0);
|
|
175
|
+
const totalCodeLines = allMetrics.reduce((s, m) => s + m.codeLines, 0);
|
|
176
|
+
const totalTryCatch = allMetrics.reduce((s, m) => s + m.tryCatchCount, 0);
|
|
177
|
+
const totalLoc = allMetrics.reduce((s, m) => s + m.loc, 0);
|
|
178
|
+
const totalImports = allMetrics.reduce((s, m) => s + m.importCount, 0);
|
|
179
|
+
|
|
180
|
+
const namingConventions = { camelCase: 0, snake_case: 0, PascalCase: 0, other: 0 };
|
|
181
|
+
for (const name of allIdentifiers) {
|
|
182
|
+
const category = classifyName(name);
|
|
183
|
+
namingConventions[category]++;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Normalize naming conventions to percentages
|
|
187
|
+
const totalNames = Math.max(1, allIdentifiers.length);
|
|
188
|
+
namingConventions.camelCase = Math.round((namingConventions.camelCase / totalNames) * 100);
|
|
189
|
+
namingConventions.snake_case = Math.round((namingConventions.snake_case / totalNames) * 100);
|
|
190
|
+
namingConventions.PascalCase = Math.round((namingConventions.PascalCase / totalNames) * 100);
|
|
191
|
+
namingConventions.other = Math.round((namingConventions.other / totalNames) * 100);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
generatedAt: new Date().toISOString(),
|
|
195
|
+
fileCount,
|
|
196
|
+
totalLoc,
|
|
197
|
+
metrics: {
|
|
198
|
+
medianFunctionLength: Math.round(median(allFunctionLengths) * 10) / 10,
|
|
199
|
+
averageFunctionLength: Math.round(average(allFunctionLengths) * 10) / 10,
|
|
200
|
+
maxFunctionLength: allFunctionLengths.length > 0 ? Math.max(...allFunctionLengths) : 0,
|
|
201
|
+
namingConventions,
|
|
202
|
+
commentToCodeRatio: totalCodeLines > 0
|
|
203
|
+
? Math.round((totalCommentLines / totalCodeLines) * 100) / 100
|
|
204
|
+
: 0,
|
|
205
|
+
averageNestingDepth: Math.round(average(allNestingDepths) * 10) / 10,
|
|
206
|
+
tryCatchDensity: totalLoc > 0
|
|
207
|
+
? Math.round((totalTryCatch / (totalLoc / 100)) * 100) / 100
|
|
208
|
+
: 0,
|
|
209
|
+
averageImportsPerFile: fileCount > 0
|
|
210
|
+
? Math.round((totalImports / fileCount) * 10) / 10
|
|
211
|
+
: 0,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { parse, detectLanguage, walkTree } from '../parser';
|
|
3
|
+
import type { Finding, StyleProfile, Language } from '../types';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
|
|
6
|
+
const FUNCTION_TYPES = new Set([
|
|
7
|
+
'function_declaration', 'method_definition', 'arrow_function',
|
|
8
|
+
'function', 'function_definition',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function countLines(text: string): number {
|
|
12
|
+
if (text.length === 0) return 0;
|
|
13
|
+
let count = 1;
|
|
14
|
+
for (let i = 0; i < text.length; i++) {
|
|
15
|
+
if (text[i] === '\n') count++;
|
|
16
|
+
}
|
|
17
|
+
return count;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function compareFileToProfile(
|
|
21
|
+
filePath: string,
|
|
22
|
+
source: string,
|
|
23
|
+
tree: Parser.Tree,
|
|
24
|
+
profile: StyleProfile,
|
|
25
|
+
): Finding[] {
|
|
26
|
+
const findings: Finding[] = [];
|
|
27
|
+
const { metrics } = profile;
|
|
28
|
+
|
|
29
|
+
// Check function lengths against profile
|
|
30
|
+
const maxAcceptable = Math.max(
|
|
31
|
+
metrics.averageFunctionLength * 4,
|
|
32
|
+
metrics.medianFunctionLength * 5,
|
|
33
|
+
30, // absolute minimum threshold
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
walkTree(tree.rootNode, (node) => {
|
|
37
|
+
if (!FUNCTION_TYPES.has(node.type)) return;
|
|
38
|
+
|
|
39
|
+
const body = node.childForFieldName('body');
|
|
40
|
+
if (!body) return;
|
|
41
|
+
|
|
42
|
+
const fnLines = countLines(body.text);
|
|
43
|
+
|
|
44
|
+
if (fnLines > maxAcceptable) {
|
|
45
|
+
const nameNode = node.childForFieldName('name');
|
|
46
|
+
let parentName: string | undefined;
|
|
47
|
+
if (!nameNode && node.parent?.type === 'variable_declarator') {
|
|
48
|
+
const pn = node.parent.childForFieldName('name');
|
|
49
|
+
if (pn) parentName = pn.text;
|
|
50
|
+
}
|
|
51
|
+
const name = nameNode?.text ?? parentName ?? 'anonymous';
|
|
52
|
+
|
|
53
|
+
findings.push({
|
|
54
|
+
file: filePath,
|
|
55
|
+
line: node.startPosition.row + 1,
|
|
56
|
+
column: node.startPosition.column + 1,
|
|
57
|
+
endLine: node.endPosition.row + 1,
|
|
58
|
+
endColumn: node.endPosition.column + 1,
|
|
59
|
+
rule: 'style-function-length',
|
|
60
|
+
severity: 'warning',
|
|
61
|
+
message: `Function '${name}' is ${fnLines} lines — project median is ${metrics.medianFunctionLength}, average is ${metrics.averageFunctionLength}`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Check comment density against profile
|
|
67
|
+
let commentLines = 0;
|
|
68
|
+
let codeLines = 0;
|
|
69
|
+
walkTree(tree.rootNode, (node) => {
|
|
70
|
+
if (node.type === 'comment') {
|
|
71
|
+
commentLines += countLines(node.text);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
const totalLines = countLines(source);
|
|
75
|
+
codeLines = Math.max(1, totalLines - commentLines);
|
|
76
|
+
const fileRatio = commentLines / codeLines;
|
|
77
|
+
|
|
78
|
+
// Flag if comment density is 3x+ the project average (overly commented, likely AI slop)
|
|
79
|
+
if (metrics.commentToCodeRatio > 0 && fileRatio > metrics.commentToCodeRatio * 3 && commentLines > 5) {
|
|
80
|
+
findings.push({
|
|
81
|
+
file: filePath,
|
|
82
|
+
line: 1,
|
|
83
|
+
column: 1,
|
|
84
|
+
endLine: 1,
|
|
85
|
+
endColumn: 1,
|
|
86
|
+
rule: 'style-comment-density',
|
|
87
|
+
severity: 'info',
|
|
88
|
+
message: `Comment density (${Math.round(fileRatio * 100)}%) is much higher than project average (${Math.round(metrics.commentToCodeRatio * 100)}%)`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return findings;
|
|
93
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import type { StyleProfile } from '../types';
|
|
4
|
+
|
|
5
|
+
const PROFILE_DIR = '.distyll';
|
|
6
|
+
const PROFILE_FILE = 'profile.json';
|
|
7
|
+
|
|
8
|
+
export function getProfilePath(baseDir: string): string {
|
|
9
|
+
return path.join(baseDir, PROFILE_DIR, PROFILE_FILE);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function saveProfile(profile: StyleProfile, baseDir: string): string {
|
|
13
|
+
const dir = path.join(baseDir, PROFILE_DIR);
|
|
14
|
+
if (!fs.existsSync(dir)) {
|
|
15
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
const filePath = getProfilePath(baseDir);
|
|
18
|
+
fs.writeFileSync(filePath, JSON.stringify(profile, null, 2) + '\n', 'utf-8');
|
|
19
|
+
return filePath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadProfile(baseDir: string): StyleProfile | null {
|
|
23
|
+
const filePath = getProfilePath(baseDir);
|
|
24
|
+
try {
|
|
25
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (!parsed || !parsed.metrics) return null;
|
|
28
|
+
return parsed as StyleProfile;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import type { Finding, FixSuggestion } from '../types';
|
|
3
|
+
import { fixUnnecessaryTryCatch } from './unnecessary-try-catch';
|
|
4
|
+
import { fixSingleUseWrapper } from './single-use-wrapper';
|
|
5
|
+
import { fixVerboseComment } from './verbose-comments';
|
|
6
|
+
import { fixUnusedImport } from './unused-imports';
|
|
7
|
+
|
|
8
|
+
type FixGenerator = (
|
|
9
|
+
tree: Parser.Tree,
|
|
10
|
+
source: string,
|
|
11
|
+
line: number,
|
|
12
|
+
context?: string,
|
|
13
|
+
) => FixSuggestion | null;
|
|
14
|
+
|
|
15
|
+
const fixGenerators: Record<string, FixGenerator> = {
|
|
16
|
+
'unnecessary-try-catch': fixUnnecessaryTryCatch,
|
|
17
|
+
'single-use-wrapper': fixSingleUseWrapper,
|
|
18
|
+
'verbose-comments': fixVerboseComment,
|
|
19
|
+
'unused-imports': (tree, source, line, context) =>
|
|
20
|
+
fixUnusedImport(tree, source, line, context ?? ''),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Attempt to generate a fix suggestion for a finding.
|
|
25
|
+
* Returns the finding with the fix attached if successful.
|
|
26
|
+
*/
|
|
27
|
+
export function attachFix(
|
|
28
|
+
finding: Finding,
|
|
29
|
+
tree: Parser.Tree,
|
|
30
|
+
source: string,
|
|
31
|
+
): Finding {
|
|
32
|
+
const generator = fixGenerators[finding.rule];
|
|
33
|
+
if (!generator) return finding;
|
|
34
|
+
|
|
35
|
+
// Extract context from the finding message for unused-imports
|
|
36
|
+
let context: string | undefined;
|
|
37
|
+
if (finding.rule === 'unused-imports') {
|
|
38
|
+
const match = finding.message.match(/'([^']+)'/);
|
|
39
|
+
if (match) context = match[1];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fix = generator(tree, source, finding.line, context);
|
|
43
|
+
if (fix) {
|
|
44
|
+
return { ...finding, fix };
|
|
45
|
+
}
|
|
46
|
+
return finding;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Attach fix suggestions to all findings for a file.
|
|
51
|
+
*/
|
|
52
|
+
export function attachFixes(
|
|
53
|
+
findings: Finding[],
|
|
54
|
+
tree: Parser.Tree,
|
|
55
|
+
source: string,
|
|
56
|
+
): Finding[] {
|
|
57
|
+
return findings.map((f) => attachFix(f, tree, source));
|
|
58
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { findNodesMulti, walkTree } from '../parser';
|
|
3
|
+
import type { FixSuggestion } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates a fix for single-use wrapper functions: suggests inlining
|
|
7
|
+
* by showing the delegated call directly.
|
|
8
|
+
*/
|
|
9
|
+
export function fixSingleUseWrapper(
|
|
10
|
+
tree: Parser.Tree,
|
|
11
|
+
source: string,
|
|
12
|
+
line: number,
|
|
13
|
+
): FixSuggestion | null {
|
|
14
|
+
const functionNodes = findNodesMulti(tree.rootNode, [
|
|
15
|
+
'function_declaration',
|
|
16
|
+
'arrow_function',
|
|
17
|
+
'function',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
for (const fn of functionNodes) {
|
|
21
|
+
if (fn.startPosition.row + 1 !== line) continue;
|
|
22
|
+
|
|
23
|
+
const body = fn.childForFieldName('body');
|
|
24
|
+
if (!body) return null;
|
|
25
|
+
|
|
26
|
+
// For arrow functions with expression body (no braces)
|
|
27
|
+
if (body.type === 'call_expression') {
|
|
28
|
+
return {
|
|
29
|
+
description: `Inline the delegated call: ${body.text}`,
|
|
30
|
+
replacement: body.text,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// For block body with single return/expression statement
|
|
35
|
+
const stmts = body.type === 'statement_block' ? body.namedChildren : [];
|
|
36
|
+
if (stmts.length !== 1) return null;
|
|
37
|
+
|
|
38
|
+
const stmt = stmts[0];
|
|
39
|
+
if (stmt.type === 'return_statement') {
|
|
40
|
+
const returnValue = stmt.namedChildren[0];
|
|
41
|
+
if (returnValue) {
|
|
42
|
+
return {
|
|
43
|
+
description: `Inline the delegated call: ${returnValue.text}`,
|
|
44
|
+
replacement: returnValue.text,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (stmt.type === 'expression_statement') {
|
|
49
|
+
const expr = stmt.namedChildren[0];
|
|
50
|
+
if (expr) {
|
|
51
|
+
return {
|
|
52
|
+
description: `Inline the delegated call: ${expr.text}`,
|
|
53
|
+
replacement: expr.text,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { findNodes } from '../parser';
|
|
3
|
+
import type { FixSuggestion } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates a fix for unnecessary try-catch: extracts the try body
|
|
7
|
+
* and removes the try-catch wrapper.
|
|
8
|
+
*/
|
|
9
|
+
export function fixUnnecessaryTryCatch(
|
|
10
|
+
tree: Parser.Tree,
|
|
11
|
+
source: string,
|
|
12
|
+
line: number,
|
|
13
|
+
): FixSuggestion | null {
|
|
14
|
+
const tryStatements = findNodes(tree.rootNode, 'try_statement');
|
|
15
|
+
|
|
16
|
+
for (const tryNode of tryStatements) {
|
|
17
|
+
// Match by line (1-indexed)
|
|
18
|
+
if (tryNode.startPosition.row + 1 !== line) continue;
|
|
19
|
+
|
|
20
|
+
const body = tryNode.childForFieldName('body');
|
|
21
|
+
if (!body) return null;
|
|
22
|
+
|
|
23
|
+
// Extract the body statements (strip the outer braces)
|
|
24
|
+
const bodyText = body.text;
|
|
25
|
+
// Remove leading { and trailing }
|
|
26
|
+
const inner = bodyText.replace(/^\s*\{/, '').replace(/\}\s*$/, '');
|
|
27
|
+
// Dedent one level
|
|
28
|
+
const lines = inner.split('\n');
|
|
29
|
+
const dedented = lines
|
|
30
|
+
.map((l) => l.replace(/^ /, ''))
|
|
31
|
+
.join('\n')
|
|
32
|
+
.trim();
|
|
33
|
+
|
|
34
|
+
if (!dedented) return null;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
description: 'Remove try-catch wrapper and keep the body',
|
|
38
|
+
replacement: dedented,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { findNodes } from '../parser';
|
|
3
|
+
import type { FixSuggestion } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates a fix for unused imports: suggests removing the import line.
|
|
7
|
+
*/
|
|
8
|
+
export function fixUnusedImport(
|
|
9
|
+
tree: Parser.Tree,
|
|
10
|
+
source: string,
|
|
11
|
+
line: number,
|
|
12
|
+
importName: string,
|
|
13
|
+
): FixSuggestion | null {
|
|
14
|
+
const importStatements = findNodes(tree.rootNode, 'import_statement');
|
|
15
|
+
|
|
16
|
+
for (const importStmt of importStatements) {
|
|
17
|
+
// Check if this import statement contains the line
|
|
18
|
+
const stmtStartLine = importStmt.startPosition.row + 1;
|
|
19
|
+
const stmtEndLine = importStmt.endPosition.row + 1;
|
|
20
|
+
if (line < stmtStartLine || line > stmtEndLine) continue;
|
|
21
|
+
|
|
22
|
+
// Count how many named imports are in this statement
|
|
23
|
+
const importClause = importStmt.children.find((c) => c.type === 'import_clause');
|
|
24
|
+
if (!importClause) continue;
|
|
25
|
+
|
|
26
|
+
const namedImports = importClause.descendantsOfType('import_specifier');
|
|
27
|
+
const defaultImport = importClause.children.find(
|
|
28
|
+
(c) => c.type === 'identifier'
|
|
29
|
+
);
|
|
30
|
+
const namespaceImport = importClause.descendantsOfType('namespace_import');
|
|
31
|
+
|
|
32
|
+
const totalImportedNames =
|
|
33
|
+
namedImports.length +
|
|
34
|
+
(defaultImport ? 1 : 0) +
|
|
35
|
+
namespaceImport.length;
|
|
36
|
+
|
|
37
|
+
if (totalImportedNames <= 1) {
|
|
38
|
+
// Only one import in this statement — remove the entire line
|
|
39
|
+
return {
|
|
40
|
+
description: `Remove unused import '${importName}'`,
|
|
41
|
+
replacement: '', // delete entire import statement
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Multiple imports — suggest removing just this specifier
|
|
46
|
+
return {
|
|
47
|
+
description: `Remove unused import '${importName}' from this import statement`,
|
|
48
|
+
replacement: `Remove '${importName}' from the import list`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import { findNodes } from '../parser';
|
|
3
|
+
import type { FixSuggestion } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates a fix for verbose comments: suggests removing the comment entirely.
|
|
7
|
+
*/
|
|
8
|
+
export function fixVerboseComment(
|
|
9
|
+
tree: Parser.Tree,
|
|
10
|
+
source: string,
|
|
11
|
+
line: number,
|
|
12
|
+
): FixSuggestion | null {
|
|
13
|
+
const comments = findNodes(tree.rootNode, 'comment');
|
|
14
|
+
|
|
15
|
+
for (const comment of comments) {
|
|
16
|
+
if (comment.startPosition.row + 1 !== line) continue;
|
|
17
|
+
|
|
18
|
+
// Provide the code without the comment
|
|
19
|
+
const lines = source.split('\n');
|
|
20
|
+
const result = lines
|
|
21
|
+
.filter((_, i) => {
|
|
22
|
+
const lineNum = i + 1;
|
|
23
|
+
return lineNum < comment.startPosition.row + 1 ||
|
|
24
|
+
lineNum > comment.endPosition.row + 1;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Just suggest removal
|
|
28
|
+
return {
|
|
29
|
+
description: 'Remove this comment — it restates the code',
|
|
30
|
+
replacement: '', // empty = delete
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|