gitacross 1.0.0__py3-none-any.whl

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.
gitacross/linter.py ADDED
@@ -0,0 +1,1105 @@
1
+ """Config linter for GitAcross.
2
+
3
+ Validates YAML syntax, detects invalid or misplaced configurations,
4
+ and flags redundant options that match default values.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ import io
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Union
12
+ import yaml
13
+
14
+
15
+ class LintSeverity(Enum):
16
+ ERROR = "ERROR"
17
+ WARNING = "WARNING"
18
+ REDUNDANT = "REDUNDANT"
19
+
20
+
21
+ @dataclass
22
+ class LintIssue:
23
+ severity: LintSeverity
24
+ message: str
25
+ project: Optional[str] = None
26
+ key: Optional[str] = None
27
+
28
+ def __str__(self) -> str:
29
+ prefix = f"[{self.severity.value}]"
30
+ if self.project:
31
+ return f" {prefix} (project '{self.project}'): {self.message}"
32
+ return f" {prefix} {self.message}"
33
+
34
+
35
+ @dataclass
36
+ class LintReport:
37
+ issues: List[LintIssue]
38
+
39
+ @property
40
+ def errors(self) -> List[LintIssue]:
41
+ return [i for i in self.issues if i.severity == LintSeverity.ERROR]
42
+
43
+ @property
44
+ def warnings(self) -> List[LintIssue]:
45
+ return [i for i in self.issues if i.severity == LintSeverity.WARNING]
46
+
47
+ @property
48
+ def redundant(self) -> List[LintIssue]:
49
+ return [i for i in self.issues if i.severity == LintSeverity.REDUNDANT]
50
+
51
+ @property
52
+ def is_valid(self) -> bool:
53
+ return len(self.errors) == 0
54
+
55
+ def format_text(self) -> str:
56
+ out = []
57
+ # Group issues by project (None first)
58
+ by_proj: Dict[Optional[str], List[LintIssue]] = {}
59
+ for issue in self.issues:
60
+ by_proj.setdefault(issue.project, []).append(issue)
61
+
62
+ if None in by_proj:
63
+ for issue in by_proj[None]:
64
+ out.append(f"[{issue.severity.value}] {issue.message}")
65
+
66
+ for proj, issues in by_proj.items():
67
+ if proj is None:
68
+ continue
69
+ out.append(f"\nProject '{proj}':")
70
+ for issue in issues:
71
+ out.append(f" [{issue.severity.value}] {issue.message}")
72
+
73
+ summary = f"\nSummary: {len(self.errors)} error(s), {len(self.warnings)} warning(s), {len(self.redundant)} redundant option(s)."
74
+ out.append(summary)
75
+ return "\n".join(out)
76
+
77
+
78
+ _VALID_PROJECT_KEYS = {
79
+ "name",
80
+ "enabled",
81
+ "source",
82
+ "target",
83
+ "renderer",
84
+ "retry",
85
+ "preserve_description",
86
+ "preserve_release_description",
87
+ "sync_assets",
88
+ "preserve_assets",
89
+ "include_assets",
90
+ "stream_assets",
91
+ }
92
+
93
+ _KNOWN_SOURCE_KEYS = {
94
+ "mode": "source",
95
+ "sync_from": "source",
96
+ "include_prereleases": "source",
97
+ "include_drafts": "source",
98
+ "tag_pattern": "source",
99
+ }
100
+
101
+ _KNOWN_ENDPOINT_KEYS = {
102
+ "repo": "source or target",
103
+ "api": "source or target",
104
+ "token": "source or target",
105
+ "branch": "source or target",
106
+ }
107
+
108
+ _VALID_SOURCE_KEYS_REMOTE = {
109
+ "type",
110
+ "repo",
111
+ "api",
112
+ "token",
113
+ "mode",
114
+ "branch",
115
+ "include_prereleases",
116
+ "include_drafts",
117
+ "sync_from",
118
+ }
119
+
120
+ _VALID_SOURCE_KEYS_LOCAL = {
121
+ "type",
122
+ "path",
123
+ "tag_pattern",
124
+ "sync_from",
125
+ }
126
+
127
+ _VALID_TARGET_KEYS_REMOTE = {
128
+ "type",
129
+ "repo",
130
+ "api",
131
+ "token",
132
+ "branch",
133
+ }
134
+
135
+ _VALID_TARGET_KEYS_LOCAL = {
136
+ "type",
137
+ "path",
138
+ "branch",
139
+ }
140
+
141
+
142
+ class ConfigLinter:
143
+ def __init__(self):
144
+ self.issues: List[LintIssue] = []
145
+
146
+ def _add(
147
+ self,
148
+ severity: LintSeverity,
149
+ message: str,
150
+ project: Optional[str] = None,
151
+ key: Optional[str] = None,
152
+ ):
153
+ self.issues.append(
154
+ LintIssue(severity=severity, message=message, project=project, key=key)
155
+ )
156
+
157
+ def lint_file(self, file_path: Union[str, Path]) -> LintReport:
158
+ path = Path(file_path)
159
+ if not path.exists():
160
+ self._add(LintSeverity.ERROR, f"File not found: {path}")
161
+ return LintReport(self.issues)
162
+
163
+ try:
164
+ content = path.read_text(encoding="utf-8")
165
+ except Exception as e:
166
+ self._add(LintSeverity.ERROR, f"Cannot read file '{path}': {e}")
167
+ return LintReport(self.issues)
168
+
169
+ return self.lint_yaml_string(content, filename=str(path))
170
+
171
+ def lint_yaml_string(
172
+ self, content: str, filename: Optional[str] = None
173
+ ) -> LintReport:
174
+ try:
175
+ data = yaml.safe_load(content)
176
+ except yaml.YAMLError as e:
177
+ mark = getattr(e, "problem_mark", None)
178
+ loc = (
179
+ f" (line {mark.line + 1}, column {mark.column + 1})" if mark else ""
180
+ )
181
+ self._add(
182
+ LintSeverity.ERROR,
183
+ f"YAML syntax error{loc}: {e.problem if hasattr(e, 'problem') and e.problem else str(e)}",
184
+ )
185
+ return LintReport(self.issues)
186
+
187
+ if data is None:
188
+ self._add(LintSeverity.WARNING, "Configuration file is empty")
189
+ return LintReport(self.issues)
190
+
191
+ projects_raw = []
192
+ if isinstance(data, list):
193
+ projects_raw = data
194
+ elif isinstance(data, dict):
195
+ if "projects" in data:
196
+ if not isinstance(data["projects"], list):
197
+ self._add(
198
+ LintSeverity.ERROR,
199
+ "'projects' key must contain a list of project configurations.",
200
+ )
201
+ return LintReport(self.issues)
202
+ projects_raw = data["projects"]
203
+ else:
204
+ self._add(
205
+ LintSeverity.ERROR,
206
+ "Top-level YAML object must be a list of projects or a map with a 'projects' list.",
207
+ )
208
+ return LintReport(self.issues)
209
+ else:
210
+ self._add(
211
+ LintSeverity.ERROR,
212
+ f"Invalid top-level YAML type: expected list or dict, got {type(data).__name__}.",
213
+ )
214
+ return LintReport(self.issues)
215
+
216
+ seen_names = set()
217
+ for idx, p in enumerate(projects_raw):
218
+ if not isinstance(p, dict):
219
+ self._add(
220
+ LintSeverity.ERROR,
221
+ f"Project at index {idx} is not a valid map/dict (got {type(p).__name__}).",
222
+ )
223
+ continue
224
+ self._lint_project(p, idx, seen_names)
225
+
226
+ return LintReport(self.issues)
227
+
228
+ def _lint_project(self, p: Dict[str, Any], idx: int, seen_names: set):
229
+ name = p.get("name")
230
+ if not name or not isinstance(name, str):
231
+ self._add(
232
+ LintSeverity.ERROR,
233
+ f"Project at index {idx} is missing required 'name' string field.",
234
+ )
235
+ p_name = f"<unnamed project #{idx}>"
236
+ else:
237
+ p_name = name
238
+ if name in seen_names:
239
+ self._add(
240
+ LintSeverity.ERROR,
241
+ f"Duplicate project name '{name}' found.",
242
+ project=p_name,
243
+ )
244
+ seen_names.add(name)
245
+
246
+ # 1. Project-level keys validation
247
+ for k in p:
248
+ if k in _VALID_PROJECT_KEYS:
249
+ continue
250
+ if k in _KNOWN_SOURCE_KEYS:
251
+ self._add(
252
+ LintSeverity.ERROR,
253
+ f"'{k}: {p[k]}' was specified at project level, but belongs under 'source:' (e.g. source.{k}: {p[k]}).",
254
+ project=p_name,
255
+ key=k,
256
+ )
257
+ elif k in _KNOWN_ENDPOINT_KEYS:
258
+ self._add(
259
+ LintSeverity.ERROR,
260
+ f"'{k}: {p[k]}' was specified at project level, but belongs under '{_KNOWN_ENDPOINT_KEYS[k]}:'.",
261
+ project=p_name,
262
+ key=k,
263
+ )
264
+ else:
265
+ self._add(
266
+ LintSeverity.WARNING,
267
+ f"Unrecognized project-level option '{k}'.",
268
+ project=p_name,
269
+ key=k,
270
+ )
271
+
272
+ # 2. Redundant project-level options
273
+ if "enabled" in p:
274
+ val = p["enabled"]
275
+ if not isinstance(val, bool):
276
+ self._add(
277
+ LintSeverity.ERROR,
278
+ f"'enabled' must be a boolean (got {type(val).__name__}).",
279
+ project=p_name,
280
+ key="enabled",
281
+ )
282
+ elif val is True:
283
+ self._add(
284
+ LintSeverity.REDUNDANT,
285
+ "'enabled: true' is redundant (default is true).",
286
+ project=p_name,
287
+ key="enabled",
288
+ )
289
+
290
+ if "preserve_description" in p:
291
+ val = p["preserve_description"]
292
+ if not isinstance(val, bool):
293
+ self._add(
294
+ LintSeverity.ERROR,
295
+ f"'preserve_description' must be a boolean (got {type(val).__name__}).",
296
+ project=p_name,
297
+ )
298
+ elif val is True:
299
+ self._add(
300
+ LintSeverity.REDUNDANT,
301
+ "'preserve_description: true' is redundant (default is true).",
302
+ project=p_name,
303
+ )
304
+
305
+ if "sync_assets" in p:
306
+ val = p["sync_assets"]
307
+ if isinstance(val, bool):
308
+ if val is False:
309
+ self._add(
310
+ LintSeverity.REDUNDANT,
311
+ "'sync_assets: false' is redundant (default is false).",
312
+ project=p_name,
313
+ )
314
+ elif isinstance(val, str):
315
+ pass
316
+ elif isinstance(val, (list, tuple)):
317
+ if not all(isinstance(x, str) for x in val):
318
+ self._add(
319
+ LintSeverity.ERROR,
320
+ "'sync_assets' list must only contain string glob patterns.",
321
+ project=p_name,
322
+ )
323
+ else:
324
+ self._add(
325
+ LintSeverity.ERROR,
326
+ f"'sync_assets' must be a boolean, string, or list of strings (got {type(val).__name__}).",
327
+ project=p_name,
328
+ )
329
+
330
+ if "stream_assets" in p:
331
+ val = p["stream_assets"]
332
+ if not isinstance(val, bool):
333
+ self._add(
334
+ LintSeverity.ERROR,
335
+ f"'stream_assets' must be a boolean (got {type(val).__name__}).",
336
+ project=p_name,
337
+ )
338
+ elif val is False:
339
+ self._add(
340
+ LintSeverity.REDUNDANT,
341
+ "'stream_assets: false' is redundant (default is false).",
342
+ project=p_name,
343
+ )
344
+
345
+ # 3. Source endpoint checks
346
+ source = p.get("source")
347
+ if source is None:
348
+ self._add(
349
+ LintSeverity.ERROR,
350
+ "Missing required 'source' endpoint configuration.",
351
+ project=p_name,
352
+ )
353
+ elif not isinstance(source, dict):
354
+ self._add(
355
+ LintSeverity.ERROR,
356
+ f"'source' must be a dictionary (got {type(source).__name__}).",
357
+ project=p_name,
358
+ )
359
+ else:
360
+ self._lint_source(source, p_name)
361
+
362
+ # 4. Target endpoint checks
363
+ target = p.get("target")
364
+ if target is None:
365
+ self._add(
366
+ LintSeverity.ERROR,
367
+ "Missing required 'target' endpoint configuration.",
368
+ project=p_name,
369
+ )
370
+ elif not isinstance(target, dict):
371
+ self._add(
372
+ LintSeverity.ERROR,
373
+ f"'target' must be a dictionary (got {type(target).__name__}).",
374
+ project=p_name,
375
+ )
376
+ else:
377
+ self._lint_target(target, p_name)
378
+
379
+ # 5. Retry checks
380
+ if "retry" in p:
381
+ retry = p["retry"]
382
+ if not isinstance(retry, dict):
383
+ self._add(
384
+ LintSeverity.ERROR,
385
+ f"'retry' must be a dictionary (got {type(retry).__name__}).",
386
+ project=p_name,
387
+ )
388
+ else:
389
+ self._lint_retry(retry, p_name)
390
+
391
+ # 6. Renderer checks
392
+ if "renderer" in p:
393
+ renderer = p["renderer"]
394
+ if not isinstance(renderer, dict):
395
+ self._add(
396
+ LintSeverity.ERROR,
397
+ f"'renderer' must be a dictionary (got {type(renderer).__name__}).",
398
+ project=p_name,
399
+ )
400
+ else:
401
+ self._lint_renderer(renderer, p_name)
402
+
403
+ def _lint_source(self, s: Dict[str, Any], p_name: str):
404
+ src_type = s.get("type", "gitea")
405
+ if src_type not in ("gitea", "github", "local"):
406
+ self._add(
407
+ LintSeverity.ERROR,
408
+ f"Invalid source type '{src_type}'. Expected 'gitea', 'github', or 'local'.",
409
+ project=p_name,
410
+ )
411
+ return
412
+
413
+ is_remote = src_type in ("gitea", "github")
414
+ valid_keys = _VALID_SOURCE_KEYS_REMOTE if is_remote else _VALID_SOURCE_KEYS_LOCAL
415
+ for k in s:
416
+ if k in _VALID_PROJECT_KEYS and k != "type":
417
+ self._add(
418
+ LintSeverity.WARNING,
419
+ f"'{k}' was specified inside 'source:', but belongs at the project level.",
420
+ project=p_name,
421
+ key=k,
422
+ )
423
+ elif k not in valid_keys:
424
+ self._add(
425
+ LintSeverity.WARNING,
426
+ f"Unrecognized option '{k}' under source ({src_type}).",
427
+ project=p_name,
428
+ )
429
+
430
+ if is_remote:
431
+ if not s.get("repo"):
432
+ self._add(
433
+ LintSeverity.ERROR,
434
+ "Source is missing required 'repo' string (e.g. 'owner/repo').",
435
+ project=p_name,
436
+ )
437
+ if not s.get("api"):
438
+ self._add(
439
+ LintSeverity.ERROR,
440
+ "Source is missing required 'api' URL.",
441
+ project=p_name,
442
+ )
443
+ if not s.get("token"):
444
+ self._add(
445
+ LintSeverity.ERROR,
446
+ "Source is missing required 'token'.",
447
+ project=p_name,
448
+ )
449
+
450
+ mode = s.get("mode", "release")
451
+ if mode not in ("release", "tag", "commit"):
452
+ self._add(
453
+ LintSeverity.ERROR,
454
+ f"Invalid source mode '{mode}'. Expected 'release', 'tag', or 'commit'.",
455
+ project=p_name,
456
+ )
457
+ elif "mode" in s and s["mode"] == "release":
458
+ self._add(
459
+ LintSeverity.REDUNDANT,
460
+ "'source.mode: release' is redundant (default is release).",
461
+ project=p_name,
462
+ )
463
+
464
+ if "include_prereleases" in s:
465
+ val = s["include_prereleases"]
466
+ if not isinstance(val, bool):
467
+ self._add(
468
+ LintSeverity.ERROR,
469
+ f"'source.include_prereleases' must be a boolean (got {type(val).__name__}).",
470
+ project=p_name,
471
+ )
472
+ elif val is False:
473
+ self._add(
474
+ LintSeverity.REDUNDANT,
475
+ "'source.include_prereleases: false' is redundant (default is false).",
476
+ project=p_name,
477
+ )
478
+
479
+ if "include_drafts" in s:
480
+ val = s["include_drafts"]
481
+ if not isinstance(val, bool):
482
+ self._add(
483
+ LintSeverity.ERROR,
484
+ f"'source.include_drafts' must be a boolean (got {type(val).__name__}).",
485
+ project=p_name,
486
+ )
487
+ elif val is False:
488
+ self._add(
489
+ LintSeverity.REDUNDANT,
490
+ "'source.include_drafts: false' is redundant (default is false).",
491
+ project=p_name,
492
+ )
493
+
494
+ if "sync_from" in s and s["sync_from"] == "":
495
+ self._add(
496
+ LintSeverity.REDUNDANT,
497
+ "'source.sync_from: \"\"' is redundant (default is empty).",
498
+ project=p_name,
499
+ )
500
+ else:
501
+ # Local source
502
+ if not s.get("path"):
503
+ self._add(
504
+ LintSeverity.ERROR,
505
+ "Local source is missing required 'path'.",
506
+ project=p_name,
507
+ )
508
+ if "tag_pattern" in s:
509
+ if s["tag_pattern"] == "*":
510
+ self._add(
511
+ LintSeverity.REDUNDANT,
512
+ "'source.tag_pattern: \"*\"' is redundant (default is \"*\").",
513
+ project=p_name,
514
+ )
515
+
516
+ def _lint_target(self, t: Dict[str, Any], p_name: str):
517
+ tgt_type = t.get("type", "github")
518
+ if tgt_type not in ("github", "gitea", "local"):
519
+ self._add(
520
+ LintSeverity.ERROR,
521
+ f"Invalid target type '{tgt_type}'. Expected 'github', 'gitea', or 'local'.",
522
+ project=p_name,
523
+ )
524
+ return
525
+
526
+ is_remote = tgt_type in ("github", "gitea")
527
+ valid_keys = _VALID_TARGET_KEYS_REMOTE if is_remote else _VALID_TARGET_KEYS_LOCAL
528
+ for k in t:
529
+ if k in _VALID_PROJECT_KEYS and k != "type":
530
+ self._add(
531
+ LintSeverity.WARNING,
532
+ f"'{k}' was specified inside 'target:', but belongs at the project level.",
533
+ project=p_name,
534
+ key=k,
535
+ )
536
+ elif k in _KNOWN_SOURCE_KEYS:
537
+ self._add(
538
+ LintSeverity.ERROR,
539
+ f"'{k}' was specified inside 'target:', but belongs under 'source:'.",
540
+ project=p_name,
541
+ key=k,
542
+ )
543
+ elif k not in valid_keys:
544
+ self._add(
545
+ LintSeverity.WARNING,
546
+ f"Unrecognized option '{k}' under target ({tgt_type}).",
547
+ project=p_name,
548
+ )
549
+
550
+ if is_remote:
551
+ if not t.get("repo"):
552
+ self._add(
553
+ LintSeverity.ERROR,
554
+ "Target is missing required 'repo' string (e.g. 'owner/repo').",
555
+ project=p_name,
556
+ )
557
+ if not t.get("api"):
558
+ self._add(
559
+ LintSeverity.ERROR,
560
+ "Target is missing required 'api' URL.",
561
+ project=p_name,
562
+ )
563
+ if not t.get("token"):
564
+ self._add(
565
+ LintSeverity.ERROR,
566
+ "Target is missing required 'token'.",
567
+ project=p_name,
568
+ )
569
+
570
+ if "branch" in t and t["branch"] == "main":
571
+ self._add(
572
+ LintSeverity.REDUNDANT,
573
+ "'target.branch: main' is redundant (default is main).",
574
+ project=p_name,
575
+ )
576
+
577
+ def _lint_retry(self, r: Dict[str, Any], p_name: str):
578
+ valid_retry_keys = {"max_attempts", "backoff_seconds"}
579
+ for k in r:
580
+ if k not in valid_retry_keys:
581
+ self._add(
582
+ LintSeverity.WARNING,
583
+ f"Unrecognized option '{k}' under retry.",
584
+ project=p_name,
585
+ )
586
+
587
+ max_att = r.get("max_attempts")
588
+ backoff = r.get("backoff_seconds")
589
+
590
+ if max_att is not None:
591
+ if not isinstance(max_att, int) or max_att < 1:
592
+ self._add(
593
+ LintSeverity.ERROR,
594
+ f"'retry.max_attempts' must be an integer >= 1 (got {max_att}).",
595
+ project=p_name,
596
+ )
597
+ elif max_att == 3:
598
+ self._add(
599
+ LintSeverity.REDUNDANT,
600
+ "'retry.max_attempts: 3' is redundant (default is 3).",
601
+ project=p_name,
602
+ )
603
+
604
+ if backoff is not None:
605
+ if not isinstance(backoff, (int, float)) or backoff < 0:
606
+ self._add(
607
+ LintSeverity.ERROR,
608
+ f"'retry.backoff_seconds' must be a number >= 0 (got {backoff}).",
609
+ project=p_name,
610
+ )
611
+ elif backoff == 2:
612
+ self._add(
613
+ LintSeverity.REDUNDANT,
614
+ "'retry.backoff_seconds: 2' is redundant (default is 2).",
615
+ project=p_name,
616
+ )
617
+
618
+ def _lint_renderer(self, ren: Dict[str, Any], p_name: str):
619
+ valid_renderer_keys = {"ignore", "operations", "author"}
620
+ for k in ren:
621
+ if k not in valid_renderer_keys:
622
+ self._add(
623
+ LintSeverity.WARNING,
624
+ f"Unrecognized option '{k}' under renderer.",
625
+ project=p_name,
626
+ )
627
+
628
+ if "ignore" in ren:
629
+ ig = ren["ignore"]
630
+ if not isinstance(ig, (list, tuple)):
631
+ self._add(
632
+ LintSeverity.ERROR,
633
+ f"'renderer.ignore' must be a list of patterns (got {type(ig).__name__}).",
634
+ project=p_name,
635
+ )
636
+
637
+ if "author" in ren:
638
+ auth = ren["author"]
639
+ if not isinstance(auth, dict):
640
+ self._add(
641
+ LintSeverity.ERROR,
642
+ f"'renderer.author' must be a dictionary (got {type(auth).__name__}).",
643
+ project=p_name,
644
+ )
645
+
646
+ if "operations" in ren:
647
+ ops = ren["operations"]
648
+ if not isinstance(ops, list):
649
+ self._add(
650
+ LintSeverity.ERROR,
651
+ f"'renderer.operations' must be a list of operation maps (got {type(ops).__name__}).",
652
+ project=p_name,
653
+ )
654
+ else:
655
+ self._lint_operations(ops, p_name)
656
+
657
+ def _lint_operations(self, ops: List[Any], p_name: str):
658
+ valid_op_types = {"remove", "rename", "replace", "add", "validate"}
659
+ for idx, op in enumerate(ops):
660
+ if not isinstance(op, dict):
661
+ self._add(
662
+ LintSeverity.ERROR,
663
+ f"Operation at index {idx} must be a dictionary (got {type(op).__name__}).",
664
+ project=p_name,
665
+ )
666
+ continue
667
+ for op_type, items in op.items():
668
+ if op_type not in valid_op_types:
669
+ self._add(
670
+ LintSeverity.ERROR,
671
+ f"Unknown renderer operation '{op_type}' at index {idx}. Valid operations: {', '.join(sorted(valid_op_types))}.",
672
+ project=p_name,
673
+ )
674
+ continue
675
+ if not isinstance(items, list):
676
+ self._add(
677
+ LintSeverity.ERROR,
678
+ f"Renderer operation '{op_type}' must contain a list of actions.",
679
+ project=p_name,
680
+ )
681
+ continue
682
+
683
+ if op_type == "remove":
684
+ for item in items:
685
+ if not isinstance(item, dict) or not item.get("path"):
686
+ self._add(
687
+ LintSeverity.ERROR,
688
+ "Remove operation item requires 'path'.",
689
+ project=p_name,
690
+ )
691
+ pat = item.get("pattern")
692
+ if pat == "literal":
693
+ self._add(
694
+ LintSeverity.REDUNDANT,
695
+ "'pattern: literal' in remove operation is redundant (default is literal).",
696
+ project=p_name,
697
+ )
698
+ elif pat and pat not in ("literal", "glob", "regex"):
699
+ self._add(
700
+ LintSeverity.ERROR,
701
+ f"Invalid remove pattern mode '{pat}'. Expected 'literal', 'glob', or 'regex'.",
702
+ project=p_name,
703
+ )
704
+
705
+ elif op_type == "rename":
706
+ for item in items:
707
+ if (
708
+ not isinstance(item, dict)
709
+ or not item.get("from")
710
+ or not item.get("to")
711
+ ):
712
+ self._add(
713
+ LintSeverity.ERROR,
714
+ "Rename operation item requires 'from' and 'to'.",
715
+ project=p_name,
716
+ )
717
+ pat = item.get("pattern")
718
+ if pat == "literal":
719
+ self._add(
720
+ LintSeverity.REDUNDANT,
721
+ "'pattern: literal' in rename operation is redundant (default is literal).",
722
+ project=p_name,
723
+ )
724
+ elif pat and pat != "literal":
725
+ self._add(
726
+ LintSeverity.ERROR,
727
+ f"Rename pattern mode '{pat}' is not supported — only 'literal' is supported.",
728
+ project=p_name,
729
+ )
730
+
731
+ elif op_type == "replace":
732
+ for item in items:
733
+ if (
734
+ not isinstance(item, dict)
735
+ or "search" not in item
736
+ or "replace" not in item
737
+ ):
738
+ self._add(
739
+ LintSeverity.ERROR,
740
+ "Replace operation item requires 'search' and 'replace'.",
741
+ project=p_name,
742
+ )
743
+ pat = item.get("pattern")
744
+ if pat == "literal":
745
+ self._add(
746
+ LintSeverity.REDUNDANT,
747
+ "'pattern: literal' in replace operation is redundant (default is literal).",
748
+ project=p_name,
749
+ )
750
+ elif pat and pat not in ("literal", "regex"):
751
+ self._add(
752
+ LintSeverity.ERROR,
753
+ f"Invalid replace pattern mode '{pat}'. Expected 'literal' or 'regex'.",
754
+ project=p_name,
755
+ )
756
+
757
+ elif op_type == "add":
758
+ for item in items:
759
+ if (
760
+ not isinstance(item, dict)
761
+ or not item.get("path")
762
+ or "content" not in item
763
+ ):
764
+ self._add(
765
+ LintSeverity.ERROR,
766
+ "Add operation item requires 'path' and 'content'.",
767
+ project=p_name,
768
+ )
769
+
770
+ elif op_type == "validate":
771
+ valid_asserts = {
772
+ "file_exists",
773
+ "file_absent",
774
+ "string_exists",
775
+ "string_absent",
776
+ }
777
+ for item in items:
778
+ if not isinstance(item, dict) or not item.get("path"):
779
+ self._add(
780
+ LintSeverity.ERROR,
781
+ "Validate operation item requires 'path'.",
782
+ project=p_name,
783
+ )
784
+ asrt = item.get("assert")
785
+ if asrt not in valid_asserts:
786
+ self._add(
787
+ LintSeverity.ERROR,
788
+ f"Invalid validate assert '{asrt}'. Expected one of {', '.join(sorted(valid_asserts))}.",
789
+ project=p_name,
790
+ )
791
+ if asrt in ("string_exists", "string_absent") and not item.get(
792
+ "pattern"
793
+ ):
794
+ self._add(
795
+ LintSeverity.ERROR,
796
+ f"Validate assert '{asrt}' requires 'pattern'.",
797
+ project=p_name,
798
+ )
799
+
800
+
801
+ def lint_config(
802
+ file_path: Union[str, Path], print_output: bool = True
803
+ ) -> LintReport:
804
+ """Lint a config file and optionally print the results."""
805
+ linter = ConfigLinter()
806
+ report = linter.lint_file(file_path)
807
+ if print_output:
808
+ print(report.format_text())
809
+ return report
810
+
811
+
812
+ # ---------------------------------------------------------------------------
813
+ # Config Fixer
814
+ # ---------------------------------------------------------------------------
815
+
816
+
817
+ class _CleanDumper(yaml.SafeDumper):
818
+ pass
819
+
820
+
821
+ def _str_presenter(dumper, data):
822
+ if "\n" in data:
823
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
824
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data)
825
+
826
+
827
+ _CleanDumper.add_representer(str, _str_presenter)
828
+
829
+
830
+ @dataclass
831
+ class FixIssue:
832
+ message: str
833
+ project: Optional[str] = None
834
+
835
+ def __str__(self) -> str:
836
+ if self.project:
837
+ return f" ✓ (project '{self.project}'): {self.message}"
838
+ return f" ✓ {self.message}"
839
+
840
+
841
+ @dataclass
842
+ class FixReport:
843
+ fixes: List[FixIssue]
844
+ content: str
845
+ is_valid: bool
846
+ error: Optional[str] = None
847
+
848
+ def format_text(self) -> str:
849
+ if not self.is_valid:
850
+ return f"[ERROR] Could not fix configuration: {self.error}"
851
+ if not self.fixes:
852
+ return "No fixes needed. Config is already clean and optimal."
853
+ out = [f"Applied {len(self.fixes)} fix(es):"]
854
+ by_proj: Dict[Optional[str], List[FixIssue]] = {}
855
+ for fix in self.fixes:
856
+ by_proj.setdefault(fix.project, []).append(fix)
857
+
858
+ if None in by_proj:
859
+ for fix in by_proj[None]:
860
+ out.append(f" ✓ {fix.message}")
861
+
862
+ for proj, fixes in by_proj.items():
863
+ if proj is None:
864
+ continue
865
+ out.append(f"\nProject '{proj}':")
866
+ for fix in fixes:
867
+ out.append(f" ✓ {fix.message}")
868
+
869
+ return "\n".join(out)
870
+
871
+
872
+ class ConfigFixer:
873
+ def __init__(self):
874
+ self.fixes: List[FixIssue] = []
875
+
876
+ def _add(self, message: str, project: Optional[str] = None):
877
+ self.fixes.append(FixIssue(message=message, project=project))
878
+
879
+ def fix_yaml_string(self, content: str) -> FixReport:
880
+ try:
881
+ data = yaml.safe_load(content)
882
+ except yaml.YAMLError as e:
883
+ return FixReport(fixes=[], content=content, is_valid=False, error=str(e))
884
+
885
+ if data is None:
886
+ return FixReport(fixes=[], content=content, is_valid=True)
887
+
888
+ is_dict_wrapper = isinstance(data, dict) and "projects" in data
889
+ projects_list = data["projects"] if is_dict_wrapper else data
890
+
891
+ if not isinstance(projects_list, list):
892
+ return FixReport(
893
+ fixes=[],
894
+ content=content,
895
+ is_valid=False,
896
+ error="Top-level YAML structure must be a list of projects or a map with a 'projects' list.",
897
+ )
898
+
899
+ for idx, p in enumerate(projects_list):
900
+ if isinstance(p, dict):
901
+ self._fix_project(p)
902
+
903
+ fixed_yaml = yaml.dump(
904
+ data,
905
+ Dumper=_CleanDumper,
906
+ sort_keys=False,
907
+ default_flow_style=False,
908
+ )
909
+ return FixReport(fixes=self.fixes, content=fixed_yaml, is_valid=True)
910
+
911
+ def _fix_project(self, p: Dict[str, Any]):
912
+ p_name = p.get("name") or "<unnamed>"
913
+
914
+ # 1. Misplaced endpoint keys at project level -> move to source
915
+ for k in list(p.keys()):
916
+ if k in _KNOWN_SOURCE_KEYS:
917
+ val = p.pop(k)
918
+ if "source" not in p or not isinstance(p["source"], dict):
919
+ p["source"] = {}
920
+ if k not in p["source"]:
921
+ p["source"][k] = val
922
+ self._add(
923
+ f"Moved '{k}: {val}' from project level into 'source:'",
924
+ project=p_name,
925
+ )
926
+ else:
927
+ self._add(
928
+ f"Removed duplicate project-level '{k}' (already present under source)",
929
+ project=p_name,
930
+ )
931
+
932
+ # 2. Misplaced project-level keys inside source -> move to project level
933
+ if isinstance(p.get("source"), dict):
934
+ for k in list(p["source"].keys()):
935
+ if k in _VALID_PROJECT_KEYS and k != "type":
936
+ val = p["source"].pop(k)
937
+ if k not in p:
938
+ p[k] = val
939
+ self._add(
940
+ f"Moved '{k}' from 'source:' to project level",
941
+ project=p_name,
942
+ )
943
+ else:
944
+ self._add(
945
+ f"Removed duplicate '{k}' from 'source:' (already present at project level)",
946
+ project=p_name,
947
+ )
948
+
949
+ # 3. Misplaced project keys in target -> move to project level
950
+ # and misplaced source keys in target -> move to source
951
+ if isinstance(p.get("target"), dict):
952
+ for k in list(p["target"].keys()):
953
+ if k in _VALID_PROJECT_KEYS and k != "type":
954
+ val = p["target"].pop(k)
955
+ if k not in p:
956
+ p[k] = val
957
+ self._add(
958
+ f"Moved '{k}' from 'target:' to project level",
959
+ project=p_name,
960
+ )
961
+ else:
962
+ self._add(
963
+ f"Removed duplicate '{k}' from 'target:' (already present at project level)",
964
+ project=p_name,
965
+ )
966
+ elif k in _KNOWN_SOURCE_KEYS:
967
+ val = p["target"].pop(k)
968
+ if "source" not in p or not isinstance(p["source"], dict):
969
+ p["source"] = {}
970
+ if k not in p["source"]:
971
+ p["source"][k] = val
972
+ self._add(
973
+ f"Moved '{k}: {val}' from 'target:' into 'source:'",
974
+ project=p_name,
975
+ )
976
+ else:
977
+ self._add(
978
+ f"Removed duplicate '{k}' from 'target:' (already present under source)",
979
+ project=p_name,
980
+ )
981
+
982
+ # 4. Redundant project-level options
983
+ if p.get("enabled") is True:
984
+ del p["enabled"]
985
+ self._add("Removed redundant 'enabled: true'", project=p_name)
986
+
987
+ for key in ("preserve_description", "preserve_release_description"):
988
+ if p.get(key) is True:
989
+ del p[key]
990
+ self._add(f"Removed redundant '{key}: true'", project=p_name)
991
+
992
+ for key in ("sync_assets", "preserve_assets", "include_assets"):
993
+ if p.get(key) is False:
994
+ del p[key]
995
+ self._add(f"Removed redundant '{key}: false'", project=p_name)
996
+
997
+ if p.get("stream_assets") is False:
998
+ del p["stream_assets"]
999
+ self._add("Removed redundant 'stream_assets: false'", project=p_name)
1000
+
1001
+ # 3. Source endpoint fixes
1002
+ if isinstance(p.get("source"), dict):
1003
+ src = p["source"]
1004
+ if src.get("mode") == "release":
1005
+ del src["mode"]
1006
+ self._add(
1007
+ "Removed redundant 'source.mode: release'", project=p_name
1008
+ )
1009
+ if src.get("include_prereleases") is False:
1010
+ del src["include_prereleases"]
1011
+ self._add(
1012
+ "Removed redundant 'source.include_prereleases: false'",
1013
+ project=p_name,
1014
+ )
1015
+ if src.get("include_drafts") is False:
1016
+ del src["include_drafts"]
1017
+ self._add(
1018
+ "Removed redundant 'source.include_drafts: false'",
1019
+ project=p_name,
1020
+ )
1021
+ if src.get("sync_from") == "":
1022
+ del src["sync_from"]
1023
+ self._add(
1024
+ "Removed redundant 'source.sync_from: \"\"'",
1025
+ project=p_name,
1026
+ )
1027
+ if src.get("tag_pattern") == "*":
1028
+ del src["tag_pattern"]
1029
+ self._add(
1030
+ "Removed redundant 'source.tag_pattern: \"*\"'",
1031
+ project=p_name,
1032
+ )
1033
+
1034
+ # 4. Target endpoint fixes
1035
+ if isinstance(p.get("target"), dict):
1036
+ tgt = p["target"]
1037
+ if tgt.get("branch") == "main":
1038
+ del tgt["branch"]
1039
+ self._add(
1040
+ "Removed redundant 'target.branch: main'", project=p_name
1041
+ )
1042
+
1043
+ # 5. Retry fixes
1044
+ if isinstance(p.get("retry"), dict):
1045
+ ret = p["retry"]
1046
+ if ret.get("max_attempts") == 3:
1047
+ del ret["max_attempts"]
1048
+ if ret.get("backoff_seconds") == 2:
1049
+ del ret["backoff_seconds"]
1050
+ if len(ret) == 0:
1051
+ del p["retry"]
1052
+ self._add(
1053
+ "Removed default 'retry' configuration block",
1054
+ project=p_name,
1055
+ )
1056
+ else:
1057
+ self._add(
1058
+ "Removed redundant retry defaults", project=p_name
1059
+ )
1060
+
1061
+ # 6. Renderer fixes
1062
+ if isinstance(p.get("renderer"), dict):
1063
+ ren = p["renderer"]
1064
+ if isinstance(ren.get("operations"), list):
1065
+ for op in ren["operations"]:
1066
+ if not isinstance(op, dict):
1067
+ continue
1068
+ for op_type, items in op.items():
1069
+ if isinstance(items, list):
1070
+ for item in items:
1071
+ if (
1072
+ isinstance(item, dict)
1073
+ and item.get("pattern") == "literal"
1074
+ ):
1075
+ del item["pattern"]
1076
+ self._add(
1077
+ f"Removed redundant 'pattern: literal' in {op_type} operation",
1078
+ project=p_name,
1079
+ )
1080
+
1081
+
1082
+ def fix_config(
1083
+ file_path: Union[str, Path],
1084
+ write_back: bool = True,
1085
+ print_output: bool = True,
1086
+ ) -> FixReport:
1087
+ """Fix misplaced and redundant options in config file."""
1088
+ path = Path(file_path)
1089
+ if not path.exists():
1090
+ report = FixReport(
1091
+ fixes=[], content="", is_valid=False, error=f"File not found: {path}"
1092
+ )
1093
+ if print_output:
1094
+ print(report.format_text())
1095
+ return report
1096
+
1097
+ content = path.read_text(encoding="utf-8")
1098
+ fixer = ConfigFixer()
1099
+ report = fixer.fix_yaml_string(content)
1100
+ if report.is_valid and write_back and report.fixes:
1101
+ path.write_text(report.content, encoding="utf-8")
1102
+ if print_output:
1103
+ print(report.format_text())
1104
+ return report
1105
+