@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.
@@ -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