hana-linter 0.1.1
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/.github/workflows/npm-publish.yml +29 -0
- package/.prettierrc +7 -0
- package/CHANGELOG.md +14 -0
- package/README.md +286 -0
- package/dist/cli.js +104 -0
- package/dist/config.js +227 -0
- package/dist/content-lint.js +151 -0
- package/dist/files.js +73 -0
- package/dist/index.js +31 -0
- package/dist/lint.js +195 -0
- package/dist/report.js +28 -0
- package/dist/types/cli.js +2 -0
- package/dist/types/config.js +2 -0
- package/dist/types/issues.js +2 -0
- package/dist/types/rules.js +2 -0
- package/package.json +28 -0
- package/src/assets/.hana-linter.json +224 -0
- package/src/cli.ts +113 -0
- package/src/config.ts +305 -0
- package/src/content-lint.ts +187 -0
- package/src/files.ts +84 -0
- package/src/index.ts +37 -0
- package/src/lint.ts +222 -0
- package/src/report.ts +29 -0
- package/src/types/cli.ts +21 -0
- package/src/types/config.ts +20 -0
- package/src/types/issues.ts +9 -0
- package/src/types/rules.ts +140 -0
- package/tsconfig.json +45 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { LintIssue } from './types/issues';
|
|
4
|
+
import { ContentRuleSet, ContentTarget, RuleDefinition } from './types/rules';
|
|
5
|
+
|
|
6
|
+
type ExtractedSubject = {
|
|
7
|
+
readonly type: ContentTarget;
|
|
8
|
+
readonly name: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run content-based naming lint for a file.
|
|
13
|
+
*
|
|
14
|
+
* @param filePath - Candidate file path.
|
|
15
|
+
* @param contentRuleSets - Configured content rule sets.
|
|
16
|
+
* @returns List of content-based lint issues.
|
|
17
|
+
*/
|
|
18
|
+
export async function lintFileContent(filePath: string, contentRuleSets: readonly ContentRuleSet[]): Promise<LintIssue[]> {
|
|
19
|
+
const extension = path.extname(filePath);
|
|
20
|
+
const matchingRuleSets = contentRuleSets.filter((ruleSet) => ruleSet.extension === '*' || ruleSet.extension === extension);
|
|
21
|
+
|
|
22
|
+
if (matchingRuleSets.length === 0) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fileContent = await fs.readFile(filePath, { encoding: 'utf-8' });
|
|
27
|
+
const extractedSubjects = extractSubjects(extension, fileContent);
|
|
28
|
+
|
|
29
|
+
if (extractedSubjects.length === 0) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const issues: LintIssue[] = [];
|
|
34
|
+
|
|
35
|
+
for (const subject of extractedSubjects) {
|
|
36
|
+
const targetRuleSets = matchingRuleSets.filter((ruleSet) => ruleSet.target === subject.type);
|
|
37
|
+
if (targetRuleSets.length === 0) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const allRules = targetRuleSets.flatMap((ruleSet) => ruleSet.groups.all ?? []);
|
|
42
|
+
const anyRules = targetRuleSets.flatMap((ruleSet) => ruleSet.groups.any ?? []);
|
|
43
|
+
|
|
44
|
+
if (allRules.length > 0) {
|
|
45
|
+
issues.push(...evaluateAllRules(filePath, extension, subject, allRules));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (anyRules.length > 0) {
|
|
49
|
+
issues.push(...evaluateAnyRules(filePath, extension, subject, anyRules));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return issues;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractSubjects(extension: string, fileContent: string): ExtractedSubject[] {
|
|
57
|
+
if (extension === '.hdbtable') {
|
|
58
|
+
return extractTableFields(fileContent);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (extension === '.hdbprocedure' || extension === '.hdbfunction') {
|
|
62
|
+
return extractProcedureFunctionParameters(fileContent);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extractTableFields(fileContent: string): ExtractedSubject[] {
|
|
69
|
+
const subjects: ExtractedSubject[] = [];
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
const lines = fileContent.split(/\r?\n/);
|
|
72
|
+
const skipKeywords = new Set(['PRIMARY', 'CONSTRAINT', 'UNIQUE', 'FOREIGN', 'CHECK', 'PARTITION', 'INDEX', 'KEY']);
|
|
73
|
+
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
const trimmedLine = line.trim();
|
|
76
|
+
if (trimmedLine.length === 0 || trimmedLine.startsWith('--')) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const unquotedMatch = trimmedLine.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+[A-Za-z]/);
|
|
81
|
+
if (unquotedMatch) {
|
|
82
|
+
const candidate = unquotedMatch[1];
|
|
83
|
+
if (!candidate) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!skipKeywords.has(candidate.toUpperCase()) && !seen.has(candidate)) {
|
|
88
|
+
seen.add(candidate);
|
|
89
|
+
subjects.push({ type: 'field', name: candidate });
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const quotedMatch = trimmedLine.match(/^"([A-Za-z_][A-Za-z0-9_]*)"\s+[A-Za-z]/);
|
|
95
|
+
if (quotedMatch) {
|
|
96
|
+
const candidate = quotedMatch[1];
|
|
97
|
+
if (!candidate) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!seen.has(candidate)) {
|
|
102
|
+
seen.add(candidate);
|
|
103
|
+
subjects.push({ type: 'field', name: candidate });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return subjects;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function extractProcedureFunctionParameters(fileContent: string): ExtractedSubject[] {
|
|
112
|
+
const subjects: ExtractedSubject[] = [];
|
|
113
|
+
const seen = new Set<string>();
|
|
114
|
+
const parameterRegex = /\b(IN|OUT|INOUT)\s+("?[A-Za-z_][A-Za-z0-9_]*"?)\s+[A-Za-z]/gi;
|
|
115
|
+
|
|
116
|
+
let match = parameterRegex.exec(fileContent);
|
|
117
|
+
while (match) {
|
|
118
|
+
const mode = match[1];
|
|
119
|
+
const rawName = match[2];
|
|
120
|
+
|
|
121
|
+
if (!mode || !rawName) {
|
|
122
|
+
match = parameterRegex.exec(fileContent);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const normalizedMode = mode.toUpperCase();
|
|
127
|
+
const normalizedName = rawName.replace(/^"|"$/g, '');
|
|
128
|
+
|
|
129
|
+
if ((normalizedMode === 'IN' || normalizedMode === 'INOUT') && !seen.has(`input:${normalizedName}`)) {
|
|
130
|
+
seen.add(`input:${normalizedName}`);
|
|
131
|
+
subjects.push({ type: 'inputParameter', name: normalizedName });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if ((normalizedMode === 'OUT' || normalizedMode === 'INOUT') && !seen.has(`output:${normalizedName}`)) {
|
|
135
|
+
seen.add(`output:${normalizedName}`);
|
|
136
|
+
subjects.push({ type: 'outputParameter', name: normalizedName });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
match = parameterRegex.exec(fileContent);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return subjects;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function evaluateAllRules(filePath: string, extension: string, subject: ExtractedSubject, rules: readonly RuleDefinition[]): LintIssue[] {
|
|
146
|
+
const issues: LintIssue[] = [];
|
|
147
|
+
|
|
148
|
+
for (const rule of rules) {
|
|
149
|
+
if (!rule.pattern.test(subject.name)) {
|
|
150
|
+
issues.push({
|
|
151
|
+
filePath,
|
|
152
|
+
artifactName: path.parse(filePath).name,
|
|
153
|
+
extension,
|
|
154
|
+
subjectType: subject.type,
|
|
155
|
+
subjectName: subject.name,
|
|
156
|
+
failedRuleDescription: rule.description,
|
|
157
|
+
failedPattern: toRegexLiteral(rule)
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return issues;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function evaluateAnyRules(filePath: string, extension: string, subject: ExtractedSubject, rules: readonly RuleDefinition[]): LintIssue[] {
|
|
166
|
+
const hasAtLeastOneMatch = rules.some((rule) => rule.pattern.test(subject.name));
|
|
167
|
+
|
|
168
|
+
if (hasAtLeastOneMatch) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return [
|
|
173
|
+
{
|
|
174
|
+
filePath,
|
|
175
|
+
artifactName: path.parse(filePath).name,
|
|
176
|
+
extension,
|
|
177
|
+
subjectType: subject.type,
|
|
178
|
+
subjectName: subject.name,
|
|
179
|
+
failedRuleDescription: 'At least one OR-group rule must match: ' + rules.map((rule) => rule.description).join(' | '),
|
|
180
|
+
failedPattern: rules.map(toRegexLiteral).join(' OR ')
|
|
181
|
+
}
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function toRegexLiteral(rule: RuleDefinition): string {
|
|
186
|
+
return `/${rule.source}/${rule.flags}`;
|
|
187
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliInput } from './types/cli';
|
|
4
|
+
import { LintConfig } from './types/config';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve files to validate:
|
|
8
|
+
* - If CLI files are provided: use only existing files from that list.
|
|
9
|
+
* - If none are provided: run full scan from rootDir.
|
|
10
|
+
*
|
|
11
|
+
* @param config - Lint configuration.
|
|
12
|
+
* @param cliInput - Parsed CLI input.
|
|
13
|
+
* @returns List of file paths to validate.
|
|
14
|
+
*/
|
|
15
|
+
export async function resolveFilesToValidate(config: LintConfig, cliInput: CliInput): Promise<string[]> {
|
|
16
|
+
if (cliInput.files.length > 0) {
|
|
17
|
+
const existingChecks = await Promise.all(
|
|
18
|
+
cliInput.files.map(async (filePath) => ({
|
|
19
|
+
filePath,
|
|
20
|
+
exists: await isExistingFile(filePath)
|
|
21
|
+
}))
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return existingChecks.filter((item) => item.exists).map((item) => path.normalize(item.filePath));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rootExists = await fs
|
|
28
|
+
.access(config.rootDir)
|
|
29
|
+
.then(() => true)
|
|
30
|
+
.catch(() => false);
|
|
31
|
+
|
|
32
|
+
if (!rootExists) {
|
|
33
|
+
throw new Error(`Configured root directory does not exist: "${config.rootDir}"`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return collectFiles(config.rootDir, config.ignoredDirectories);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a path exists and is a regular file.
|
|
41
|
+
*
|
|
42
|
+
* @param filePath - Path to validate.
|
|
43
|
+
* @returns True if path exists and is a file.
|
|
44
|
+
*/
|
|
45
|
+
async function isExistingFile(filePath: string): Promise<boolean> {
|
|
46
|
+
try {
|
|
47
|
+
const stat = await fs.stat(filePath);
|
|
48
|
+
return stat.isFile();
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Recursively collect all files from a directory while skipping ignored folders.
|
|
56
|
+
*
|
|
57
|
+
* @param directoryPath - Absolute or relative folder path.
|
|
58
|
+
* @param ignoredDirectories - Directory names to skip.
|
|
59
|
+
* @returns List of full file paths.
|
|
60
|
+
*/
|
|
61
|
+
async function collectFiles(directoryPath: string, ignoredDirectories: readonly string[]): Promise<string[]> {
|
|
62
|
+
const entries = await fs.readdir(directoryPath, { withFileTypes: true });
|
|
63
|
+
|
|
64
|
+
const collected = await Promise.all(
|
|
65
|
+
entries.map(async (entry) => {
|
|
66
|
+
const fullPath = path.join(directoryPath, entry.name);
|
|
67
|
+
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
if (ignoredDirectories.includes(entry.name)) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
return collectFiles(fullPath, ignoredDirectories);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (entry.isFile()) {
|
|
76
|
+
return [fullPath];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return [];
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return collected.flat();
|
|
84
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseCliInput, runInit } from './cli';
|
|
4
|
+
import { readJsonConfig, toLintConfig } from './config';
|
|
5
|
+
import { resolveFilesToValidate } from './files';
|
|
6
|
+
import { runLint } from './lint';
|
|
7
|
+
import { printReport } from './report';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Application entry point.
|
|
11
|
+
*/
|
|
12
|
+
async function main(): Promise<void> {
|
|
13
|
+
const cliInput = parseCliInput();
|
|
14
|
+
|
|
15
|
+
if (cliInput.command === 'init') {
|
|
16
|
+
await runInit(cliInput.force);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const jsonConfig = await readJsonConfig(cliInput.configPath);
|
|
21
|
+
const config = toLintConfig(jsonConfig);
|
|
22
|
+
|
|
23
|
+
const filesToValidate = await resolveFilesToValidate(config, cliInput);
|
|
24
|
+
const issues = await runLint(config, filesToValidate);
|
|
25
|
+
|
|
26
|
+
printReport(issues, filesToValidate);
|
|
27
|
+
|
|
28
|
+
if (issues.length > 0) {
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
void main().catch((error: unknown) => {
|
|
34
|
+
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
35
|
+
console.error(`❌ HANA naming lint crashed: ${message}`);
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
});
|
package/src/lint.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lintFileContent } from './content-lint';
|
|
3
|
+
import { LintIssue } from './types/issues';
|
|
4
|
+
import { LintConfig } from './types/config';
|
|
5
|
+
import { ExtensionRuleSet, RuleDefinition } from './types/rules';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run naming lint and return all discovered issues.
|
|
9
|
+
*
|
|
10
|
+
* @param config - Lint configuration.
|
|
11
|
+
* @param filesToValidate - File paths to validate.
|
|
12
|
+
* @returns All naming violations.
|
|
13
|
+
*/
|
|
14
|
+
export async function runLint(config: LintConfig, filesToValidate: readonly string[]): Promise<LintIssue[]> {
|
|
15
|
+
const issues: LintIssue[] = [];
|
|
16
|
+
|
|
17
|
+
for (const filePath of filesToValidate) {
|
|
18
|
+
const fileIssues = validateFileName(filePath, config.extensionRuleSets, config.rootDir);
|
|
19
|
+
issues.push(...fileIssues);
|
|
20
|
+
|
|
21
|
+
if (config.contentRuleSets.length > 0) {
|
|
22
|
+
const contentIssues = await lintFileContent(filePath, config.contentRuleSets);
|
|
23
|
+
issues.push(...contentIssues);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return issues;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate a single file against configured rule groups for its extension.
|
|
32
|
+
*
|
|
33
|
+
* @param filePath - Full file path.
|
|
34
|
+
* @param extensionRuleSets - Configured extension rule sets.
|
|
35
|
+
* @param rootDir - Configured root directory.
|
|
36
|
+
* @returns List of lint issues.
|
|
37
|
+
*/
|
|
38
|
+
function validateFileName(filePath: string, extensionRuleSets: readonly ExtensionRuleSet[], rootDir: string): LintIssue[] {
|
|
39
|
+
const extension = path.extname(filePath);
|
|
40
|
+
const matchedRuleSets = findRuleSetsForExtension(extension, extensionRuleSets);
|
|
41
|
+
const ruleSet = mergeRuleSets(extension, matchedRuleSets);
|
|
42
|
+
|
|
43
|
+
if (!ruleSet) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const artifactName = getArtifactNameFromFilePath(filePath);
|
|
48
|
+
const issues: LintIssue[] = [];
|
|
49
|
+
|
|
50
|
+
if (ruleSet.folderName && !isFileInRequiredFolder(filePath, rootDir, ruleSet.folderName)) {
|
|
51
|
+
issues.push({
|
|
52
|
+
filePath,
|
|
53
|
+
artifactName,
|
|
54
|
+
extension,
|
|
55
|
+
subjectType: 'artifact',
|
|
56
|
+
subjectName: artifactName,
|
|
57
|
+
failedRuleDescription: `File must be located in folder "${ruleSet.folderName}" under configured rootDir`,
|
|
58
|
+
failedPattern: `folder:${ruleSet.folderName}`
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const allRules = ruleSet.groups.all ?? [];
|
|
63
|
+
const anyRules = ruleSet.groups.any ?? [];
|
|
64
|
+
|
|
65
|
+
if (allRules.length > 0) {
|
|
66
|
+
issues.push(...evaluateAllRules(filePath, artifactName, extension, allRules));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (anyRules.length > 0) {
|
|
70
|
+
issues.push(...evaluateAnyRules(filePath, artifactName, extension, anyRules));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return issues;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract artifact name from file path.
|
|
78
|
+
*
|
|
79
|
+
* @param filePath - Full file path.
|
|
80
|
+
* @returns Artifact name without extension.
|
|
81
|
+
*/
|
|
82
|
+
function getArtifactNameFromFilePath(filePath: string): string {
|
|
83
|
+
const parsed = path.parse(filePath);
|
|
84
|
+
return parsed.name;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve matching rule sets for a file extension.
|
|
89
|
+
*
|
|
90
|
+
* @param extension - File extension.
|
|
91
|
+
* @param extensionRuleSets - Configured extension rule sets.
|
|
92
|
+
* @returns Matching extension rule sets.
|
|
93
|
+
*/
|
|
94
|
+
function findRuleSetsForExtension(extension: string, extensionRuleSets: readonly ExtensionRuleSet[]): ExtensionRuleSet[] {
|
|
95
|
+
return extensionRuleSets.filter((ruleSet) => ruleSet.extension === '*' || ruleSet.extension === extension);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Merge multiple rule sets into a single effective set.
|
|
100
|
+
*
|
|
101
|
+
* @param extension - File extension.
|
|
102
|
+
* @param ruleSets - Matching rule sets for extension.
|
|
103
|
+
* @returns Merged rule set or undefined when no rules apply.
|
|
104
|
+
*/
|
|
105
|
+
function mergeRuleSets(extension: string, ruleSets: readonly ExtensionRuleSet[]): ExtensionRuleSet | undefined {
|
|
106
|
+
if (ruleSets.length === 0) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const specificRuleSets = ruleSets.filter((ruleSet) => ruleSet.extension === extension);
|
|
111
|
+
const wildcardRuleSets = ruleSets.filter((ruleSet) => ruleSet.extension === '*');
|
|
112
|
+
|
|
113
|
+
const specificFolderName = specificRuleSets.find((ruleSet) => ruleSet.folderName !== undefined)?.folderName;
|
|
114
|
+
const wildcardFolderName = wildcardRuleSets.find((ruleSet) => ruleSet.folderName !== undefined)?.folderName;
|
|
115
|
+
|
|
116
|
+
const allRules = ruleSets.flatMap((ruleSet) => ruleSet.groups.all ?? []);
|
|
117
|
+
const anyRules = ruleSets.flatMap((ruleSet) => ruleSet.groups.any ?? []);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
extension,
|
|
121
|
+
folderName: specificFolderName ?? wildcardFolderName,
|
|
122
|
+
groups: {
|
|
123
|
+
all: allRules.length > 0 ? allRules : undefined,
|
|
124
|
+
any: anyRules.length > 0 ? anyRules : undefined
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check whether a file is located under a target folder name within rootDir.
|
|
131
|
+
*
|
|
132
|
+
* @param filePath - Candidate file path.
|
|
133
|
+
* @param rootDir - Configured root directory.
|
|
134
|
+
* @param requiredFolderName - Folder name to enforce.
|
|
135
|
+
* @returns True when file is under rootDir and contained in required folder.
|
|
136
|
+
*/
|
|
137
|
+
function isFileInRequiredFolder(filePath: string, rootDir: string, requiredFolderName: string): boolean {
|
|
138
|
+
const absoluteRootDir = path.resolve(rootDir);
|
|
139
|
+
const absoluteFilePath = path.resolve(filePath);
|
|
140
|
+
const relativeToRoot = path.relative(absoluteRootDir, absoluteFilePath);
|
|
141
|
+
|
|
142
|
+
// Files outside rootDir are never valid for folder enforcement.
|
|
143
|
+
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const parentDirectory = path.dirname(relativeToRoot);
|
|
148
|
+
if (parentDirectory === '.' || parentDirectory.length === 0) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const folderSegments = parentDirectory.split(path.sep);
|
|
153
|
+
return folderSegments.includes(requiredFolderName);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Convert a rule to displayable regex literal text.
|
|
158
|
+
*
|
|
159
|
+
* @param rule - Runtime rule.
|
|
160
|
+
* @returns Regex literal-like string.
|
|
161
|
+
*/
|
|
162
|
+
function toRegexLiteral(rule: RuleDefinition): string {
|
|
163
|
+
return `/${rule.source}/${rule.flags}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build lint issues for failed AND-rules.
|
|
168
|
+
*
|
|
169
|
+
* @param filePath - File path.
|
|
170
|
+
* @param artifactName - Artifact name.
|
|
171
|
+
* @param extension - Artifact extension.
|
|
172
|
+
* @param rules - AND-rules list.
|
|
173
|
+
* @returns Lint issues for failed rules.
|
|
174
|
+
*/
|
|
175
|
+
function evaluateAllRules(filePath: string, artifactName: string, extension: string, rules: readonly RuleDefinition[]): LintIssue[] {
|
|
176
|
+
const issues: LintIssue[] = [];
|
|
177
|
+
|
|
178
|
+
for (const rule of rules) {
|
|
179
|
+
if (!rule.pattern.test(artifactName)) {
|
|
180
|
+
issues.push({
|
|
181
|
+
filePath,
|
|
182
|
+
artifactName,
|
|
183
|
+
extension,
|
|
184
|
+
subjectType: 'artifact',
|
|
185
|
+
subjectName: artifactName,
|
|
186
|
+
failedRuleDescription: rule.description,
|
|
187
|
+
failedPattern: toRegexLiteral(rule)
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return issues;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Build lint issue for failed OR-group.
|
|
197
|
+
*
|
|
198
|
+
* @param filePath - File path.
|
|
199
|
+
* @param artifactName - Artifact name.
|
|
200
|
+
* @param extension - Artifact extension.
|
|
201
|
+
* @param rules - OR-rules list.
|
|
202
|
+
* @returns Single lint issue if OR-group fails; otherwise empty list.
|
|
203
|
+
*/
|
|
204
|
+
function evaluateAnyRules(filePath: string, artifactName: string, extension: string, rules: readonly RuleDefinition[]): LintIssue[] {
|
|
205
|
+
const hasAtLeastOneMatch = rules.some((rule) => rule.pattern.test(artifactName));
|
|
206
|
+
|
|
207
|
+
if (hasAtLeastOneMatch) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return [
|
|
212
|
+
{
|
|
213
|
+
filePath,
|
|
214
|
+
artifactName,
|
|
215
|
+
extension,
|
|
216
|
+
subjectType: 'artifact',
|
|
217
|
+
subjectName: artifactName,
|
|
218
|
+
failedRuleDescription: 'At least one OR-group rule must match: ' + rules.map((rule) => rule.description).join(' | '),
|
|
219
|
+
failedPattern: rules.map(toRegexLiteral).join(' OR ')
|
|
220
|
+
}
|
|
221
|
+
];
|
|
222
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LintIssue } from './types/issues';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Print lint result report to stdout/stderr.
|
|
5
|
+
*
|
|
6
|
+
* @param issues - Found naming violations.
|
|
7
|
+
* @param filesToValidate - Number of candidate files processed.
|
|
8
|
+
*/
|
|
9
|
+
export function printReport(issues: readonly LintIssue[], filesToValidate: readonly string[]): void {
|
|
10
|
+
if (issues.length === 0) {
|
|
11
|
+
console.info(`✅ HANA naming lint passed. Checked ${filesToValidate.length} file(s).`);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.error(`❌ HANA naming lint failed. Violations: ${issues.length}`);
|
|
16
|
+
console.error('');
|
|
17
|
+
|
|
18
|
+
for (const issue of issues) {
|
|
19
|
+
console.error(`- File: ${issue.filePath}`);
|
|
20
|
+
console.error(` Artifact: ${issue.artifactName}`);
|
|
21
|
+
if (issue.subjectType && issue.subjectName) {
|
|
22
|
+
console.error(` Subject: ${issue.subjectType} (${issue.subjectName})`);
|
|
23
|
+
}
|
|
24
|
+
console.error(` Type: ${issue.extension}`);
|
|
25
|
+
console.error(` Failed rule: ${issue.failedRuleDescription}`);
|
|
26
|
+
console.error(` Expected regex: ${issue.failedPattern}`);
|
|
27
|
+
console.error('');
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/types/cli.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type CliCommand = 'lint' | 'init';
|
|
2
|
+
|
|
3
|
+
export type CliInput = {
|
|
4
|
+
/**
|
|
5
|
+
* CLI command mode.
|
|
6
|
+
*/
|
|
7
|
+
readonly command: CliCommand;
|
|
8
|
+
/**
|
|
9
|
+
* Files passed via CLI.
|
|
10
|
+
* Empty list means full-scan mode.
|
|
11
|
+
*/
|
|
12
|
+
readonly files: readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Config path override.
|
|
15
|
+
*/
|
|
16
|
+
readonly configPath: string;
|
|
17
|
+
/**
|
|
18
|
+
* Allow overwriting existing config in init mode.
|
|
19
|
+
*/
|
|
20
|
+
readonly force: boolean;
|
|
21
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ContentRuleSet, ExtensionRuleSet } from './rules';
|
|
2
|
+
|
|
3
|
+
export type LintConfig = {
|
|
4
|
+
/**
|
|
5
|
+
* Base folder to scan in full-scan mode.
|
|
6
|
+
*/
|
|
7
|
+
readonly rootDir: string;
|
|
8
|
+
/**
|
|
9
|
+
* Directory names to skip during traversal.
|
|
10
|
+
*/
|
|
11
|
+
readonly ignoredDirectories: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Compiled rule sets.
|
|
14
|
+
*/
|
|
15
|
+
readonly extensionRuleSets: readonly ExtensionRuleSet[];
|
|
16
|
+
/**
|
|
17
|
+
* Optional compiled content rule sets.
|
|
18
|
+
*/
|
|
19
|
+
readonly contentRuleSets: readonly ContentRuleSet[];
|
|
20
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type LintIssue = {
|
|
2
|
+
readonly filePath: string;
|
|
3
|
+
readonly artifactName: string;
|
|
4
|
+
readonly extension: string;
|
|
5
|
+
readonly subjectType?: 'artifact' | 'field' | 'inputParameter' | 'outputParameter';
|
|
6
|
+
readonly subjectName?: string;
|
|
7
|
+
readonly failedRuleDescription: string;
|
|
8
|
+
readonly failedPattern: string;
|
|
9
|
+
};
|