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,224 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rootDir": "db",
|
|
3
|
+
"ignoredDirectories": ["node_modules", ".git", "gen"],
|
|
4
|
+
"extensionRuleSets": [
|
|
5
|
+
{
|
|
6
|
+
"extension": "hdb*",
|
|
7
|
+
"groups": {
|
|
8
|
+
"all": [
|
|
9
|
+
{
|
|
10
|
+
"description": "Upper snake case only",
|
|
11
|
+
"pattern": "^[A-Z0-9]+(?:_[A-Z0-9]+)*$"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"description": "Max length 30",
|
|
15
|
+
"pattern": "^.{1,30}$",
|
|
16
|
+
"flags": "u"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"extension": ".hdbtable",
|
|
23
|
+
"folderName": "tables",
|
|
24
|
+
"groups": {
|
|
25
|
+
"any": [
|
|
26
|
+
{
|
|
27
|
+
"description": "Prefix T_",
|
|
28
|
+
"pattern": "^T_.+"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"description": "Prefix TX_",
|
|
32
|
+
"pattern": "^TX_.+"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"extension": ".hdbview",
|
|
39
|
+
"folderName": "sqlviews",
|
|
40
|
+
"groups": {
|
|
41
|
+
"all": [
|
|
42
|
+
{
|
|
43
|
+
"description": "Prefix V_",
|
|
44
|
+
"pattern": "^V_.+"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"extension": ".hdbfunction",
|
|
51
|
+
"folderName": "functions",
|
|
52
|
+
"groups": {
|
|
53
|
+
"any": [
|
|
54
|
+
{
|
|
55
|
+
"description": "Prefix TF_",
|
|
56
|
+
"pattern": "^TF_.+"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"description": "Prefix SF_",
|
|
60
|
+
"pattern": "^SF_.+"
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"extension": ".hdbprocedure",
|
|
67
|
+
"folderName": "procedures",
|
|
68
|
+
"groups": {
|
|
69
|
+
"all": [
|
|
70
|
+
{
|
|
71
|
+
"description": "Prefix PR_",
|
|
72
|
+
"pattern": "^PR_.+"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"extension": ".hdbcalculationview",
|
|
79
|
+
"folderName": "models",
|
|
80
|
+
"groups": {
|
|
81
|
+
"all": [
|
|
82
|
+
{
|
|
83
|
+
"description": "Prefix CV_",
|
|
84
|
+
"pattern": "^CV_.+"
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"extension": ".hdbanalyticalprivilege",
|
|
91
|
+
"folderName": "authorizations",
|
|
92
|
+
"groups": {
|
|
93
|
+
"all": [
|
|
94
|
+
{
|
|
95
|
+
"description": "Prefix AP_",
|
|
96
|
+
"pattern": "^AP_.+"
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"extension": ".hdbrole",
|
|
103
|
+
"folderName": "roles",
|
|
104
|
+
"groups": {
|
|
105
|
+
"all": [
|
|
106
|
+
{
|
|
107
|
+
"description": "Prefix R_",
|
|
108
|
+
"pattern": "^R_.+"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"extension": ".hdbsequence",
|
|
115
|
+
"folderName": "sequences",
|
|
116
|
+
"groups": {
|
|
117
|
+
"all": [
|
|
118
|
+
{
|
|
119
|
+
"description": "Prefix S_",
|
|
120
|
+
"pattern": "^S_.+"
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"extension": ".hdbtabletype",
|
|
127
|
+
"folderName": "tabletypes",
|
|
128
|
+
"groups": {
|
|
129
|
+
"all": [
|
|
130
|
+
{
|
|
131
|
+
"description": "Prefix TT_",
|
|
132
|
+
"pattern": "^TT_.+"
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"extension": ".hdbconstraint",
|
|
139
|
+
"folderName": "constraints",
|
|
140
|
+
"groups": {
|
|
141
|
+
"all": [
|
|
142
|
+
{
|
|
143
|
+
"description": "Prefix C_",
|
|
144
|
+
"pattern": "^C_.+"
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"extension": ".hdbschedulerjob",
|
|
151
|
+
"folderName": "jobs",
|
|
152
|
+
"groups": {
|
|
153
|
+
"all": [
|
|
154
|
+
{
|
|
155
|
+
"description": "Prefix J_",
|
|
156
|
+
"pattern": "^J_.+"
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"extension": ".hdbindex",
|
|
163
|
+
"folderName": "indexes",
|
|
164
|
+
"groups": {
|
|
165
|
+
"all": [
|
|
166
|
+
{
|
|
167
|
+
"description": "Prefix IDX_",
|
|
168
|
+
"pattern": "^IDX_.+"
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"extension": ".hdbtrigger",
|
|
175
|
+
"folderName": "triggers",
|
|
176
|
+
"groups": {
|
|
177
|
+
"all": [
|
|
178
|
+
{
|
|
179
|
+
"description": "Prefix TR_",
|
|
180
|
+
"pattern": "^TR_.+"
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
],
|
|
186
|
+
"contentRuleSets": [
|
|
187
|
+
{
|
|
188
|
+
"extension": ".hdbtable",
|
|
189
|
+
"target": "field",
|
|
190
|
+
"groups": {
|
|
191
|
+
"all": [
|
|
192
|
+
{
|
|
193
|
+
"description": "Field names in uppercase snake case",
|
|
194
|
+
"pattern": "^[A-Z0-9]+(?:_[A-Z0-9]+)*$"
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"extension": ".hdbprocedure",
|
|
201
|
+
"target": "inputParameter",
|
|
202
|
+
"groups": {
|
|
203
|
+
"all": [
|
|
204
|
+
{
|
|
205
|
+
"description": "Input parameters prefixed with IP_",
|
|
206
|
+
"pattern": "^IP_[A-Z0-9_]+$"
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"extension": ".hdbprocedure",
|
|
213
|
+
"target": "outputParameter",
|
|
214
|
+
"groups": {
|
|
215
|
+
"all": [
|
|
216
|
+
{
|
|
217
|
+
"description": "Output parameters prefixed with OP_",
|
|
218
|
+
"pattern": "^OP_[A-Z0-9_]+$"
|
|
219
|
+
}
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
]
|
|
224
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { constants as fsConstants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliInput } from './types/cli';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG_PATH = '.hana-linter.json';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse command-line arguments.
|
|
9
|
+
*
|
|
10
|
+
* Supported:
|
|
11
|
+
* - --config <path>
|
|
12
|
+
* - remaining arguments are treated as file paths
|
|
13
|
+
*
|
|
14
|
+
* @returns Parsed CLI input.
|
|
15
|
+
*/
|
|
16
|
+
export function parseCliInput(): CliInput {
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
if (args[0] === 'init') {
|
|
20
|
+
const initArgs = args.slice(1);
|
|
21
|
+
let force = false;
|
|
22
|
+
|
|
23
|
+
for (const value of initArgs) {
|
|
24
|
+
if (value === '--force') {
|
|
25
|
+
force = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error(`Unknown argument for init: "${value}". Supported: --force`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
command: 'init',
|
|
34
|
+
files: [],
|
|
35
|
+
configPath: DEFAULT_CONFIG_PATH,
|
|
36
|
+
force
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const files: string[] = [];
|
|
41
|
+
let configPath = DEFAULT_CONFIG_PATH;
|
|
42
|
+
|
|
43
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
44
|
+
const value = args[index];
|
|
45
|
+
|
|
46
|
+
if (value === '--config') {
|
|
47
|
+
const nextValue = args[index + 1];
|
|
48
|
+
if (!nextValue || nextValue.startsWith('--')) {
|
|
49
|
+
throw new Error('Missing value for --config');
|
|
50
|
+
}
|
|
51
|
+
configPath = nextValue;
|
|
52
|
+
index += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
files.push(value as string);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
command: 'lint',
|
|
61
|
+
files: files.filter((value) => value.trim().length > 0),
|
|
62
|
+
configPath,
|
|
63
|
+
force: false
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create .hana-linter.json in current working directory from bundled template.
|
|
69
|
+
*
|
|
70
|
+
* @param force - Overwrite existing file when true.
|
|
71
|
+
*/
|
|
72
|
+
export async function runInit(force: boolean): Promise<void> {
|
|
73
|
+
const templatePath = await resolveTemplateConfigPath();
|
|
74
|
+
const targetPath = path.resolve(process.cwd(), DEFAULT_CONFIG_PATH);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await fs.copyFile(templatePath, targetPath, force ? 0 : fsConstants.COPYFILE_EXCL);
|
|
78
|
+
console.info(`✅ Created ${DEFAULT_CONFIG_PATH} at ${targetPath}`);
|
|
79
|
+
} catch (error: unknown) {
|
|
80
|
+
if (!force && typeof error === 'object' && error !== null && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
81
|
+
throw new Error(`${DEFAULT_CONFIG_PATH} already exists at ${targetPath}. Use "hana-linter init --force" to overwrite.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the packaged default config template path.
|
|
90
|
+
*
|
|
91
|
+
* Supports both:
|
|
92
|
+
* - built artifact layout (dist/assets)
|
|
93
|
+
* - source layout (src/assets) during local development
|
|
94
|
+
*
|
|
95
|
+
* @returns Existing template path.
|
|
96
|
+
*/
|
|
97
|
+
async function resolveTemplateConfigPath(): Promise<string> {
|
|
98
|
+
const candidatePaths = [
|
|
99
|
+
path.resolve(__dirname, 'assets', DEFAULT_CONFIG_PATH),
|
|
100
|
+
path.resolve(__dirname, '..', 'src', 'assets', DEFAULT_CONFIG_PATH)
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (const candidatePath of candidatePaths) {
|
|
104
|
+
try {
|
|
105
|
+
await fs.access(candidatePath);
|
|
106
|
+
return candidatePath;
|
|
107
|
+
} catch {
|
|
108
|
+
// Try next candidate.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw new Error('Could not locate bundled default configuration template. Expected one of: ' + candidatePaths.join(', '));
|
|
113
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { LintConfig } from './types/config';
|
|
3
|
+
import {
|
|
4
|
+
ContentRuleSet,
|
|
5
|
+
ContentTarget,
|
|
6
|
+
ExtensionRuleSet,
|
|
7
|
+
JsonContentRuleSet,
|
|
8
|
+
JsonLintConfig,
|
|
9
|
+
JsonExtensionRuleSet,
|
|
10
|
+
JsonRuleDefinition,
|
|
11
|
+
RuleDefinition,
|
|
12
|
+
RuleGroup
|
|
13
|
+
} from './types/rules';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read and parse JSON config file.
|
|
17
|
+
*
|
|
18
|
+
* @param configPath - Path to JSON configuration.
|
|
19
|
+
* @returns Parsed JSON config object.
|
|
20
|
+
*/
|
|
21
|
+
export async function readJsonConfig(configPath: string): Promise<JsonLintConfig> {
|
|
22
|
+
const rawContent = await fs.readFile(configPath, { encoding: 'utf-8' });
|
|
23
|
+
const parsed: unknown = JSON.parse(rawContent);
|
|
24
|
+
|
|
25
|
+
if (!isJsonLintConfig(parsed)) {
|
|
26
|
+
throw new Error(`Invalid configuration schema in "${configPath}". Please verify required fields.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert JSON config into runtime compiled config.
|
|
34
|
+
*
|
|
35
|
+
* @param jsonConfig - Parsed JSON config.
|
|
36
|
+
* @returns Runtime lint config with compiled regex.
|
|
37
|
+
*/
|
|
38
|
+
export function toLintConfig(jsonConfig: JsonLintConfig): LintConfig {
|
|
39
|
+
const extensionFolderNames = new Map<string, string>();
|
|
40
|
+
|
|
41
|
+
const extensionRuleSets: ExtensionRuleSet[] = jsonConfig.extensionRuleSets.map((jsonRuleSet) => {
|
|
42
|
+
const folderName = jsonRuleSet.folderName?.trim();
|
|
43
|
+
|
|
44
|
+
if (folderName !== undefined && folderName.length === 0) {
|
|
45
|
+
throw new Error(`Invalid folderName for extension "${jsonRuleSet.extension}": value must not be empty.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (folderName !== undefined && (folderName.includes('/') || folderName.includes('\\'))) {
|
|
49
|
+
throw new Error(`Invalid folderName for extension "${jsonRuleSet.extension}": use only a folder name, not a path.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (folderName !== undefined) {
|
|
53
|
+
const existingFolderName = extensionFolderNames.get(jsonRuleSet.extension);
|
|
54
|
+
if (existingFolderName && existingFolderName !== folderName) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Conflicting folderName values configured for extension "${jsonRuleSet.extension}": "${existingFolderName}" and "${folderName}".`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
extensionFolderNames.set(jsonRuleSet.extension, folderName);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const compiledGroups = compileRuleGroup(jsonRuleSet.groups, `extension "${jsonRuleSet.extension}"`);
|
|
63
|
+
|
|
64
|
+
const allRules = compiledGroups.all ?? [];
|
|
65
|
+
const anyRules = compiledGroups.any ?? [];
|
|
66
|
+
|
|
67
|
+
const hasAllRules = allRules.length > 0;
|
|
68
|
+
const hasAnyRules = anyRules.length > 0;
|
|
69
|
+
|
|
70
|
+
if (!hasAllRules && !hasAnyRules) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Invalid rule configuration for extension "${jsonRuleSet.extension}": at least one rule is required in "groups.all" or "groups.any".`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
extension: jsonRuleSet.extension,
|
|
78
|
+
folderName,
|
|
79
|
+
groups: compiledGroups
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const contentRuleSets: ContentRuleSet[] = (jsonConfig.contentRuleSets ?? []).map((jsonRuleSet) => {
|
|
84
|
+
const compiledGroups = compileRuleGroup(jsonRuleSet.groups, `content target "${jsonRuleSet.target}" extension "${jsonRuleSet.extension}"`);
|
|
85
|
+
|
|
86
|
+
const hasAllRules = (compiledGroups.all ?? []).length > 0;
|
|
87
|
+
const hasAnyRules = (compiledGroups.any ?? []).length > 0;
|
|
88
|
+
|
|
89
|
+
if (!hasAllRules && !hasAnyRules) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Invalid content rule configuration for target "${jsonRuleSet.target}" extension "${jsonRuleSet.extension}": at least one rule is required in "groups.all" or "groups.any".`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
extension: jsonRuleSet.extension,
|
|
97
|
+
target: jsonRuleSet.target,
|
|
98
|
+
groups: compiledGroups
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
rootDir: jsonConfig.rootDir,
|
|
104
|
+
ignoredDirectories: jsonConfig.ignoredDirectories,
|
|
105
|
+
extensionRuleSets,
|
|
106
|
+
contentRuleSets
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Basic runtime schema guard for JSON config.
|
|
112
|
+
*
|
|
113
|
+
* @param value - Unknown parsed JSON value.
|
|
114
|
+
* @returns True when value matches expected shape.
|
|
115
|
+
*/
|
|
116
|
+
function isJsonLintConfig(value: unknown): value is JsonLintConfig {
|
|
117
|
+
if (!value || typeof value !== 'object') {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const candidate = value as Partial<JsonLintConfig>;
|
|
122
|
+
|
|
123
|
+
if (typeof candidate.rootDir !== 'string') {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!Array.isArray(candidate.ignoredDirectories)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!Array.isArray(candidate.extensionRuleSets)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (candidate.contentRuleSets !== undefined && !Array.isArray(candidate.contentRuleSets)) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const extensionRuleSetsValid = candidate.extensionRuleSets.every((ruleSet) => {
|
|
140
|
+
if (!ruleSet || typeof ruleSet !== 'object') {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const typedRuleSet = ruleSet as Partial<JsonExtensionRuleSet>;
|
|
145
|
+
if (typeof typedRuleSet.extension !== 'string') {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typedRuleSet.folderName !== undefined && typeof typedRuleSet.folderName !== 'string') {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!typedRuleSet.groups || typeof typedRuleSet.groups !== 'object') {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const groups = typedRuleSet.groups;
|
|
158
|
+
const allRulesValid =
|
|
159
|
+
groups.all === undefined ||
|
|
160
|
+
(Array.isArray(groups.all) &&
|
|
161
|
+
(groups.all as JsonRuleDefinition[]).every(
|
|
162
|
+
(rule) =>
|
|
163
|
+
!!rule &&
|
|
164
|
+
typeof rule.description === 'string' &&
|
|
165
|
+
typeof rule.pattern === 'string' &&
|
|
166
|
+
(rule.flags === undefined || typeof rule.flags === 'string')
|
|
167
|
+
));
|
|
168
|
+
|
|
169
|
+
const anyRulesValid =
|
|
170
|
+
groups.any === undefined ||
|
|
171
|
+
(Array.isArray(groups.any) &&
|
|
172
|
+
(groups.any as JsonRuleDefinition[]).every(
|
|
173
|
+
(rule) =>
|
|
174
|
+
!!rule &&
|
|
175
|
+
typeof rule.description === 'string' &&
|
|
176
|
+
typeof rule.pattern === 'string' &&
|
|
177
|
+
(rule.flags === undefined || typeof rule.flags === 'string')
|
|
178
|
+
));
|
|
179
|
+
|
|
180
|
+
return allRulesValid && anyRulesValid;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!extensionRuleSetsValid) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return (candidate.contentRuleSets ?? []).every((ruleSet) => {
|
|
188
|
+
if (!ruleSet || typeof ruleSet !== 'object') {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const typedRuleSet = ruleSet as Partial<JsonContentRuleSet>;
|
|
193
|
+
if (typeof typedRuleSet.extension !== 'string') {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!isContentTarget(typedRuleSet.target)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!typedRuleSet.groups || typeof typedRuleSet.groups !== 'object') {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const groups = typedRuleSet.groups;
|
|
206
|
+
const allRulesValid =
|
|
207
|
+
groups.all === undefined ||
|
|
208
|
+
(Array.isArray(groups.all) &&
|
|
209
|
+
(groups.all as JsonRuleDefinition[]).every(
|
|
210
|
+
(rule) =>
|
|
211
|
+
!!rule &&
|
|
212
|
+
typeof rule.description === 'string' &&
|
|
213
|
+
typeof rule.pattern === 'string' &&
|
|
214
|
+
(rule.flags === undefined || typeof rule.flags === 'string')
|
|
215
|
+
));
|
|
216
|
+
|
|
217
|
+
const anyRulesValid =
|
|
218
|
+
groups.any === undefined ||
|
|
219
|
+
(Array.isArray(groups.any) &&
|
|
220
|
+
(groups.any as JsonRuleDefinition[]).every(
|
|
221
|
+
(rule) =>
|
|
222
|
+
!!rule &&
|
|
223
|
+
typeof rule.description === 'string' &&
|
|
224
|
+
typeof rule.pattern === 'string' &&
|
|
225
|
+
(rule.flags === undefined || typeof rule.flags === 'string')
|
|
226
|
+
));
|
|
227
|
+
|
|
228
|
+
return allRulesValid && anyRulesValid;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isContentTarget(target: unknown): target is ContentTarget {
|
|
233
|
+
return target === 'field' || target === 'inputParameter' || target === 'outputParameter';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function compileRuleGroup(groups: { all?: readonly JsonRuleDefinition[]; any?: readonly JsonRuleDefinition[] }, contextRoot: string): RuleGroup {
|
|
237
|
+
const allRules: RuleDefinition[] = (groups.all ?? []).map((rule, index) => {
|
|
238
|
+
const flags = rule.flags ?? '';
|
|
239
|
+
const context = `${contextRoot} group "all" rule #${index + 1}`;
|
|
240
|
+
return {
|
|
241
|
+
description: rule.description,
|
|
242
|
+
source: rule.pattern,
|
|
243
|
+
flags,
|
|
244
|
+
pattern: compileRegex(rule.pattern, flags, context)
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const anyRules: RuleDefinition[] = (groups.any ?? []).map((rule, index) => {
|
|
249
|
+
const flags = rule.flags ?? '';
|
|
250
|
+
const context = `${contextRoot} group "any" rule #${index + 1}`;
|
|
251
|
+
return {
|
|
252
|
+
description: rule.description,
|
|
253
|
+
source: rule.pattern,
|
|
254
|
+
flags,
|
|
255
|
+
pattern: compileRegex(rule.pattern, flags, context)
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
all: allRules.length > 0 ? allRules : undefined,
|
|
261
|
+
any: anyRules.length > 0 ? anyRules : undefined
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Validate regex flags for duplicate/unsupported values.
|
|
267
|
+
*
|
|
268
|
+
* @param flags - Raw regex flags string.
|
|
269
|
+
* @param context - Rule context for clear error messages.
|
|
270
|
+
*/
|
|
271
|
+
function validateRegexFlags(flags: string, context: string): void {
|
|
272
|
+
const allowedFlags = new Set(['d', 'g', 'i', 'm', 's', 'u', 'v', 'y']);
|
|
273
|
+
const seen = new Set<string>();
|
|
274
|
+
|
|
275
|
+
for (const flag of flags) {
|
|
276
|
+
if (!allowedFlags.has(flag)) {
|
|
277
|
+
throw new Error(`Invalid regex flag in ${context}: "${flag}"`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (seen.has(flag)) {
|
|
281
|
+
throw new Error(`Duplicate regex flag in ${context}: "${flag}"`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
seen.add(flag);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Compile regex source and flags into RegExp with helpful error context.
|
|
290
|
+
*
|
|
291
|
+
* @param source - Regex source from config.
|
|
292
|
+
* @param flags - Regex flags from config.
|
|
293
|
+
* @param context - Rule context for error reporting.
|
|
294
|
+
* @returns Compiled RegExp.
|
|
295
|
+
*/
|
|
296
|
+
function compileRegex(source: string, flags: string, context: string): RegExp {
|
|
297
|
+
validateRegexFlags(flags, context);
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
return new RegExp(source, flags);
|
|
301
|
+
} catch (error: unknown) {
|
|
302
|
+
const message = error instanceof Error ? error.message : 'Unknown regex error';
|
|
303
|
+
throw new Error(`Invalid regex in ${context}: pattern="${source}" flags="${flags}". ${message}`, { cause: error });
|
|
304
|
+
}
|
|
305
|
+
}
|