@herb-tools/config 0.8.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 +58 -0
- package/dist/herb-config.cjs +12736 -0
- package/dist/herb-config.cjs.map +1 -0
- package/dist/herb-config.esm.js +12712 -0
- package/dist/herb-config.esm.js.map +1 -0
- package/dist/package.json +49 -0
- package/dist/src/config-schema.js +39 -0
- package/dist/src/config-schema.js.map +1 -0
- package/dist/src/config.js +856 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/merge.js +41 -0
- package/dist/src/merge.js.map +1 -0
- package/dist/src/vscode.js +73 -0
- package/dist/src/vscode.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/config-schema.d.ts +90 -0
- package/dist/types/config.d.ts +348 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/merge.d.ts +11 -0
- package/dist/types/src/config-schema.d.ts +90 -0
- package/dist/types/src/config.d.ts +348 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/merge.d.ts +11 -0
- package/dist/types/src/vscode.d.ts +13 -0
- package/dist/types/vscode.d.ts +13 -0
- package/package.json +49 -0
- package/src/config-schema.ts +51 -0
- package/src/config-template.yml +78 -0
- package/src/config.ts +1105 -0
- package/src/index.ts +17 -0
- package/src/merge.ts +47 -0
- package/src/vscode.ts +96 -0
- package/src/yaml.d.ts +9 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { promises as fs } from "fs";
|
|
3
|
+
import { stringify, parse, parseDocument, isMap } from "yaml";
|
|
4
|
+
import { ZodError } from "zod";
|
|
5
|
+
import { fromZodError } from "zod-validation-error";
|
|
6
|
+
import { minimatch } from "minimatch";
|
|
7
|
+
import { glob } from "glob";
|
|
8
|
+
import { HerbConfigSchema } from "./config-schema.js";
|
|
9
|
+
import { deepMerge } from "./merge.js";
|
|
10
|
+
import packageJson from "../package.json";
|
|
11
|
+
import configTemplate from "./config-template.yml";
|
|
12
|
+
const DEFAULT_VERSION = packageJson.version;
|
|
13
|
+
export class Config {
|
|
14
|
+
static configPath = ".herb.yml";
|
|
15
|
+
static PROJECT_INDICATORS = [
|
|
16
|
+
'.git',
|
|
17
|
+
'Gemfile',
|
|
18
|
+
'package.json',
|
|
19
|
+
'Rakefile',
|
|
20
|
+
'README.md',
|
|
21
|
+
'*.gemspec',
|
|
22
|
+
'config/application.rb'
|
|
23
|
+
];
|
|
24
|
+
path;
|
|
25
|
+
config;
|
|
26
|
+
constructor(projectPath, config) {
|
|
27
|
+
this.path = Config.configPathFromProjectPath(projectPath);
|
|
28
|
+
this.config = config;
|
|
29
|
+
}
|
|
30
|
+
get projectPath() {
|
|
31
|
+
return path.dirname(this.path);
|
|
32
|
+
}
|
|
33
|
+
get version() {
|
|
34
|
+
return this.config.version;
|
|
35
|
+
}
|
|
36
|
+
get options() {
|
|
37
|
+
return {
|
|
38
|
+
files: this.config.files,
|
|
39
|
+
linter: this.config.linter,
|
|
40
|
+
formatter: this.config.formatter
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
get linter() {
|
|
44
|
+
return this.config.linter;
|
|
45
|
+
}
|
|
46
|
+
get formatter() {
|
|
47
|
+
return this.config.formatter;
|
|
48
|
+
}
|
|
49
|
+
toJSON() {
|
|
50
|
+
return JSON.stringify(this.config, null, " ");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check if the linter is enabled.
|
|
54
|
+
* @returns true if linter is enabled (default), false if explicitly disabled
|
|
55
|
+
*/
|
|
56
|
+
get isLinterEnabled() {
|
|
57
|
+
return this.config.linter?.enabled ?? Config.getDefaultConfig().linter?.enabled ?? true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if the formatter is enabled.
|
|
61
|
+
* @returns true if formatter is enabled (default), false if explicitly disabled
|
|
62
|
+
*/
|
|
63
|
+
get isFormatterEnabled() {
|
|
64
|
+
return this.config.formatter?.enabled ?? Config.getDefaultConfig().formatter?.enabled ?? true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Check if a specific rule is disabled.
|
|
68
|
+
* @param ruleName - The name of the rule to check
|
|
69
|
+
* @returns true if the rule is explicitly disabled, false otherwise
|
|
70
|
+
*/
|
|
71
|
+
isRuleDisabled(ruleName) {
|
|
72
|
+
return this.config.linter?.rules?.[ruleName]?.enabled === false;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if a specific rule is enabled.
|
|
76
|
+
* @param ruleName - The name of the rule to check
|
|
77
|
+
* @returns true if the rule is enabled, false otherwise
|
|
78
|
+
*/
|
|
79
|
+
isRuleEnabled(ruleName) {
|
|
80
|
+
return !this.isRuleDisabled(ruleName);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get the files configuration for a specific tool.
|
|
84
|
+
* Tool-specific file config takes precedence over top-level config.
|
|
85
|
+
* Include patterns are additive (defaults are already merged in this.config).
|
|
86
|
+
* @param tool - The tool to get files config for ('linter' or 'formatter')
|
|
87
|
+
* @returns The merged files configuration
|
|
88
|
+
*/
|
|
89
|
+
getFilesConfigForTool(tool) {
|
|
90
|
+
const toolConfig = tool === 'linter' ? this.config.linter : this.config.formatter;
|
|
91
|
+
const topLevelFiles = this.config.files || {};
|
|
92
|
+
const topLevelInclude = topLevelFiles.include || [];
|
|
93
|
+
const toolInclude = toolConfig?.include || [];
|
|
94
|
+
const include = [...topLevelInclude, ...toolInclude];
|
|
95
|
+
const exclude = toolConfig?.exclude || topLevelFiles.exclude || [];
|
|
96
|
+
return {
|
|
97
|
+
include,
|
|
98
|
+
exclude
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Get the files configuration for the linter.
|
|
103
|
+
* Linter-specific file config takes precedence over top-level config.
|
|
104
|
+
* @returns The merged files configuration for linter
|
|
105
|
+
*/
|
|
106
|
+
get filesConfigForLinter() {
|
|
107
|
+
return this.getFilesConfigForTool('linter');
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Get the files configuration for the formatter.
|
|
111
|
+
* Formatter-specific file config takes precedence over top-level config.
|
|
112
|
+
* @returns The merged files configuration for formatter
|
|
113
|
+
*/
|
|
114
|
+
get filesConfigForFormatter() {
|
|
115
|
+
return this.getFilesConfigForTool('formatter');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Find files for a specific tool based on its configuration.
|
|
119
|
+
* Uses include patterns from config, applies exclude patterns.
|
|
120
|
+
* @param tool - The tool to find files for ('linter' or 'formatter')
|
|
121
|
+
* @param cwd - The directory to search from (defaults to project path)
|
|
122
|
+
* @returns Promise resolving to array of absolute file paths
|
|
123
|
+
*/
|
|
124
|
+
async findFilesForTool(tool, cwd) {
|
|
125
|
+
const searchDir = cwd || path.dirname(this.path);
|
|
126
|
+
const filesConfig = this.getFilesConfigForTool(tool);
|
|
127
|
+
const patterns = filesConfig.include || [];
|
|
128
|
+
if (patterns.length === 0) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
return await glob(patterns, {
|
|
132
|
+
cwd: searchDir,
|
|
133
|
+
absolute: true,
|
|
134
|
+
nodir: true,
|
|
135
|
+
ignore: filesConfig.exclude || []
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Find files for the linter based on linter configuration.
|
|
140
|
+
* @param cwd - The directory to search from (defaults to project path)
|
|
141
|
+
* @returns Promise resolving to array of absolute file paths
|
|
142
|
+
*/
|
|
143
|
+
async findFilesForLinter(cwd) {
|
|
144
|
+
return this.findFilesForTool('linter', cwd);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Find files for the formatter based on formatter configuration.
|
|
148
|
+
* @param cwd - The directory to search from (defaults to project path)
|
|
149
|
+
* @returns Promise resolving to array of absolute file paths
|
|
150
|
+
*/
|
|
151
|
+
async findFilesForFormatter(cwd) {
|
|
152
|
+
return this.findFilesForTool('formatter', cwd);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Check if a file path is excluded by glob patterns.
|
|
156
|
+
* @param filePath - The file path to check
|
|
157
|
+
* @param excludePatterns - Array of glob patterns to check against
|
|
158
|
+
* @returns true if the path matches any exclude pattern
|
|
159
|
+
*/
|
|
160
|
+
isPathExcluded(filePath, excludePatterns) {
|
|
161
|
+
if (!excludePatterns || excludePatterns.length === 0) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return excludePatterns.some(pattern => minimatch(filePath, pattern));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Check if a file path matches any of the include patterns.
|
|
168
|
+
* @param filePath - The file path to check
|
|
169
|
+
* @param includePatterns - Array of glob patterns to check against
|
|
170
|
+
* @returns true if the path matches any include pattern, or true if no patterns specified
|
|
171
|
+
*/
|
|
172
|
+
isPathIncluded(filePath, includePatterns) {
|
|
173
|
+
if (!includePatterns || includePatterns.length === 0) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
return includePatterns.some(pattern => minimatch(filePath, pattern));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Check if a tool (linter or formatter) is enabled for a specific file path.
|
|
180
|
+
* Respects both the tool's enabled state and its exclude patterns.
|
|
181
|
+
* @param filePath - The file path to check
|
|
182
|
+
* @param tool - The tool to check ('linter' or 'formatter')
|
|
183
|
+
* @returns true if the tool is enabled for this path
|
|
184
|
+
*/
|
|
185
|
+
isEnabledForPath(filePath, tool) {
|
|
186
|
+
const isEnabled = tool === 'linter' ? this.isLinterEnabled : this.isFormatterEnabled;
|
|
187
|
+
if (!isEnabled) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
const toolConfig = tool === 'linter' ? this.config.linter : this.config.formatter;
|
|
191
|
+
const excludePatterns = toolConfig?.exclude || [];
|
|
192
|
+
return !this.isPathExcluded(filePath, excludePatterns);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check if the linter is enabled for a specific file path.
|
|
196
|
+
* Respects both linter.enabled and linter.exclude patterns.
|
|
197
|
+
* @param filePath - The file path to check
|
|
198
|
+
* @returns true if the linter is enabled for this path
|
|
199
|
+
*/
|
|
200
|
+
isLinterEnabledForPath(filePath) {
|
|
201
|
+
return this.isEnabledForPath(filePath, 'linter');
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Check if the formatter is enabled for a specific file path.
|
|
205
|
+
* Respects both formatter.enabled and formatter.exclude patterns.
|
|
206
|
+
* @param filePath - The file path to check
|
|
207
|
+
* @returns true if the formatter is enabled for this path
|
|
208
|
+
*/
|
|
209
|
+
isFormatterEnabledForPath(filePath) {
|
|
210
|
+
return this.isEnabledForPath(filePath, 'formatter');
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Check if a specific rule is enabled for a specific file path.
|
|
214
|
+
* Respects linter.enabled, linter.exclude, rule.enabled, rule.include, rule.only, and rule.exclude patterns.
|
|
215
|
+
*
|
|
216
|
+
* Pattern precedence:
|
|
217
|
+
* - If rule.only is specified: Only files matching 'only' patterns (ignores all 'include' patterns)
|
|
218
|
+
* - If rule.only is NOT specified: Files matching 'include' patterns (if specified, additive)
|
|
219
|
+
* - rule.exclude is always applied regardless of 'only' or 'include'
|
|
220
|
+
*
|
|
221
|
+
* @param ruleName - The name of the rule to check
|
|
222
|
+
* @param filePath - The file path to check
|
|
223
|
+
* @returns true if the rule is enabled for this path
|
|
224
|
+
*/
|
|
225
|
+
isRuleEnabledForPath(ruleName, filePath) {
|
|
226
|
+
if (!this.isLinterEnabledForPath(filePath)) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (this.isRuleDisabled(ruleName)) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
const ruleConfig = this.config.linter?.rules?.[ruleName];
|
|
233
|
+
const ruleOnlyPatterns = ruleConfig?.only || [];
|
|
234
|
+
const ruleIncludePatterns = ruleConfig?.include || [];
|
|
235
|
+
const ruleExcludePatterns = ruleConfig?.exclude || [];
|
|
236
|
+
if (ruleOnlyPatterns.length > 0) {
|
|
237
|
+
if (!this.isPathIncluded(filePath, ruleOnlyPatterns)) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else if (ruleIncludePatterns.length > 0) {
|
|
242
|
+
if (!this.isPathIncluded(filePath, ruleIncludePatterns)) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return !this.isPathExcluded(filePath, ruleExcludePatterns);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Apply configured severity overrides to a lint offense.
|
|
250
|
+
* Returns the configured severity if set, otherwise returns the original severity.
|
|
251
|
+
*/
|
|
252
|
+
getConfiguredSeverity(ruleName, defaultSeverity) {
|
|
253
|
+
const ruleConfig = this.config.linter?.rules?.[ruleName];
|
|
254
|
+
if (ruleConfig && ruleConfig.severity) {
|
|
255
|
+
return ruleConfig.severity;
|
|
256
|
+
}
|
|
257
|
+
return defaultSeverity;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Apply severity overrides from config to an array of offenses.
|
|
261
|
+
* Each offense must have a `rule` and `severity` property.
|
|
262
|
+
*/
|
|
263
|
+
applySeverityOverrides(offenses) {
|
|
264
|
+
if (!this.config.linter?.rules) {
|
|
265
|
+
return offenses;
|
|
266
|
+
}
|
|
267
|
+
return offenses.map(offense => {
|
|
268
|
+
const ruleConfig = this.config.linter?.rules?.[offense.rule];
|
|
269
|
+
if (ruleConfig && ruleConfig.severity) {
|
|
270
|
+
return { ...offense, severity: ruleConfig.severity };
|
|
271
|
+
}
|
|
272
|
+
return offense;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
static configPathFromProjectPath(projectPath) {
|
|
276
|
+
return path.join(projectPath, this.configPath);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Get the default file patterns that Herb recognizes.
|
|
280
|
+
* These are the default extensions/patterns used when no custom patterns are specified.
|
|
281
|
+
* @returns Array of glob patterns for HTML+ERB files
|
|
282
|
+
*/
|
|
283
|
+
static getDefaultFilePatterns() {
|
|
284
|
+
return this.getDefaultConfig().files?.include || [];
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Check if a .herb.yml config file exists at the given path.
|
|
288
|
+
*
|
|
289
|
+
* @param pathOrFile - Path to directory or explicit config file path
|
|
290
|
+
* @returns True if .herb.yml exists at the location, false otherwise
|
|
291
|
+
*/
|
|
292
|
+
static exists(pathOrFile) {
|
|
293
|
+
try {
|
|
294
|
+
let configPath;
|
|
295
|
+
if (pathOrFile.endsWith(this.configPath)) {
|
|
296
|
+
configPath = pathOrFile;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
configPath = this.configPathFromProjectPath(pathOrFile);
|
|
300
|
+
}
|
|
301
|
+
require('fs').statSync(configPath);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Read raw YAML content from a config file.
|
|
310
|
+
* Handles both explicit .herb.yml paths and directory paths.
|
|
311
|
+
*
|
|
312
|
+
* @param pathOrFile - Path to .herb.yml file or directory containing it
|
|
313
|
+
* @returns string - The raw YAML content
|
|
314
|
+
*/
|
|
315
|
+
static readRawYaml(pathOrFile) {
|
|
316
|
+
const configPath = pathOrFile.endsWith(this.configPath)
|
|
317
|
+
? pathOrFile
|
|
318
|
+
: this.configPathFromProjectPath(pathOrFile);
|
|
319
|
+
return require('fs').readFileSync(configPath, 'utf-8');
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Load Herb configuration from a file or directory
|
|
323
|
+
*
|
|
324
|
+
* This is the main entry point for loading configuration. It:
|
|
325
|
+
* 1. Discovers the config file by walking up the directory tree
|
|
326
|
+
* 2. Reads and validates the config
|
|
327
|
+
* 3. Merges with defaults for a fully resolved config
|
|
328
|
+
* 4. Auto-creates default config if createIfMissing option is true
|
|
329
|
+
* 5. Prints informative messages to console
|
|
330
|
+
*
|
|
331
|
+
* @param pathOrFile - File path, directory path, or any path to start search from
|
|
332
|
+
* @param options - Loading options
|
|
333
|
+
* @returns Promise<Config> - Fully resolved config instance
|
|
334
|
+
*/
|
|
335
|
+
static async load(pathOrFile, options = {}) {
|
|
336
|
+
const { silent = false, version = DEFAULT_VERSION, createIfMissing = false, exitOnError = false } = options;
|
|
337
|
+
try {
|
|
338
|
+
if (pathOrFile.endsWith(this.configPath)) {
|
|
339
|
+
return await this.loadFromExplicitPath(pathOrFile, silent, version, exitOnError);
|
|
340
|
+
}
|
|
341
|
+
const { configPath, projectRoot } = await this.findConfigFile(pathOrFile);
|
|
342
|
+
if (configPath) {
|
|
343
|
+
return await this.loadFromPath(configPath, projectRoot, silent, version, exitOnError);
|
|
344
|
+
}
|
|
345
|
+
else if (createIfMissing) {
|
|
346
|
+
return await this.createDefaultConfig(projectRoot, silent, version);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const defaults = this.getDefaultConfig(version);
|
|
350
|
+
return new Config(projectRoot, defaults);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
if (error instanceof Error) {
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
throw new Error(`Failed to load Herb configuration: ${error}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Load config for editor/language server use (silent mode, no file creation).
|
|
362
|
+
* This is a convenience method for the common pattern used in editors.
|
|
363
|
+
*
|
|
364
|
+
* @param pathOrFile - Directory path or explicit .herb.yml file path
|
|
365
|
+
* @param version - Optional version string (defaults to package version)
|
|
366
|
+
* @returns Config instance or throws on errors
|
|
367
|
+
*/
|
|
368
|
+
static async loadForEditor(pathOrFile, version) {
|
|
369
|
+
return await this.load(pathOrFile, {
|
|
370
|
+
silent: true,
|
|
371
|
+
version,
|
|
372
|
+
createIfMissing: false,
|
|
373
|
+
exitOnError: false
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Load config for CLI use (may create file, show errors).
|
|
378
|
+
* This is a convenience method for the common pattern used in CLI tools.
|
|
379
|
+
*
|
|
380
|
+
* @param pathOrFile - Directory path or explicit .herb.yml file path
|
|
381
|
+
* @param version - Optional version string (defaults to package version)
|
|
382
|
+
* @param createIfMissing - Whether to create config if missing (default: false)
|
|
383
|
+
* @returns Config instance or throws on errors
|
|
384
|
+
*/
|
|
385
|
+
static async loadForCLI(pathOrFile, version, createIfMissing = false) {
|
|
386
|
+
return await this.load(pathOrFile, {
|
|
387
|
+
silent: false,
|
|
388
|
+
version,
|
|
389
|
+
createIfMissing,
|
|
390
|
+
exitOnError: false
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Mutate an existing config file by reading it, validating, merging with a mutation, and writing back.
|
|
395
|
+
* This preserves the user's YAML file structure and only writes what's explicitly configured.
|
|
396
|
+
*
|
|
397
|
+
* @param configPath - Path to the .herb.yml file
|
|
398
|
+
* @param mutation - Partial config to merge (e.g., { linter: { rules: { "rule-name": { enabled: false } } } })
|
|
399
|
+
* @returns Promise<void>
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* // Disable a rule in .herb.yml
|
|
403
|
+
* await Config.mutateConfigFile('/path/to/.herb.yml', {
|
|
404
|
+
* linter: {
|
|
405
|
+
* rules: {
|
|
406
|
+
* 'html-img-require-alt': { enabled: false }
|
|
407
|
+
* }
|
|
408
|
+
* }
|
|
409
|
+
* })
|
|
410
|
+
*/
|
|
411
|
+
static async mutateConfigFile(configPath, mutation) {
|
|
412
|
+
let yamlContent;
|
|
413
|
+
try {
|
|
414
|
+
const existingContent = await fs.readFile(configPath, 'utf-8');
|
|
415
|
+
if (Object.keys(mutation).length > 0) {
|
|
416
|
+
const document = parseDocument(existingContent);
|
|
417
|
+
const validation = HerbConfigSchema.safeParse(document.toJSON());
|
|
418
|
+
if (!validation.success) {
|
|
419
|
+
const readableError = fromZodError(validation.error);
|
|
420
|
+
throw new Error(`Invalid config file at ${configPath}: ${readableError.message}`);
|
|
421
|
+
}
|
|
422
|
+
if (document.contents) {
|
|
423
|
+
this.applyMutationToDocument(document.contents, mutation);
|
|
424
|
+
if (!document.get('version')) {
|
|
425
|
+
document.set('version', DEFAULT_VERSION);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
yamlContent = document.toString();
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
yamlContent = existingContent;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
if (error.code !== 'ENOENT') {
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
if (Object.keys(mutation).length === 0) {
|
|
439
|
+
yamlContent = configTemplate.replace(/^version:\s*[\d.]+$/m, `version: ${DEFAULT_VERSION}`);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
const defaults = this.getDefaultConfig(DEFAULT_VERSION);
|
|
443
|
+
const updated = deepMerge(defaults, mutation);
|
|
444
|
+
yamlContent = stringify(updated, {
|
|
445
|
+
indent: 2,
|
|
446
|
+
lineWidth: 0,
|
|
447
|
+
blockQuote: 'literal'
|
|
448
|
+
});
|
|
449
|
+
yamlContent = this.addYamlSpacing(yamlContent);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
await fs.writeFile(configPath, yamlContent, 'utf-8');
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Apply mutation to YAML content and return the mutated string (synchronous)
|
|
456
|
+
* Useful for code actions and other scenarios where you need the mutated content
|
|
457
|
+
* without writing to disk.
|
|
458
|
+
*
|
|
459
|
+
* @param yamlContent - The original YAML content (with comments)
|
|
460
|
+
* @param mutation - The mutation to apply
|
|
461
|
+
* @returns The mutated YAML content (with comments preserved)
|
|
462
|
+
*/
|
|
463
|
+
static applyMutationToYamlString(yamlContent, mutation) {
|
|
464
|
+
const document = parseDocument(yamlContent);
|
|
465
|
+
if (document.contents) {
|
|
466
|
+
this.applyMutationToDocument(document.contents, mutation);
|
|
467
|
+
}
|
|
468
|
+
return document.toString();
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Create a new config file content with a mutation applied
|
|
472
|
+
* Uses the default template with comments and applies the mutation
|
|
473
|
+
*
|
|
474
|
+
* @param mutation - The mutation to apply to the default config
|
|
475
|
+
* @param version - The version to use (defaults to package version)
|
|
476
|
+
* @returns The config file content as a YAML string
|
|
477
|
+
*/
|
|
478
|
+
static createConfigYamlString(mutation, version = DEFAULT_VERSION) {
|
|
479
|
+
let yamlContent = configTemplate.replace(/^version:\s*[\d.]+$/m, `version: ${version}`);
|
|
480
|
+
if (Object.keys(mutation).length > 0) {
|
|
481
|
+
yamlContent = this.applyMutationToYamlString(yamlContent, mutation);
|
|
482
|
+
}
|
|
483
|
+
return yamlContent;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Apply mutation to a YAML document while preserving comments
|
|
487
|
+
* Works recursively to handle nested objects
|
|
488
|
+
*/
|
|
489
|
+
static applyMutationToDocument(node, mutation) {
|
|
490
|
+
for (const key in mutation) {
|
|
491
|
+
const value = mutation[key];
|
|
492
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
493
|
+
let nestedNode = node.get(key);
|
|
494
|
+
if (!nestedNode || !isMap(nestedNode)) {
|
|
495
|
+
node.set(key, {});
|
|
496
|
+
nestedNode = node.get(key);
|
|
497
|
+
}
|
|
498
|
+
if (isMap(nestedNode)) {
|
|
499
|
+
this.applyMutationToDocument(nestedNode, value);
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
node.set(key, value);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
node.set(key, value);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Add spacing between top-level keys and nested rule keys in YAML
|
|
512
|
+
*/
|
|
513
|
+
static addYamlSpacing(yaml) {
|
|
514
|
+
const lines = yaml.split('\n');
|
|
515
|
+
const result = [];
|
|
516
|
+
for (let i = 0; i < lines.length; i++) {
|
|
517
|
+
const line = lines[i];
|
|
518
|
+
const prevLine = lines[i - 1];
|
|
519
|
+
if (i > 0 && /^[a-z][\w-]*:/.test(line) && prevLine !== undefined) {
|
|
520
|
+
if (!prevLine.trim().startsWith('version:') && prevLine.trim() !== '') {
|
|
521
|
+
result.push('');
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (/^ [a-z][\w-]*:/.test(line) && prevLine && /^ /.test(prevLine)) {
|
|
525
|
+
result.push('');
|
|
526
|
+
}
|
|
527
|
+
result.push(line);
|
|
528
|
+
}
|
|
529
|
+
return result.join('\n');
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Create a Config instance from a partial config object
|
|
533
|
+
*
|
|
534
|
+
* Useful for testing and programmatic config creation.
|
|
535
|
+
* Deep merges the partial config with defaults.
|
|
536
|
+
*
|
|
537
|
+
* @param partial - Partial config object
|
|
538
|
+
* @param options - Options including projectPath and version
|
|
539
|
+
* @returns Config instance with fully resolved config
|
|
540
|
+
*/
|
|
541
|
+
static fromObject(partial, options = {}) {
|
|
542
|
+
const { projectPath = process.cwd(), version = DEFAULT_VERSION } = options;
|
|
543
|
+
const defaults = this.getDefaultConfig(version);
|
|
544
|
+
const merged = deepMerge(defaults, partial);
|
|
545
|
+
try {
|
|
546
|
+
HerbConfigSchema.parse(merged);
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
if (error instanceof ZodError) {
|
|
550
|
+
const validationError = fromZodError(error, {
|
|
551
|
+
prefix: "Configuration validation error",
|
|
552
|
+
});
|
|
553
|
+
throw new Error(validationError.toString());
|
|
554
|
+
}
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
return new Config(projectPath, merged);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Find config file by walking up directory tree
|
|
561
|
+
*
|
|
562
|
+
* @param startPath - Path to start searching from (file or directory)
|
|
563
|
+
* @returns Object with configPath (if found) and projectRoot
|
|
564
|
+
*/
|
|
565
|
+
static async findConfigFile(startPath) {
|
|
566
|
+
let currentPath = path.resolve(startPath);
|
|
567
|
+
try {
|
|
568
|
+
const stats = await fs.stat(currentPath);
|
|
569
|
+
if (stats.isFile()) {
|
|
570
|
+
currentPath = path.dirname(currentPath);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
currentPath = path.resolve(process.cwd());
|
|
575
|
+
}
|
|
576
|
+
while (true) {
|
|
577
|
+
const configPath = path.join(currentPath, this.configPath);
|
|
578
|
+
try {
|
|
579
|
+
await fs.access(configPath);
|
|
580
|
+
return { configPath, projectRoot: currentPath };
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
// Config not in this directory, continue
|
|
584
|
+
}
|
|
585
|
+
const isProjectRoot = await this.isProjectRoot(currentPath);
|
|
586
|
+
if (isProjectRoot) {
|
|
587
|
+
return { configPath: null, projectRoot: currentPath };
|
|
588
|
+
}
|
|
589
|
+
const parentPath = path.dirname(currentPath);
|
|
590
|
+
if (parentPath === currentPath) {
|
|
591
|
+
return { configPath: null, projectRoot: process.cwd() };
|
|
592
|
+
}
|
|
593
|
+
currentPath = parentPath;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Check if a directory is a project root
|
|
598
|
+
*/
|
|
599
|
+
static async isProjectRoot(dirPath) {
|
|
600
|
+
for (const indicator of this.PROJECT_INDICATORS) {
|
|
601
|
+
try {
|
|
602
|
+
await fs.access(path.join(dirPath, indicator));
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// Indicator not found, continue checking
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Load config from explicit path (from --config-file argument)
|
|
613
|
+
*/
|
|
614
|
+
static async loadFromExplicitPath(configPath, silent, version, exitOnError) {
|
|
615
|
+
const resolvedPath = path.resolve(configPath);
|
|
616
|
+
try {
|
|
617
|
+
const stats = await fs.stat(resolvedPath);
|
|
618
|
+
if (!stats.isFile()) {
|
|
619
|
+
if (exitOnError) {
|
|
620
|
+
console.error(`\n✗ Config path is not a file: ${resolvedPath}\n`);
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
throw new Error(`Config path is not a file: ${resolvedPath}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
if (error.code === 'ENOENT') {
|
|
630
|
+
if (exitOnError) {
|
|
631
|
+
console.error(`\n✗ Config file not found: ${resolvedPath}\n`);
|
|
632
|
+
process.exit(1);
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
throw new Error(`Config file not found: ${resolvedPath}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
const projectRoot = path.dirname(resolvedPath);
|
|
641
|
+
const config = await this.readAndValidateConfig(resolvedPath, projectRoot, version, exitOnError);
|
|
642
|
+
if (!silent) {
|
|
643
|
+
console.error(`✓ Using Herb config file at ${resolvedPath}`);
|
|
644
|
+
}
|
|
645
|
+
return config;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Load config from discovered path
|
|
649
|
+
*/
|
|
650
|
+
static async loadFromPath(configPath, projectRoot, silent, version, exitOnError) {
|
|
651
|
+
const config = await this.readAndValidateConfig(configPath, projectRoot, version, exitOnError);
|
|
652
|
+
if (!silent) {
|
|
653
|
+
console.error(`✓ Using Herb config file at ${configPath}`);
|
|
654
|
+
}
|
|
655
|
+
return config;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Create default config at project root
|
|
659
|
+
*/
|
|
660
|
+
static async createDefaultConfig(projectRoot, silent, version) {
|
|
661
|
+
const yamlPath = path.join(projectRoot, '.herb.yaml');
|
|
662
|
+
try {
|
|
663
|
+
await fs.access(yamlPath);
|
|
664
|
+
console.error(`\n✗ Found \`.herb.yaml\` file at ${yamlPath}`);
|
|
665
|
+
console.error(` Please rename it to \`.herb.yml\`\n`);
|
|
666
|
+
process.exit(1);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// File doesn't exist
|
|
670
|
+
}
|
|
671
|
+
const configPath = this.configPathFromProjectPath(projectRoot);
|
|
672
|
+
try {
|
|
673
|
+
await this.mutateConfigFile(configPath, {});
|
|
674
|
+
if (!silent) {
|
|
675
|
+
console.error(`✓ Created default configuration at ${configPath}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
if (!silent) {
|
|
680
|
+
console.error(`⚠ Could not create config file at ${configPath}, using defaults in-memory`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const defaults = this.getDefaultConfig(version);
|
|
684
|
+
return new Config(projectRoot, defaults);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Validate config text without loading or exiting process
|
|
688
|
+
* Used by language servers to show diagnostics
|
|
689
|
+
* Returns empty array if valid, array of errors/warnings if invalid
|
|
690
|
+
*/
|
|
691
|
+
static async validateConfigText(text, options) {
|
|
692
|
+
const errors = [];
|
|
693
|
+
const version = options?.version;
|
|
694
|
+
const projectPath = options?.projectPath;
|
|
695
|
+
if (projectPath) {
|
|
696
|
+
try {
|
|
697
|
+
const yamlPath = path.join(projectPath, '.herb.yaml');
|
|
698
|
+
await fs.access(yamlPath);
|
|
699
|
+
errors.push({
|
|
700
|
+
message: 'Found .herb.yaml file. Please rename to .herb.yml',
|
|
701
|
+
path: [],
|
|
702
|
+
code: 'wrong_file_extension',
|
|
703
|
+
severity: 'warning',
|
|
704
|
+
line: 0,
|
|
705
|
+
column: 0
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
// .herb.yaml doesn't exist
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
let parsed;
|
|
713
|
+
try {
|
|
714
|
+
parsed = parse(text);
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
let line;
|
|
718
|
+
let column;
|
|
719
|
+
const errorMatch = error.message?.match(/at line (\d+), column (\d+)/);
|
|
720
|
+
if (errorMatch) {
|
|
721
|
+
line = parseInt(errorMatch[1]) - 1;
|
|
722
|
+
column = parseInt(errorMatch[2]) - 1;
|
|
723
|
+
}
|
|
724
|
+
errors.push({
|
|
725
|
+
message: error.message || 'Invalid YAML syntax',
|
|
726
|
+
path: [],
|
|
727
|
+
code: 'yaml_syntax_error',
|
|
728
|
+
severity: 'error',
|
|
729
|
+
line,
|
|
730
|
+
column
|
|
731
|
+
});
|
|
732
|
+
return errors;
|
|
733
|
+
}
|
|
734
|
+
if (parsed === null || parsed === undefined) {
|
|
735
|
+
parsed = {};
|
|
736
|
+
}
|
|
737
|
+
if (version && parsed.version && parsed.version !== version) {
|
|
738
|
+
errors.push({
|
|
739
|
+
message: `Configuration version (${parsed.version}) doesn't match current version (${version}). Consider updating your configuration.`,
|
|
740
|
+
path: ['version'],
|
|
741
|
+
code: 'version_mismatch',
|
|
742
|
+
severity: 'warning'
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
if (!parsed.version) {
|
|
746
|
+
parsed.version = version || '0.0.0';
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
HerbConfigSchema.parse(parsed);
|
|
750
|
+
}
|
|
751
|
+
catch (error) {
|
|
752
|
+
if (error instanceof ZodError) {
|
|
753
|
+
errors.push(...error.issues.map(issue => ({
|
|
754
|
+
message: issue.message,
|
|
755
|
+
path: issue.path,
|
|
756
|
+
code: issue.code,
|
|
757
|
+
severity: 'error'
|
|
758
|
+
})));
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return errors;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Read, parse, and validate config file
|
|
765
|
+
*/
|
|
766
|
+
static async readAndValidateConfig(configPath, projectRoot, version, exitOnError = false) {
|
|
767
|
+
try {
|
|
768
|
+
const content = await fs.readFile(configPath, "utf8");
|
|
769
|
+
let parsed;
|
|
770
|
+
try {
|
|
771
|
+
parsed = parse(content);
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
if (exitOnError) {
|
|
775
|
+
console.error(`\n✗ Invalid YAML syntax in ${configPath}`);
|
|
776
|
+
if (error instanceof Error) {
|
|
777
|
+
console.error(` ${error.message}\n`);
|
|
778
|
+
}
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
throw new Error(`Invalid YAML syntax in ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (parsed === null || parsed === undefined) {
|
|
786
|
+
parsed = {};
|
|
787
|
+
}
|
|
788
|
+
if (!parsed.version) {
|
|
789
|
+
parsed.version = version;
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
HerbConfigSchema.parse(parsed);
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
if (error instanceof ZodError) {
|
|
796
|
+
const validationError = fromZodError(error, {
|
|
797
|
+
prefix: `Configuration errors in ${configPath}`,
|
|
798
|
+
});
|
|
799
|
+
if (exitOnError) {
|
|
800
|
+
console.error(`\n✗ ${validationError.toString()}\n`);
|
|
801
|
+
process.exit(1);
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
throw new Error(validationError.toString());
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
throw error;
|
|
808
|
+
}
|
|
809
|
+
if (parsed.version && parsed.version !== version) {
|
|
810
|
+
console.error(`\n⚠️ Configuration version mismatch in ${configPath}`);
|
|
811
|
+
console.error(` Config version: ${parsed.version}`);
|
|
812
|
+
console.error(` Current version: ${version}`);
|
|
813
|
+
console.error(` Consider updating your .herb.yml file.\n`);
|
|
814
|
+
}
|
|
815
|
+
const defaults = this.getDefaultConfig(version);
|
|
816
|
+
const resolved = deepMerge(defaults, parsed);
|
|
817
|
+
resolved.version = version;
|
|
818
|
+
return new Config(projectRoot, resolved);
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Get default configuration object
|
|
826
|
+
*/
|
|
827
|
+
static getDefaultConfig(version = DEFAULT_VERSION) {
|
|
828
|
+
return {
|
|
829
|
+
version,
|
|
830
|
+
files: {
|
|
831
|
+
include: [
|
|
832
|
+
'**/*.html',
|
|
833
|
+
'**/*.rhtml',
|
|
834
|
+
'**/*.html.erb',
|
|
835
|
+
'**/*.html+*.erb',
|
|
836
|
+
'**/*.turbo_stream.erb'
|
|
837
|
+
],
|
|
838
|
+
exclude: [
|
|
839
|
+
'node_modules/**/*',
|
|
840
|
+
'vendor/bundle/**/*',
|
|
841
|
+
'coverage/**/*',
|
|
842
|
+
]
|
|
843
|
+
},
|
|
844
|
+
linter: {
|
|
845
|
+
enabled: true,
|
|
846
|
+
rules: {}
|
|
847
|
+
},
|
|
848
|
+
formatter: {
|
|
849
|
+
enabled: true,
|
|
850
|
+
indentWidth: 2,
|
|
851
|
+
maxLineLength: 80
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
//# sourceMappingURL=config.js.map
|