conflint 0.1.1__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.
Files changed (51) hide show
  1. conflint/__about__.py +7 -0
  2. conflint/__init__.py +32 -0
  3. conflint/__main__.py +8 -0
  4. conflint/_version.py +24 -0
  5. conflint/cli.py +374 -0
  6. conflint/config.py +268 -0
  7. conflint/discovery.py +167 -0
  8. conflint/engine.py +182 -0
  9. conflint/exceptions.py +60 -0
  10. conflint/models.py +302 -0
  11. conflint/registry.py +163 -0
  12. conflint/reporters/__init__.py +50 -0
  13. conflint/reporters/base.py +31 -0
  14. conflint/reporters/github.py +45 -0
  15. conflint/reporters/json.py +37 -0
  16. conflint/reporters/junit.py +70 -0
  17. conflint/reporters/sarif.py +103 -0
  18. conflint/reporters/text.py +102 -0
  19. conflint/rules/__init__.py +44 -0
  20. conflint/rules/base.py +61 -0
  21. conflint/rules/drift.py +38 -0
  22. conflint/rules/duplicate.py +70 -0
  23. conflint/rules/empty.py +38 -0
  24. conflint/rules/missing.py +38 -0
  25. conflint/rules/secrets.py +39 -0
  26. conflint/rules/typo.py +39 -0
  27. conflint/rules/undocumented.py +48 -0
  28. conflint/rules/unused.py +40 -0
  29. conflint/rules/weak.py +75 -0
  30. conflint/scanners/__init__.py +21 -0
  31. conflint/scanners/base.py +49 -0
  32. conflint/scanners/go.py +53 -0
  33. conflint/scanners/javascript.py +115 -0
  34. conflint/scanners/python.py +196 -0
  35. conflint/scanners/ruby.py +80 -0
  36. conflint/sources/__init__.py +19 -0
  37. conflint/sources/_yaml.py +25 -0
  38. conflint/sources/base.py +66 -0
  39. conflint/sources/docker_compose.py +88 -0
  40. conflint/sources/dotenv.py +83 -0
  41. conflint/sources/github_actions.py +77 -0
  42. conflint/sources/kubernetes.py +146 -0
  43. conflint/sources/terraform.py +168 -0
  44. conflint/sync.py +123 -0
  45. conflint/utils.py +263 -0
  46. conflint/version.py +30 -0
  47. conflint-0.1.1.dist-info/METADATA +255 -0
  48. conflint-0.1.1.dist-info/RECORD +51 -0
  49. conflint-0.1.1.dist-info/WHEEL +4 -0
  50. conflint-0.1.1.dist-info/entry_points.txt +26 -0
  51. conflint-0.1.1.dist-info/licenses/LICENSE +21 -0
conflint/__about__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Single source of truth for package metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from conflint.version import __version__
6
+
7
+ __all__ = ["__version__"]
conflint/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """conflint - code-aware config linter.
2
+
3
+ Find every environment variable, config key and secret your application reads,
4
+ reconcile it against every place it is defined (.env, Docker, CI, Kubernetes,
5
+ Terraform) and fail fast on the drift that silently breaks production.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from conflint.models import (
11
+ Config,
12
+ Entry,
13
+ Finding,
14
+ Language,
15
+ Location,
16
+ Report,
17
+ Severity,
18
+ Usage,
19
+ )
20
+ from conflint.version import __version__
21
+
22
+ __all__ = [
23
+ "Config",
24
+ "Entry",
25
+ "Finding",
26
+ "Language",
27
+ "Location",
28
+ "Report",
29
+ "Severity",
30
+ "Usage",
31
+ "__version__",
32
+ ]
conflint/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m conflint`` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from conflint.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
conflint/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
+
24
+ __commit_id__ = commit_id = None
conflint/cli.py ADDED
@@ -0,0 +1,374 @@
1
+ """conflint command-line interface.
2
+
3
+ The command name is :program:`confl` (the PyPI package is ``conflint``)::
4
+
5
+ pip install conflint
6
+ confl check
7
+ confl sync
8
+ confl explain DB_HOST
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from collections.abc import Sequence
15
+ from pathlib import Path
16
+
17
+ import click
18
+
19
+ from conflint.__about__ import __version__
20
+ from conflint.config import Settings, load_settings
21
+ from conflint.engine import Analyzer
22
+ from conflint.exceptions import ConflintError, ExitCode
23
+ from conflint.models import Report
24
+ from conflint.registry import get_rules
25
+ from conflint.reporters import (
26
+ GithubReporter,
27
+ JsonReporter,
28
+ JunitReporter,
29
+ Reporter,
30
+ SarifReporter,
31
+ TextReporter,
32
+ )
33
+ from conflint.sync import build_example, default_example_file
34
+ from conflint.utils import read_text
35
+
36
+ logger = logging.getLogger("conflint.cli")
37
+
38
+ __all__ = ["main"]
39
+
40
+ _REPORTERS: dict[str, type[Reporter]] = {
41
+ "text": TextReporter,
42
+ "json": JsonReporter,
43
+ "sarif": SarifReporter,
44
+ "github": GithubReporter,
45
+ "junit": JunitReporter,
46
+ }
47
+
48
+
49
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
50
+ @click.version_option(__version__, prog_name="confl", message="%(prog)s %(version)s")
51
+ def main() -> None:
52
+ """conflint - code-aware config linter.
53
+
54
+ Find the env vars, config keys and secrets your app *actually* reads, then
55
+ reconcile them against .env, CI, Docker, Kubernetes and Terraform.
56
+ """
57
+
58
+
59
+ # --------------------------------------------------------------------------
60
+ # check
61
+ # --------------------------------------------------------------------------
62
+ @main.command()
63
+ @click.argument("paths", nargs=-1, type=click.Path())
64
+ @click.option("--config", "config_path", type=click.Path(dir_okay=False))
65
+ @click.option("--report", "report_flags", multiple=True, type=click.Choice(sorted(_REPORTERS)))
66
+ @click.option("--fail-level", type=click.Choice(["error", "warning", "info"]))
67
+ @click.option("--fix", is_flag=True, help="Auto-remediate fixable findings.")
68
+ @click.option("--no-color", "no_color", is_flag=True)
69
+ @click.option("--json", "as_json", is_flag=True, hidden=False)
70
+ @click.option("--sarif", "as_sarif", is_flag=True)
71
+ @click.option("--github", "as_github", is_flag=True)
72
+ @click.option("--output", type=click.Path(dir_okay=False), help="Write the report to a file.")
73
+ def check(
74
+ paths: tuple[str, ...],
75
+ config_path: str | None,
76
+ report_flags: tuple[str, ...],
77
+ fail_level: str | None,
78
+ fix: bool,
79
+ no_color: bool,
80
+ as_json: bool,
81
+ as_sarif: bool,
82
+ as_github: bool,
83
+ output: str | None,
84
+ ) -> int:
85
+ """Lint the project and report configuration drift."""
86
+
87
+ root = _resolve_root(paths)
88
+ try:
89
+ settings = load_settings(
90
+ root,
91
+ config_path=Path(config_path) if config_path else None,
92
+ overrides=_build_overrides(paths, fail_level, no_color),
93
+ )
94
+ except ConflintError as exc:
95
+ click.echo(f"confl: {exc}", err=True)
96
+ raise SystemExit(ExitCode.USAGE) from None
97
+ reporter_classes = _select_reporters(settings, report_flags, as_json, as_sarif, as_github)
98
+ if not reporter_classes:
99
+ click.echo("confl: no reporters selected", err=True)
100
+ raise SystemExit(ExitCode.USAGE)
101
+ try:
102
+ analyzer = Analyzer(settings)
103
+ if fix:
104
+ _autofix(analyzer, settings)
105
+ report = analyzer.run()
106
+ except ConflintError as exc:
107
+ click.echo(f"confl: {exc}", err=True)
108
+ raise SystemExit(ExitCode.INTERNAL) from None
109
+ _emit(reporter_classes, report, settings, output)
110
+
111
+ if report.has_findings_at_or_above(settings.fail_level):
112
+ raise SystemExit(ExitCode.FINDINGS)
113
+ raise SystemExit(ExitCode.OK)
114
+
115
+
116
+ # --------------------------------------------------------------------------
117
+ # sync
118
+ # --------------------------------------------------------------------------
119
+ @main.command()
120
+ @click.option("--config", "config_path", type=click.Path(dir_okay=False))
121
+ @click.option("--dry-run", is_flag=True, help="Print the generated file without writing.")
122
+ @click.option("--output", type=click.Path(dir_okay=False), help="Example file to write/update.")
123
+ def sync(config_path: str | None, dry_run: bool, output: str | None) -> int:
124
+ """Regenerate the .env.example from code + config sources."""
125
+
126
+ root = Path.cwd()
127
+ try:
128
+ settings = load_settings(root, config_path=Path(config_path) if config_path else None)
129
+ model = Analyzer(settings).collect()
130
+ except ConflintError as exc:
131
+ click.echo(f"confl: {exc}", err=True)
132
+ raise SystemExit(ExitCode.USAGE) from None
133
+ content = build_example(model, settings)
134
+ if dry_run:
135
+ click.echo(content, nl=False)
136
+ raise SystemExit(ExitCode.OK)
137
+ target = Path(output) if output else default_example_file(settings.root, settings)
138
+ changed = target.is_file() and read_text(target) == content
139
+ try:
140
+ target.write_text(content, encoding="utf-8")
141
+ except OSError as exc: # pragma: no cover - filesystem dependent
142
+ click.echo(f"confl: cannot write {target}: {exc}", err=True)
143
+ raise SystemExit(ExitCode.INTERNAL) from None
144
+ click.echo(("unchanged: " if changed else "updated: ") + str(target))
145
+ raise SystemExit(ExitCode.OK)
146
+
147
+
148
+ # --------------------------------------------------------------------------
149
+ # explain
150
+ # --------------------------------------------------------------------------
151
+ @main.command("explain")
152
+ @click.argument("name")
153
+ @click.option("--config", "config_path", type=click.Path(dir_okay=False))
154
+ def explain(name: str, config_path: str | None) -> int:
155
+ """Show where a configuration value is used and defined."""
156
+
157
+ root = Path.cwd()
158
+ try:
159
+ settings = load_settings(root, config_path=Path(config_path) if config_path else None)
160
+ analyzer = Analyzer(settings)
161
+ model = analyzer.collect()
162
+ except ConflintError as exc:
163
+ click.echo(f"confl: {exc}", err=True)
164
+ raise SystemExit(ExitCode.USAGE) from None
165
+ usages = model.usages_by_name().get(name, [])
166
+ entries = model.entries_by_name().get(name, [])
167
+ from rich.console import Console
168
+
169
+ console = Console()
170
+ if not usages and not entries:
171
+ console.print(f"[red]'{name}' is not used or defined anywhere.[/red]")
172
+ raise SystemExit(ExitCode.FINDINGS)
173
+
174
+ console.print(f"[bold]{name}[/bold]")
175
+ if usages:
176
+ console.print(f" read at [bold]{len(usages)}[/bold] place(s):")
177
+ seen = set()
178
+ for usage in usages:
179
+ line = str(usage.location.relative_to(model.root))
180
+ if line in seen:
181
+ continue
182
+ seen.add(line)
183
+ detail = f" - {line} (via {usage.read_via or '?'}"
184
+ if usage.has_default:
185
+ detail += f", default={usage.default!r})"
186
+ else:
187
+ detail += ")"
188
+ console.print(detail)
189
+ if entries:
190
+ console.print(f" defined in [bold]{len(entries)}[/bold] place(s):")
191
+ for entry in entries:
192
+ console.print(
193
+ f" - {entry.location.relative_to(model.root)} "
194
+ f"({entry.source}, value={entry.value!r}, example={entry.is_example})"
195
+ )
196
+ else:
197
+ console.print(" not defined in any configuration source")
198
+ raise SystemExit(ExitCode.OK)
199
+
200
+
201
+ # --------------------------------------------------------------------------
202
+ # rules / init / version
203
+ # --------------------------------------------------------------------------
204
+ @main.command("rules")
205
+ @click.option("--json", "as_json", is_flag=True)
206
+ def rules(as_json: bool) -> int:
207
+ """List every rule with its default severity."""
208
+
209
+ items = []
210
+ for _rule_id, rule in get_rules().items():
211
+ items.append(
212
+ {
213
+ "id": rule.id,
214
+ "name": rule.name,
215
+ "severity": rule.severity.value,
216
+ "description": rule.description,
217
+ "fixable": rule.fixable,
218
+ }
219
+ )
220
+ if as_json:
221
+ import json as _json
222
+
223
+ click.echo(_json.dumps(items, indent=2))
224
+ raise SystemExit(ExitCode.OK)
225
+ click.echo(f"{'Rule':<8} {'Severity':<9} {'Name':<16} Description")
226
+ for item in items:
227
+ click.echo(
228
+ f"{item['id']:<8} {item['severity']:<9} {item['name']:<16} {item['description']}"
229
+ )
230
+ raise SystemExit(ExitCode.OK)
231
+
232
+
233
+ @main.command("init")
234
+ @click.option("--force", is_flag=True, help="Overwrite an existing conflint.toml.")
235
+ def init(force: bool) -> int:
236
+ """Write a template conflint.toml in the current directory."""
237
+
238
+ target = Path.cwd() / "conflint.toml"
239
+ if target.exists() and not force:
240
+ click.echo(f"confl: {target} already exists (use --force to overwrite).", err=True)
241
+ raise SystemExit(ExitCode.USAGE)
242
+ template = """\
243
+ # conflint configuration - https://github.com/conflint/conflint
244
+ # Keys are top-level here (in pyproject.toml, nest them under [tool.conflint]).
245
+
246
+ # fail-level = "error" # error | warning | info
247
+ # require-example = true # enforce a populated .env.example
248
+
249
+ # enable = { CL003 = "error" } # raise a rule's severity
250
+ # disable = ["CL002", "CL009"] # turn rules off
251
+
252
+ # ignore-names = ["CI=true", "NODE_ENV=*"]
253
+ # ignore-paths = ["scripts/**"]
254
+
255
+ # sources = ["dotenv", "docker-compose", "github-actions", "kubernetes", "terraform"]
256
+ # scanners = ["python", "javascript"]
257
+ """
258
+ try:
259
+ target.write_text(template, encoding="utf-8")
260
+ except OSError as exc: # pragma: no cover
261
+ click.echo(f"confl: cannot write {target}: {exc}", err=True)
262
+ raise SystemExit(ExitCode.INTERNAL) from None
263
+ click.echo(f"created {target}")
264
+ raise SystemExit(ExitCode.OK)
265
+
266
+
267
+ @main.command()
268
+ def version() -> int:
269
+ """Print the installed version."""
270
+
271
+ click.echo(f"confl {__version__}")
272
+ raise SystemExit(ExitCode.OK)
273
+
274
+
275
+ # --------------------------------------------------------------------------
276
+ # helpers
277
+ # --------------------------------------------------------------------------
278
+ def _resolve_root(paths: Sequence[str]) -> Path:
279
+ if not paths:
280
+ return Path.cwd()
281
+ first = Path(paths[0])
282
+ if first.is_dir():
283
+ return first.resolve()
284
+ return first.parent.resolve()
285
+
286
+
287
+ def _build_overrides(
288
+ paths: Sequence[str],
289
+ fail_level: str | None,
290
+ no_color: bool,
291
+ ) -> dict[str, object]:
292
+ overrides: dict[str, object] = {}
293
+ if paths:
294
+ root = _resolve_root(paths)
295
+ includes: list[str] = []
296
+ for raw in paths:
297
+ path = Path(raw)
298
+ if path.is_dir():
299
+ # Discovery is already rooted at the path; keep the default
300
+ # include glob ("**/*") rather than emitting an absolute glob.
301
+ continue
302
+ try:
303
+ rel = path.resolve().relative_to(root.resolve())
304
+ except (ValueError, OSError):
305
+ rel = path
306
+ includes.append(str(rel).replace("\\", "/"))
307
+ if includes:
308
+ overrides["include"] = tuple(includes)
309
+ if fail_level:
310
+ overrides["fail-level"] = fail_level
311
+ if no_color:
312
+ overrides["color"] = False
313
+ return overrides
314
+
315
+
316
+ def _select_reporters(
317
+ settings: Settings,
318
+ flags: Sequence[str],
319
+ as_json: bool,
320
+ as_sarif: bool,
321
+ as_github: bool,
322
+ ) -> list[type[Reporter]]:
323
+ chosen: list[str] = []
324
+ if as_json:
325
+ chosen.append("json")
326
+ if as_sarif:
327
+ chosen.append("sarif")
328
+ if as_github:
329
+ chosen.append("github")
330
+ if not chosen:
331
+ chosen.extend(flags)
332
+ if not chosen:
333
+ chosen.extend(settings.reporters)
334
+ unique = list(dict.fromkeys(chosen))
335
+ return [_REPORTERS[name] for name in unique if name in _REPORTERS]
336
+
337
+
338
+ def _emit(
339
+ reporter_classes: Sequence[type[Reporter]],
340
+ report: Report,
341
+ settings: Settings,
342
+ output: str | None,
343
+ ) -> None:
344
+ rendered = [(cls.name, cls().render(report, settings)) for cls in reporter_classes]
345
+ if output is not None and len(rendered) == 1:
346
+ path = Path(output)
347
+ try:
348
+ path.write_text(rendered[0][1], encoding="utf-8")
349
+ except OSError as exc: # pragma: no cover
350
+ click.echo(f"confl: cannot write {path}: {exc}", err=True)
351
+ return
352
+ for _, text in rendered:
353
+ click.echo(text, nl=text.endswith("\n"))
354
+
355
+
356
+ def _autofix(analyzer: Analyzer, settings: Settings) -> None:
357
+ """Apply safe, lossless auto-fixes.
358
+
359
+ Currently resolves CL009 (undocumented keys) by rewriting the example file.
360
+ All fixes are deterministic and never touch real configuration values.
361
+ """
362
+
363
+ if not settings.require_example:
364
+ return
365
+ model = analyzer.collect()
366
+ content = build_example(model, settings)
367
+ target = default_example_file(settings.root, settings)
368
+ if target.is_file() and read_text(target) == content:
369
+ return
370
+ target.write_text(content, encoding="utf-8")
371
+
372
+
373
+ if __name__ == "__main__": # pragma: no cover
374
+ main()