@emeryld/manager 1.0.0 → 1.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/README.md +78 -27
- package/dist/format-checker/cli/options.js +117 -0
- package/dist/format-checker/cli/prompts.js +276 -0
- package/dist/format-checker/cli/settings.js +140 -0
- package/dist/format-checker/config.js +63 -0
- package/dist/format-checker/index.js +38 -0
- package/dist/format-checker/report.js +115 -0
- package/dist/format-checker/scan/analysis.js +99 -0
- package/dist/format-checker/scan/collect.js +33 -0
- package/dist/format-checker/scan/constants.js +23 -0
- package/dist/format-checker/scan/duplicates.js +108 -0
- package/dist/format-checker/scan/functions.js +70 -0
- package/dist/format-checker/scan/indentation.js +58 -0
- package/dist/format-checker/scan/index.js +2 -0
- package/dist/format-checker/scan/types.js +1 -0
- package/dist/format-checker/scan/utils.js +68 -0
- package/dist/format-checker/scan.js +444 -0
- package/dist/menu.js +9 -0
- package/dist/publish.js +85 -0
- package/package.json +2 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { rootDir } from '../helper-cli/env.js';
|
|
5
|
+
export const REPORTING_MODES = ['group', 'file'];
|
|
6
|
+
export const DEFAULT_LIMITS = {
|
|
7
|
+
maxFunctionLength: 120,
|
|
8
|
+
maxIndentationDepth: 6,
|
|
9
|
+
maxFunctionsPerFile: 16,
|
|
10
|
+
maxComponentsPerFile: 8,
|
|
11
|
+
maxFileLength: 500,
|
|
12
|
+
maxDuplicateLineOccurrences: 3,
|
|
13
|
+
minDuplicateLines: 3,
|
|
14
|
+
exportOnly: true,
|
|
15
|
+
indentationWidth: 2,
|
|
16
|
+
reportingMode: 'group',
|
|
17
|
+
};
|
|
18
|
+
const SETTINGS_SECTION = 'manager.formatChecker';
|
|
19
|
+
const workspaceSettingsPath = path.join(rootDir, '.vscode', 'settings.json');
|
|
20
|
+
function coerceNumber(value, fallback, options) {
|
|
21
|
+
const num = Number(value);
|
|
22
|
+
if (Number.isNaN(num))
|
|
23
|
+
return fallback;
|
|
24
|
+
if (options?.allowZero) {
|
|
25
|
+
return num < 0 ? fallback : Math.floor(num);
|
|
26
|
+
}
|
|
27
|
+
return num <= 0 ? fallback : Math.floor(num);
|
|
28
|
+
}
|
|
29
|
+
export async function loadFormatLimits() {
|
|
30
|
+
if (!existsSync(workspaceSettingsPath))
|
|
31
|
+
return DEFAULT_LIMITS;
|
|
32
|
+
try {
|
|
33
|
+
const raw = await readFile(workspaceSettingsPath, 'utf-8');
|
|
34
|
+
const json = JSON.parse(raw);
|
|
35
|
+
const settings = json[SETTINGS_SECTION];
|
|
36
|
+
if (!settings || typeof settings !== 'object')
|
|
37
|
+
return DEFAULT_LIMITS;
|
|
38
|
+
const settingsRecord = settings;
|
|
39
|
+
return {
|
|
40
|
+
maxFunctionLength: coerceNumber(settingsRecord.maxFunctionLength, DEFAULT_LIMITS.maxFunctionLength),
|
|
41
|
+
maxIndentationDepth: coerceNumber(settingsRecord.maxIndentationDepth, DEFAULT_LIMITS.maxIndentationDepth),
|
|
42
|
+
maxFunctionsPerFile: coerceNumber(settingsRecord.maxFunctionsPerFile, DEFAULT_LIMITS.maxFunctionsPerFile),
|
|
43
|
+
maxComponentsPerFile: coerceNumber(settingsRecord.maxComponentsPerFile, DEFAULT_LIMITS.maxComponentsPerFile),
|
|
44
|
+
maxFileLength: coerceNumber(settingsRecord.maxFileLength, DEFAULT_LIMITS.maxFileLength),
|
|
45
|
+
maxDuplicateLineOccurrences: coerceNumber(settingsRecord.maxDuplicateLineOccurrences, DEFAULT_LIMITS.maxDuplicateLineOccurrences),
|
|
46
|
+
minDuplicateLines: coerceNumber(settingsRecord.minDuplicateLines, DEFAULT_LIMITS.minDuplicateLines, { allowZero: true }),
|
|
47
|
+
exportOnly: Boolean(settingsRecord.exportOnly ?? DEFAULT_LIMITS.exportOnly),
|
|
48
|
+
indentationWidth: coerceNumber(settingsRecord.indentationWidth, DEFAULT_LIMITS.indentationWidth),
|
|
49
|
+
reportingMode: coerceReportingMode(settingsRecord.reportingMode, DEFAULT_LIMITS.reportingMode),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return DEFAULT_LIMITS;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function coerceReportingMode(value, fallback) {
|
|
57
|
+
if (typeof value !== 'string')
|
|
58
|
+
return fallback;
|
|
59
|
+
if (REPORTING_MODES.includes(value)) {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { colors } from '../utils/log.js';
|
|
2
|
+
import { rootDir } from '../helper-cli/env.js';
|
|
3
|
+
import { loadFormatLimits, } from './config.js';
|
|
4
|
+
import { collectSourceFiles, analyzeFiles } from './scan/index.js';
|
|
5
|
+
import { reportViolations } from './report.js';
|
|
6
|
+
import { promptLimits } from './cli/prompts.js';
|
|
7
|
+
import { parseScanCliArgs, printScanUsage } from './cli/options.js';
|
|
8
|
+
export async function runFormatChecker() {
|
|
9
|
+
console.log(colors.cyan('Gathering defaults from .vscode/settings.json (manager.formatChecker)'));
|
|
10
|
+
const defaults = await loadFormatLimits();
|
|
11
|
+
const limits = await promptLimits(defaults);
|
|
12
|
+
await executeFormatCheck(limits);
|
|
13
|
+
}
|
|
14
|
+
export async function runFormatCheckerScanCli(argv) {
|
|
15
|
+
const { overrides, help } = parseScanCliArgs(argv);
|
|
16
|
+
if (help) {
|
|
17
|
+
printScanUsage();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const defaults = await loadFormatLimits();
|
|
21
|
+
const limits = { ...defaults, ...overrides };
|
|
22
|
+
console.log(colors.cyan('Running format checker scan (machine-friendly)'));
|
|
23
|
+
await executeFormatCheck(limits);
|
|
24
|
+
}
|
|
25
|
+
async function executeFormatCheck(limits) {
|
|
26
|
+
console.log(colors.magenta('Scanning workspace for source files...'));
|
|
27
|
+
const files = await collectSourceFiles(rootDir);
|
|
28
|
+
if (files.length === 0) {
|
|
29
|
+
console.log(colors.yellow('No source files were found to analyze.'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(colors.magenta(`Analyzing ${files.length} files`));
|
|
33
|
+
const violations = await analyzeFiles(files, limits);
|
|
34
|
+
const hadViolations = reportViolations(violations, limits.reportingMode);
|
|
35
|
+
if (!hadViolations) {
|
|
36
|
+
console.log(colors.green('Workspace meets the configured format limits.'));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { colors } from '../utils/log.js';
|
|
3
|
+
import { rootDir } from '../helper-cli/env.js';
|
|
4
|
+
const TYPE_LABELS = {
|
|
5
|
+
functionLength: 'Function length',
|
|
6
|
+
indentation: 'Indentation depth',
|
|
7
|
+
functionCount: 'Functions per file',
|
|
8
|
+
componentCount: 'Components per file',
|
|
9
|
+
fileLength: 'File length',
|
|
10
|
+
duplicateLine: 'Repeated code',
|
|
11
|
+
};
|
|
12
|
+
const TYPE_ORDER = [
|
|
13
|
+
'fileLength',
|
|
14
|
+
'functionLength',
|
|
15
|
+
'functionCount',
|
|
16
|
+
'componentCount',
|
|
17
|
+
'indentation',
|
|
18
|
+
'duplicateLine',
|
|
19
|
+
];
|
|
20
|
+
export function reportViolations(violations, reportingMode) {
|
|
21
|
+
if (violations.length === 0) {
|
|
22
|
+
console.log(colors.green('Format checker found no violations.'));
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
console.log(colors.bold('Format checker violations:'));
|
|
26
|
+
if (reportingMode === 'group') {
|
|
27
|
+
for (const type of TYPE_ORDER) {
|
|
28
|
+
const entries = violations.filter((violation) => violation.type === type);
|
|
29
|
+
if (!entries.length)
|
|
30
|
+
continue;
|
|
31
|
+
entries.sort((a, b) => b.severity - a.severity);
|
|
32
|
+
console.log(colors.bold(`- ${TYPE_LABELS[type]} (${entries.length})`));
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const relativePath = path.relative(rootDir, entry.file) || entry.file;
|
|
35
|
+
const isDuplicate = entry.type === 'duplicateLine';
|
|
36
|
+
const locationDescriptor = isDuplicate
|
|
37
|
+
? entry.line
|
|
38
|
+
? `${relativePath}:${entry.line}`
|
|
39
|
+
: relativePath
|
|
40
|
+
: entry.detail
|
|
41
|
+
? `${relativePath}:${entry.detail}`
|
|
42
|
+
: entry.line
|
|
43
|
+
? `${relativePath}:${entry.line}`
|
|
44
|
+
: relativePath;
|
|
45
|
+
logViolationEntry(entry, isDuplicate ? ' ' : ' ', isDuplicate ? undefined : locationDescriptor);
|
|
46
|
+
if (isDuplicate && entry.detail) {
|
|
47
|
+
console.log(` ${colors.dim('Places:')}`);
|
|
48
|
+
entry.detail
|
|
49
|
+
.split('\n')
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.forEach((occurrence) => {
|
|
52
|
+
console.log(` ${colors.dim(occurrence)}`);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
console.log('');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const violationsByFile = new Map();
|
|
61
|
+
for (const entry of violations) {
|
|
62
|
+
const bucket = violationsByFile.get(entry.file);
|
|
63
|
+
if (bucket) {
|
|
64
|
+
bucket.push(entry);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
violationsByFile.set(entry.file, [entry]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const sortedFiles = [...violationsByFile.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
71
|
+
for (const [file, entries] of sortedFiles) {
|
|
72
|
+
const relativePath = path.relative(rootDir, file) || file;
|
|
73
|
+
console.log(colors.bold(relativePath));
|
|
74
|
+
const sortedEntries = [...entries].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
|
|
75
|
+
for (const entry of sortedEntries) {
|
|
76
|
+
const hasRangeDetail = entry.type !== 'duplicateLine' && Boolean(entry.detail);
|
|
77
|
+
const locationDescriptor = hasRangeDetail
|
|
78
|
+
? `${relativePath}:${entry.detail}`
|
|
79
|
+
: entry.line
|
|
80
|
+
? `${relativePath}:${entry.line}`
|
|
81
|
+
: relativePath;
|
|
82
|
+
if (entry.type === 'duplicateLine') {
|
|
83
|
+
const occurrenceDescriptor = entry.line
|
|
84
|
+
? `${relativePath}:${entry.line}`
|
|
85
|
+
: relativePath;
|
|
86
|
+
logViolationEntry(entry, ' ', occurrenceDescriptor);
|
|
87
|
+
if (entry.detail) {
|
|
88
|
+
console.log(` ${colors.dim('Places:')}`);
|
|
89
|
+
entry.detail
|
|
90
|
+
.split('\n')
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.forEach((occurrence) => {
|
|
93
|
+
console.log(` ${colors.dim(occurrence)}`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
logViolationEntry(entry, ' ', locationDescriptor);
|
|
99
|
+
}
|
|
100
|
+
console.log('');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
function logViolationEntry(entry, messagePrefix, locationDescriptor) {
|
|
106
|
+
console.log(`${messagePrefix}${colors.yellow(`${entry.message} (severity ${entry.severity})`)}`);
|
|
107
|
+
if (entry.snippet) {
|
|
108
|
+
entry.snippet.split('\n').forEach((line) => {
|
|
109
|
+
console.log(` ${colors.yellow(line)}`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (locationDescriptor) {
|
|
113
|
+
console.log(` ${colors.dim(locationDescriptor)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { collectDuplicateViolations, recordDuplicateLines, } from './duplicates.js';
|
|
3
|
+
import { countIndentation, groupIndentationViolations, } from './indentation.js';
|
|
4
|
+
import { collectFunctionRecords } from './functions.js';
|
|
5
|
+
import { markImportLines, normalizeLine } from './utils.js';
|
|
6
|
+
export async function analyzeFiles(files, limits) {
|
|
7
|
+
const violations = [];
|
|
8
|
+
const duplicateMap = new Map();
|
|
9
|
+
for (const file of files) {
|
|
10
|
+
violations.push(...(await analyzeSingleFile(file, limits, duplicateMap)));
|
|
11
|
+
}
|
|
12
|
+
violations.push(...collectDuplicateViolations(duplicateMap, limits));
|
|
13
|
+
return violations;
|
|
14
|
+
}
|
|
15
|
+
async function analyzeSingleFile(filePath, limits, duplicates) {
|
|
16
|
+
let content;
|
|
17
|
+
try {
|
|
18
|
+
content = await readFile(filePath, 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const lines = content.split(/\r?\n/);
|
|
24
|
+
const importFlags = markImportLines(lines);
|
|
25
|
+
const normalizedLines = lines.map((line, index) => ({
|
|
26
|
+
normalized: normalizeLine(line),
|
|
27
|
+
trimmed: line.trim(),
|
|
28
|
+
isImport: Boolean(importFlags[index]),
|
|
29
|
+
raw: line,
|
|
30
|
+
}));
|
|
31
|
+
const violations = [];
|
|
32
|
+
if (lines.length > limits.maxFileLength) {
|
|
33
|
+
violations.push({
|
|
34
|
+
type: 'fileLength',
|
|
35
|
+
file: filePath,
|
|
36
|
+
line: 1,
|
|
37
|
+
severity: lines.length - limits.maxFileLength,
|
|
38
|
+
message: `File has ${lines.length} lines (max ${limits.maxFileLength})`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const indentationViolations = [];
|
|
42
|
+
lines.forEach((line, index) => {
|
|
43
|
+
const indent = countIndentation(line, limits.indentationWidth);
|
|
44
|
+
if (indent > limits.maxIndentationDepth) {
|
|
45
|
+
indentationViolations.push({ line: index + 1, indent });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
recordDuplicateLines(normalizedLines, filePath, limits, duplicates);
|
|
49
|
+
const indentationGroups = groupIndentationViolations(indentationViolations, lines, limits.maxIndentationDepth);
|
|
50
|
+
indentationGroups
|
|
51
|
+
.sort((a, b) => b.severity - a.severity)
|
|
52
|
+
.slice(0, 4)
|
|
53
|
+
.forEach((group) => {
|
|
54
|
+
violations.push({
|
|
55
|
+
type: 'indentation',
|
|
56
|
+
file: filePath,
|
|
57
|
+
line: group.startLine,
|
|
58
|
+
severity: group.severity,
|
|
59
|
+
message: `Indentation level ${group.maxIndent} exceeds max ${limits.maxIndentationDepth}`,
|
|
60
|
+
snippet: group.snippet,
|
|
61
|
+
detail: `${group.startLine}:${group.startColumn}/${group.endLine}:${group.endColumn}`,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
const allFunctions = collectFunctionRecords(content, filePath);
|
|
65
|
+
const functions = limits.exportOnly
|
|
66
|
+
? allFunctions.filter((record) => record.isExported)
|
|
67
|
+
: allFunctions;
|
|
68
|
+
const componentCount = functions.filter((record) => record.isComponent).length;
|
|
69
|
+
if (componentCount > limits.maxComponentsPerFile) {
|
|
70
|
+
violations.push({
|
|
71
|
+
type: 'componentCount',
|
|
72
|
+
file: filePath,
|
|
73
|
+
line: functions.find((record) => record.isComponent)?.startLine,
|
|
74
|
+
severity: componentCount - limits.maxComponentsPerFile,
|
|
75
|
+
message: `Found ${componentCount} components (max ${limits.maxComponentsPerFile})`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (functions.length > limits.maxFunctionsPerFile) {
|
|
79
|
+
violations.push({
|
|
80
|
+
type: 'functionCount',
|
|
81
|
+
file: filePath,
|
|
82
|
+
line: functions[0]?.startLine,
|
|
83
|
+
severity: functions.length - limits.maxFunctionsPerFile,
|
|
84
|
+
message: `Contains ${functions.length} functions (max ${limits.maxFunctionsPerFile})`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
functions.forEach((record) => {
|
|
88
|
+
if (record.length > limits.maxFunctionLength) {
|
|
89
|
+
violations.push({
|
|
90
|
+
type: 'functionLength',
|
|
91
|
+
file: filePath,
|
|
92
|
+
line: record.startLine,
|
|
93
|
+
severity: record.length - limits.maxFunctionLength,
|
|
94
|
+
message: `Function ${record.name ?? '<anonymous>'} spans ${record.length} lines (max ${limits.maxFunctionLength})`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
return violations;
|
|
99
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SOURCE_EXTENSIONS, IGNORED_DIRECTORIES } from './constants.js';
|
|
4
|
+
export async function collectSourceFiles(root) {
|
|
5
|
+
const results = [];
|
|
6
|
+
await walk(root, results);
|
|
7
|
+
return results;
|
|
8
|
+
}
|
|
9
|
+
async function walk(directory, results) {
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (entry.isSymbolicLink())
|
|
19
|
+
continue;
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
if (IGNORED_DIRECTORIES.has(entry.name))
|
|
22
|
+
continue;
|
|
23
|
+
await walk(path.join(directory, entry.name), results);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!entry.isFile())
|
|
27
|
+
continue;
|
|
28
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
29
|
+
if (!SOURCE_EXTENSIONS.has(ext))
|
|
30
|
+
continue;
|
|
31
|
+
results.push(path.join(directory, entry.name));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const SOURCE_EXTENSIONS = new Set([
|
|
2
|
+
'.ts',
|
|
3
|
+
'.tsx',
|
|
4
|
+
'.js',
|
|
5
|
+
'.jsx',
|
|
6
|
+
'.mts',
|
|
7
|
+
'.mjs',
|
|
8
|
+
'.cts',
|
|
9
|
+
'.cjs',
|
|
10
|
+
]);
|
|
11
|
+
export const IGNORED_DIRECTORIES = new Set([
|
|
12
|
+
'.git',
|
|
13
|
+
'node_modules',
|
|
14
|
+
'dist',
|
|
15
|
+
'bin',
|
|
16
|
+
'.turbo',
|
|
17
|
+
'.cache',
|
|
18
|
+
'.next',
|
|
19
|
+
'out',
|
|
20
|
+
'.pnpm',
|
|
21
|
+
'.idea',
|
|
22
|
+
'.vscode',
|
|
23
|
+
]);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getLineColumnRange } from './utils.js';
|
|
3
|
+
export function recordDuplicateLines(normalizedLines, filePath, limits, duplicates) {
|
|
4
|
+
const minLines = Math.max(1, limits.minDuplicateLines);
|
|
5
|
+
if (limits.minDuplicateLines <= 0 || minLines === 1) {
|
|
6
|
+
normalizedLines.forEach((entry, index) => {
|
|
7
|
+
if (!entry.normalized)
|
|
8
|
+
return;
|
|
9
|
+
const snippet = entry.trimmed.replace(/\s+/g, ' ');
|
|
10
|
+
if (!snippet)
|
|
11
|
+
return;
|
|
12
|
+
if (entry.isImport)
|
|
13
|
+
return;
|
|
14
|
+
const { startColumn, endColumn } = getLineColumnRange(entry.raw);
|
|
15
|
+
addDuplicateOccurrence(duplicates, entry.normalized, filePath, index + 1, startColumn, index + 1, endColumn, snippet);
|
|
16
|
+
});
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
for (let i = 0; i + minLines <= normalizedLines.length; i += 1) {
|
|
20
|
+
const slice = normalizedLines.slice(i, i + minLines);
|
|
21
|
+
if (slice.some((entry) => !entry.normalized))
|
|
22
|
+
continue;
|
|
23
|
+
const key = slice.map((entry) => entry.normalized).join('\n');
|
|
24
|
+
const snippetLines = slice
|
|
25
|
+
.map((entry) => entry.trimmed)
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
if (!snippetLines.length)
|
|
28
|
+
continue;
|
|
29
|
+
if (slice.some((entry) => entry.isImport))
|
|
30
|
+
continue;
|
|
31
|
+
const snippet = snippetLines
|
|
32
|
+
.map((line) => line.replace(/\s+/g, ' '))
|
|
33
|
+
.join('\n');
|
|
34
|
+
if (!snippet)
|
|
35
|
+
continue;
|
|
36
|
+
const firstNonEmptyIndex = slice.findIndex((entry) => entry.trimmed.length > 0);
|
|
37
|
+
let lastNonEmptyIndex = -1;
|
|
38
|
+
for (let offset = slice.length - 1; offset >= 0; offset -= 1) {
|
|
39
|
+
if (slice[offset].trimmed.length > 0) {
|
|
40
|
+
lastNonEmptyIndex = offset;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (firstNonEmptyIndex === -1 || lastNonEmptyIndex === -1)
|
|
45
|
+
continue;
|
|
46
|
+
const startLine = i + 1 + firstNonEmptyIndex;
|
|
47
|
+
const endLine = i + 1 + lastNonEmptyIndex;
|
|
48
|
+
const { startColumn } = getLineColumnRange(slice[firstNonEmptyIndex].raw);
|
|
49
|
+
const { endColumn } = getLineColumnRange(slice[lastNonEmptyIndex].raw);
|
|
50
|
+
addDuplicateOccurrence(duplicates, key, filePath, startLine, startColumn, endLine, endColumn, snippet);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function addDuplicateOccurrence(duplicates, key, filePath, startLine, startColumn, endLine, endColumn, snippet) {
|
|
54
|
+
const tracker = duplicates.get(key);
|
|
55
|
+
if (tracker) {
|
|
56
|
+
tracker.count += 1;
|
|
57
|
+
if (tracker.occurrences.length < 6) {
|
|
58
|
+
tracker.occurrences.push({
|
|
59
|
+
file: filePath,
|
|
60
|
+
line: startLine,
|
|
61
|
+
startColumn,
|
|
62
|
+
endLine,
|
|
63
|
+
endColumn,
|
|
64
|
+
snippet,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
duplicates.set(key, {
|
|
70
|
+
count: 1,
|
|
71
|
+
snippet,
|
|
72
|
+
occurrences: [
|
|
73
|
+
{
|
|
74
|
+
file: filePath,
|
|
75
|
+
line: startLine,
|
|
76
|
+
startColumn,
|
|
77
|
+
endLine,
|
|
78
|
+
endColumn,
|
|
79
|
+
snippet,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
export function collectDuplicateViolations(duplicates, limits) {
|
|
85
|
+
const violations = [];
|
|
86
|
+
for (const tracker of duplicates.values()) {
|
|
87
|
+
if (tracker.count <= limits.maxDuplicateLineOccurrences)
|
|
88
|
+
continue;
|
|
89
|
+
if (tracker.occurrences.length === 0)
|
|
90
|
+
continue;
|
|
91
|
+
violations.push({
|
|
92
|
+
type: 'duplicateLine',
|
|
93
|
+
file: tracker.occurrences[0].file,
|
|
94
|
+
line: tracker.occurrences[0].line,
|
|
95
|
+
severity: tracker.count - limits.maxDuplicateLineOccurrences,
|
|
96
|
+
message: `Repeated ${tracker.count} times (max ${limits.maxDuplicateLineOccurrences})`,
|
|
97
|
+
detail: tracker.occurrences
|
|
98
|
+
.map((occurrence) => {
|
|
99
|
+
const relativePath = path.relative(process.cwd(), occurrence.file);
|
|
100
|
+
return `${relativePath}:${occurrence.line}:${occurrence.startColumn}/${occurrence.endLine}:${occurrence.endColumn}`;
|
|
101
|
+
})
|
|
102
|
+
.join('\n'),
|
|
103
|
+
snippet: tracker.snippet,
|
|
104
|
+
repeatCount: tracker.count,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return violations;
|
|
108
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { resolveScriptKind } from './utils.js';
|
|
3
|
+
export function collectFunctionRecords(content, filePath) {
|
|
4
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.ESNext, true, resolveScriptKind(filePath));
|
|
5
|
+
const records = [];
|
|
6
|
+
function visit(node) {
|
|
7
|
+
if ((ts.isFunctionDeclaration(node) ||
|
|
8
|
+
ts.isFunctionExpression(node) ||
|
|
9
|
+
ts.isArrowFunction(node) ||
|
|
10
|
+
ts.isMethodDeclaration(node)) &&
|
|
11
|
+
node.body) {
|
|
12
|
+
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
13
|
+
const end = sourceFile.getLineAndCharacterOfPosition(node.end);
|
|
14
|
+
const length = end.line - start.line + 1;
|
|
15
|
+
const name = resolveFunctionName(node);
|
|
16
|
+
const isComponent = typeof name === 'string' && isComponentFunction(node, name);
|
|
17
|
+
const isExported = ts.isExportDeclaration(node);
|
|
18
|
+
records.push({
|
|
19
|
+
name,
|
|
20
|
+
startLine: start.line + 1,
|
|
21
|
+
length,
|
|
22
|
+
isComponent,
|
|
23
|
+
isExported,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
ts.forEachChild(node, visit);
|
|
27
|
+
}
|
|
28
|
+
visit(sourceFile);
|
|
29
|
+
return records;
|
|
30
|
+
}
|
|
31
|
+
function resolveFunctionName(node) {
|
|
32
|
+
if ('name' in node && node.name) {
|
|
33
|
+
const candidate = node.name;
|
|
34
|
+
if (ts.isIdentifier(candidate)) {
|
|
35
|
+
return candidate.text;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const parent = node.parent;
|
|
39
|
+
if (parent) {
|
|
40
|
+
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
41
|
+
return parent.name.text;
|
|
42
|
+
}
|
|
43
|
+
if ((ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent)) &&
|
|
44
|
+
ts.isIdentifier(parent.name)) {
|
|
45
|
+
return parent.name.text;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
function isComponentFunction(node, name) {
|
|
51
|
+
if (!/^[A-Z]/.test(name))
|
|
52
|
+
return false;
|
|
53
|
+
return containsJsx(node);
|
|
54
|
+
}
|
|
55
|
+
function containsJsx(node) {
|
|
56
|
+
let found = false;
|
|
57
|
+
function walk(child) {
|
|
58
|
+
if (child.kind === ts.SyntaxKind.JsxElement ||
|
|
59
|
+
child.kind === ts.SyntaxKind.JsxSelfClosingElement ||
|
|
60
|
+
child.kind === ts.SyntaxKind.JsxFragment) {
|
|
61
|
+
found = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (found)
|
|
65
|
+
return;
|
|
66
|
+
ts.forEachChild(child, walk);
|
|
67
|
+
}
|
|
68
|
+
ts.forEachChild(node, walk);
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getLineColumnRange } from './utils.js';
|
|
2
|
+
export function countIndentation(line, tabWidth) {
|
|
3
|
+
let indent = 0;
|
|
4
|
+
for (const char of line) {
|
|
5
|
+
if (char === ' ')
|
|
6
|
+
indent += 1;
|
|
7
|
+
else if (char === '\t')
|
|
8
|
+
indent += tabWidth;
|
|
9
|
+
else
|
|
10
|
+
break;
|
|
11
|
+
}
|
|
12
|
+
return indent / tabWidth;
|
|
13
|
+
}
|
|
14
|
+
export function groupIndentationViolations(violations, lines, maxDepth) {
|
|
15
|
+
if (!violations.length)
|
|
16
|
+
return [];
|
|
17
|
+
const sorted = [...violations].sort((a, b) => a.line - b.line);
|
|
18
|
+
const grouped = [];
|
|
19
|
+
let currentGroup = [];
|
|
20
|
+
let previousLine = -Infinity;
|
|
21
|
+
for (const violation of sorted) {
|
|
22
|
+
if (violation.line === previousLine + 1) {
|
|
23
|
+
currentGroup.push(violation);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
if (currentGroup.length) {
|
|
27
|
+
grouped.push(currentGroup);
|
|
28
|
+
}
|
|
29
|
+
currentGroup = [violation];
|
|
30
|
+
}
|
|
31
|
+
previousLine = violation.line;
|
|
32
|
+
}
|
|
33
|
+
if (currentGroup.length) {
|
|
34
|
+
grouped.push(currentGroup);
|
|
35
|
+
}
|
|
36
|
+
return grouped.map((group) => {
|
|
37
|
+
const startLine = group[0].line;
|
|
38
|
+
const endLine = group[group.length - 1].line;
|
|
39
|
+
const maxIndent = Math.max(...group.map((entry) => entry.indent));
|
|
40
|
+
const severity = Math.max(maxIndent - maxDepth, 0);
|
|
41
|
+
const contextStartLine = Math.max(1, startLine - 1);
|
|
42
|
+
const contextEndLine = Math.min(lines.length, endLine + 1);
|
|
43
|
+
const snippet = lines
|
|
44
|
+
.slice(contextStartLine - 1, contextEndLine)
|
|
45
|
+
.join('\n');
|
|
46
|
+
const { startColumn } = getLineColumnRange(lines[startLine - 1]);
|
|
47
|
+
const { endColumn } = getLineColumnRange(lines[endLine - 1]);
|
|
48
|
+
return {
|
|
49
|
+
startLine,
|
|
50
|
+
endLine,
|
|
51
|
+
startColumn,
|
|
52
|
+
endColumn,
|
|
53
|
+
maxIndent,
|
|
54
|
+
severity,
|
|
55
|
+
snippet,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import * as ts from 'typescript';
|
|
3
|
+
export function normalizeLine(line) {
|
|
4
|
+
const trimmed = line.trim();
|
|
5
|
+
if (trimmed.length < 12)
|
|
6
|
+
return undefined;
|
|
7
|
+
const collapse = trimmed.replace(/\s+/g, ' ');
|
|
8
|
+
const noNumbers = collapse.replace(/\d+/g, '#');
|
|
9
|
+
return noNumbers.toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
export function markImportLines(lines) {
|
|
12
|
+
const flags = new Array(lines.length).fill(false);
|
|
13
|
+
let insideImport = false;
|
|
14
|
+
let braceBalance = 0;
|
|
15
|
+
const importStart = /^(export\s+)?import\b/;
|
|
16
|
+
const fromLine = /^\}?\s*from\b/;
|
|
17
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
18
|
+
const trimmed = lines[index].trim();
|
|
19
|
+
const openBraces = (trimmed.match(/{/g) ?? []).length;
|
|
20
|
+
const closeBraces = (trimmed.match(/}/g) ?? []).length;
|
|
21
|
+
if (importStart.test(trimmed)) {
|
|
22
|
+
flags[index] = true;
|
|
23
|
+
braceBalance = openBraces - closeBraces;
|
|
24
|
+
insideImport = braceBalance > 0;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (insideImport) {
|
|
28
|
+
flags[index] = true;
|
|
29
|
+
braceBalance += openBraces - closeBraces;
|
|
30
|
+
if (braceBalance <= 0) {
|
|
31
|
+
insideImport = false;
|
|
32
|
+
braceBalance = 0;
|
|
33
|
+
}
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (fromLine.test(trimmed)) {
|
|
37
|
+
flags[index] = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return flags;
|
|
41
|
+
}
|
|
42
|
+
export function getLineColumnRange(line) {
|
|
43
|
+
const leadingSpaceCount = line.length - line.trimStart().length;
|
|
44
|
+
const startColumn = leadingSpaceCount + 1;
|
|
45
|
+
const trimmedEndLength = line.trimEnd().length;
|
|
46
|
+
const endColumn = Math.max(trimmedEndLength, startColumn);
|
|
47
|
+
return { startColumn, endColumn };
|
|
48
|
+
}
|
|
49
|
+
export function resolveScriptKind(filePath) {
|
|
50
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
51
|
+
case '.ts':
|
|
52
|
+
return ts.ScriptKind.TS;
|
|
53
|
+
case '.tsx':
|
|
54
|
+
return ts.ScriptKind.TSX;
|
|
55
|
+
case '.mts':
|
|
56
|
+
case '.cts':
|
|
57
|
+
return ts.ScriptKind.TS;
|
|
58
|
+
case '.js':
|
|
59
|
+
return ts.ScriptKind.JS;
|
|
60
|
+
case '.jsx':
|
|
61
|
+
return ts.ScriptKind.JSX;
|
|
62
|
+
case '.mjs':
|
|
63
|
+
case '.cjs':
|
|
64
|
+
return ts.ScriptKind.JS;
|
|
65
|
+
default:
|
|
66
|
+
return ts.ScriptKind.Unknown;
|
|
67
|
+
}
|
|
68
|
+
}
|