truthlint 0.1.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.
Files changed (46) hide show
  1. truthlint/__init__.py +10 -0
  2. truthlint/__main__.py +4 -0
  3. truthlint/cli.py +408 -0
  4. truthlint/config.py +57 -0
  5. truthlint/context.py +440 -0
  6. truthlint/engine.py +131 -0
  7. truthlint/fixes.py +132 -0
  8. truthlint/ignore.py +129 -0
  9. truthlint/intel.py +237 -0
  10. truthlint/models.py +198 -0
  11. truthlint/onboarding.py +227 -0
  12. truthlint/parsers/__init__.py +1 -0
  13. truthlint/parsers/ci.py +201 -0
  14. truthlint/parsers/compose.py +101 -0
  15. truthlint/parsers/dockerfile.py +150 -0
  16. truthlint/parsers/envfile.py +41 -0
  17. truthlint/parsers/lines.py +78 -0
  18. truthlint/parsers/makefile.py +119 -0
  19. truthlint/parsers/markdown.py +193 -0
  20. truthlint/parsers/node_meta.py +139 -0
  21. truthlint/parsers/python_meta.py +467 -0
  22. truthlint/parsers/shell.py +235 -0
  23. truthlint/parsers/source.py +506 -0
  24. truthlint/parsers/versions.py +273 -0
  25. truthlint/parsers/yamlx.py +65 -0
  26. truthlint/py.typed +0 -0
  27. truthlint/render/__init__.py +1 -0
  28. truthlint/render/json_report.py +36 -0
  29. truthlint/render/markdown.py +158 -0
  30. truthlint/render/terminal.py +355 -0
  31. truthlint/rules/__init__.py +34 -0
  32. truthlint/rules/base.py +85 -0
  33. truthlint/rules/build.py +188 -0
  34. truthlint/rules/commands.py +1671 -0
  35. truthlint/rules/containers.py +403 -0
  36. truthlint/rules/deps.py +713 -0
  37. truthlint/rules/docs.py +416 -0
  38. truthlint/rules/env.py +380 -0
  39. truthlint/rules/runtime.py +552 -0
  40. truthlint/scoring.py +83 -0
  41. truthlint-0.1.0.dist-info/METADATA +281 -0
  42. truthlint-0.1.0.dist-info/RECORD +46 -0
  43. truthlint-0.1.0.dist-info/WHEEL +5 -0
  44. truthlint-0.1.0.dist-info/entry_points.txt +2 -0
  45. truthlint-0.1.0.dist-info/licenses/LICENSE +21 -0
  46. truthlint-0.1.0.dist-info/top_level.txt +1 -0
truthlint/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """truthlint - find where your repository contradicts itself.
2
+
3
+ Finds the places where a repository's documentation, configuration, CI,
4
+ containers and code contradict each other. Fully offline; never executes
5
+ anything from the repository it inspects.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["__version__"]
truthlint/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
truthlint/cli.py ADDED
@@ -0,0 +1,408 @@
1
+ """Command-line interface (Typer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import sys
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from rich import box
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+ from rich.prompt import Prompt
16
+ from rich.syntax import Syntax
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from . import __version__
21
+ from .engine import scan as run_scan
22
+ from .fixes import EditConflict, apply_finding, build_patches, finding_patches, write_patch
23
+ from .models import ScanResult, Severity
24
+ from .render.json_report import render_json
25
+ from .render.markdown import render_markdown
26
+ from .render.terminal import render_explain, render_finding, render_profile, render_scan
27
+ from .rules import DEEP_ONLY, RULES
28
+
29
+ app = typer.Typer(
30
+ name="truthlint",
31
+ help="truthlint - find where your docs, configs, CI, containers and code contradict each other.",
32
+ no_args_is_help=True,
33
+ add_completion=False,
34
+ rich_markup_mode="rich",
35
+ context_settings={"help_option_names": ["-h", "--help"]},
36
+ )
37
+
38
+ out = Console(soft_wrap=False)
39
+ err = Console(stderr=True)
40
+
41
+
42
+ class ScanFormat(StrEnum):
43
+ terminal = "terminal"
44
+ json = "json"
45
+ markdown = "markdown"
46
+
47
+
48
+ class ReportFormat(StrEnum):
49
+ markdown = "markdown"
50
+ json = "json"
51
+
52
+
53
+ PathArg = Annotated[Path, typer.Argument(help="Repository to analyse.", show_default=True)]
54
+ DeepOpt = Annotated[
55
+ bool,
56
+ typer.Option(
57
+ "--deep",
58
+ help="Also run heuristic checks (unused dependencies/env vars, stale doc paths, CI coverage) and read nested docs.",
59
+ ),
60
+ ]
61
+ ExcludeOpt = Annotated[
62
+ list[str] | None,
63
+ typer.Option("--exclude", "-x", help="Glob to exclude (gitignore syntax). Repeatable."),
64
+ ]
65
+ NoIgnoreOpt = Annotated[
66
+ bool, typer.Option("--no-gitignore", help="Do not honour .gitignore files.")
67
+ ]
68
+
69
+
70
+ def _version(value: bool) -> None:
71
+ if value:
72
+ out.print(f"truthlint {__version__}")
73
+ raise typer.Exit()
74
+
75
+
76
+ @app.callback()
77
+ def _root(
78
+ version: Annotated[
79
+ bool,
80
+ typer.Option(
81
+ "--version", "-V", callback=_version, is_eager=True, help="Show version and exit."
82
+ ),
83
+ ] = False,
84
+ ) -> None:
85
+ """truthlint investigates a repository and reports what it CLAIMS vs what it DOES."""
86
+
87
+
88
+ def _do_scan(
89
+ path: Path, deep: bool, exclude: list[str] | None, no_gitignore: bool, quiet: bool = False
90
+ ) -> ScanResult:
91
+ if not path.exists() or not path.is_dir():
92
+ err.print(f"[red]error:[/] {escape(str(path))} is not a directory")
93
+ raise typer.Exit(2)
94
+ show = not quiet and err.is_terminal
95
+ if show:
96
+ with err.status("[magenta]Investigating repository…", spinner="dots") as status:
97
+ return run_scan(
98
+ path,
99
+ deep=deep,
100
+ exclude=exclude or [],
101
+ respect_gitignore=not no_gitignore,
102
+ progress=lambda m: status.update(f"[magenta]{m}…"),
103
+ )
104
+ return run_scan(path, deep=deep, exclude=exclude or [], respect_gitignore=not no_gitignore)
105
+
106
+
107
+ def _write(text: str, output: Path | None) -> None:
108
+ if output is None:
109
+ sys.stdout.write(text if text.endswith("\n") else text + "\n")
110
+ sys.stdout.flush()
111
+ else:
112
+ output.parent.mkdir(parents=True, exist_ok=True)
113
+ output.write_text(text, encoding="utf-8")
114
+ err.print(f"[green]✔[/] wrote {escape(str(output))}")
115
+
116
+
117
+ @app.command()
118
+ def scan(
119
+ path: PathArg = Path("."),
120
+ deep: DeepOpt = False,
121
+ exclude: ExcludeOpt = None,
122
+ no_gitignore: NoIgnoreOpt = False,
123
+ fmt: Annotated[
124
+ ScanFormat, typer.Option("--format", "-f", help="Output format.")
125
+ ] = ScanFormat.terminal,
126
+ output: Annotated[
127
+ Path | None,
128
+ typer.Option("--output", "-o", help="Write the report to a file instead of stdout."),
129
+ ] = None,
130
+ limit: Annotated[
131
+ int, typer.Option(help="Max findings to print in the terminal (0 = all).")
132
+ ] = 25,
133
+ verbose: Annotated[
134
+ bool, typer.Option("--verbose", "-v", help="Show excerpts and patches for every finding.")
135
+ ] = False,
136
+ fail_under: Annotated[
137
+ int | None,
138
+ typer.Option(
139
+ "--fail-under",
140
+ help="Exit with code 1 if the Truth Score is below this value (CI gate).",
141
+ ),
142
+ ] = None,
143
+ fail_on: Annotated[
144
+ Severity | None,
145
+ typer.Option("--fail-on", help="Exit with code 1 if any finding is at least this severe."),
146
+ ] = None,
147
+ ) -> None:
148
+ """Investigate a repository and report contradictions between its claims and reality."""
149
+ result = _do_scan(
150
+ path, deep, exclude, no_gitignore, quiet=fmt != ScanFormat.terminal and output is None
151
+ )
152
+ if fmt == ScanFormat.json:
153
+ _write(render_json(result), output)
154
+ elif fmt == ScanFormat.markdown:
155
+ _write(render_markdown(result), output)
156
+ else:
157
+ if output:
158
+ with open(output, "w", encoding="utf-8") as fh:
159
+ console = Console(file=fh, width=120, force_terminal=False, color_system=None)
160
+ render_scan(console, result, limit=None if limit == 0 else limit, verbose=verbose)
161
+ err.print(f"[green]✔[/] wrote {escape(str(output))}")
162
+ else:
163
+ render_scan(out, result, limit=None if limit == 0 else limit, verbose=verbose)
164
+ _gate(result, fail_under, fail_on)
165
+
166
+
167
+ def _gate(result: ScanResult, fail_under: int | None, fail_on: Severity | None) -> None:
168
+ failed = False
169
+ if fail_under is not None and result.truth.score < fail_under:
170
+ err.print(f"[red]✘ Truth Score {result.truth.score} is below --fail-under {fail_under}[/]")
171
+ failed = True
172
+ if fail_on is not None and any(f.severity.rank >= fail_on.rank for f in result.findings):
173
+ err.print(f"[red]✘ found {fail_on.value}-or-worse findings (--fail-on {fail_on.value})[/]")
174
+ failed = True
175
+ if failed:
176
+ raise typer.Exit(1)
177
+
178
+
179
+ @app.command()
180
+ def report(
181
+ path: PathArg = Path("."),
182
+ fmt: Annotated[
183
+ ReportFormat, typer.Option("--format", "-f", help="Report format.")
184
+ ] = ReportFormat.markdown,
185
+ output: Annotated[
186
+ Path | None, typer.Option("--output", "-o", help="Output file (default: stdout).")
187
+ ] = None,
188
+ deep: DeepOpt = False,
189
+ exclude: ExcludeOpt = None,
190
+ no_gitignore: NoIgnoreOpt = False,
191
+ ) -> None:
192
+ """Generate a full Markdown or JSON report with evidence, confidence and patches."""
193
+ result = _do_scan(path, deep, exclude, no_gitignore, quiet=output is None)
194
+ _write(render_markdown(result) if fmt == ReportFormat.markdown else render_json(result), output)
195
+
196
+
197
+ @app.command()
198
+ def explain(
199
+ issue_id: Annotated[
200
+ str,
201
+ typer.Argument(
202
+ help="Finding ID (e.g. TL201-3FA2C1, prefix allowed) or rule ID (e.g. TL201)."
203
+ ),
204
+ ],
205
+ path: PathArg = Path("."),
206
+ exclude: ExcludeOpt = None,
207
+ no_gitignore: NoIgnoreOpt = False,
208
+ ) -> None:
209
+ """Explain a finding in depth (evidence in context, rationale, proposed patch) or describe a rule."""
210
+ key = issue_id.strip().upper()
211
+ if key in RULES:
212
+ r = RULES[key]
213
+ out.print(
214
+ f"[bold]{r.id}[/] — [bold]{escape(r.title)}[/] [dim]({r.category.value}, default severity {r.default_severity.value}{', --deep only' if r.id in DEEP_ONLY else ''})[/]\n"
215
+ )
216
+ out.print(Text(r.description + "\n"))
217
+ out.print("[bold]Why it matters[/]")
218
+ out.print(Text(r.rationale))
219
+ return
220
+ result = _do_scan(path, False, exclude, no_gitignore)
221
+ f = result.find(key)
222
+ if f is None:
223
+ result = _do_scan(
224
+ path, True, exclude, no_gitignore
225
+ ) # finding may come from a deep-only rule
226
+ f = result.find(key)
227
+ if f is None:
228
+ err.print(
229
+ f"[red]No finding matches {escape(repr(issue_id))}.[/] Run [bold]truthlint scan[/] to list current IDs (IDs are stable as long as the underlying issue is unchanged)."
230
+ )
231
+ raise typer.Exit(2)
232
+ render_explain(out, result, f)
233
+
234
+
235
+ @app.command()
236
+ def fix(
237
+ path: PathArg = Path("."),
238
+ interactive: Annotated[
239
+ bool,
240
+ typer.Option(
241
+ "--interactive", "-i", help="Review each patch and choose whether to apply it."
242
+ ),
243
+ ] = False,
244
+ output: Annotated[
245
+ Path | None,
246
+ typer.Option(
247
+ "--output",
248
+ "-o",
249
+ help="Write all patches to a unified diff file (apply with `git apply`).",
250
+ ),
251
+ ] = None,
252
+ only: Annotated[
253
+ list[str] | None, typer.Option("--only", help="Restrict to these finding IDs. Repeatable.")
254
+ ] = None,
255
+ deep: DeepOpt = False,
256
+ exclude: ExcludeOpt = None,
257
+ no_gitignore: NoIgnoreOpt = False,
258
+ ) -> None:
259
+ """Show, export or (with --interactive) apply suggested fixes. Files are never modified without your confirmation."""
260
+ result = _do_scan(path, deep, exclude, no_gitignore)
261
+ wanted = {o.upper() for o in only or []}
262
+ candidates = [
263
+ f
264
+ for f in result.findings
265
+ if f.fix.edits and (not wanted or any(f.id.upper().startswith(w) for w in wanted))
266
+ ]
267
+ manual = [
268
+ f
269
+ for f in result.findings
270
+ if not f.fix.edits and (not wanted or any(f.id.upper().startswith(w) for w in wanted))
271
+ ]
272
+ root = Path(result.root)
273
+ if not candidates:
274
+ out.print(
275
+ "[yellow]No automatic fixes available.[/]"
276
+ + (
277
+ f" {len(manual)} finding(s) need a manual fix; see `truthlint explain <ID>`."
278
+ if manual
279
+ else ""
280
+ )
281
+ )
282
+ return
283
+
284
+ if output is not None:
285
+ patches, errors = build_patches(root, candidates)
286
+ _write(write_patch(patches), output)
287
+ for e in errors:
288
+ err.print(Text(f"skipped: {e}", style="yellow"))
289
+ out.print(f"Review it, then apply with: [bold]git apply {escape(str(output))}[/]")
290
+ return
291
+
292
+ if not interactive:
293
+ out.print(
294
+ f"[bold]{len(candidates)}[/] finding(s) have proposed patches (dry run — nothing was changed):\n"
295
+ )
296
+ for f in candidates:
297
+ render_finding(out, f, verbose=True, root=root)
298
+ out.print(
299
+ Text(
300
+ "To apply: truthlint fix --interactive · To export: truthlint fix -o truthlint-fixes.patch",
301
+ style="bold",
302
+ )
303
+ )
304
+ if manual:
305
+ out.print(
306
+ Text(
307
+ f"{len(manual)} other finding(s) need a manual fix (see `truthlint explain <ID>`).",
308
+ style="dim",
309
+ )
310
+ )
311
+ return
312
+
313
+ if not _stdin_is_tty():
314
+ err.print("[red]--interactive needs a terminal.[/] Use --output to export a patch instead.")
315
+ raise typer.Exit(2)
316
+ applied, skipped = [], []
317
+ for i, f in enumerate(candidates, 1):
318
+ out.rule(f"[bold]{i}/{len(candidates)}[/]")
319
+ render_finding(out, f, verbose=False, root=root)
320
+ patches, errors = finding_patches(root, f)
321
+ if errors:
322
+ out.print(Text(f"Cannot build patch: {'; '.join(errors)}", style="yellow"))
323
+ skipped.append(f.id)
324
+ continue
325
+ out.print(
326
+ Syntax(
327
+ "".join(p.diff for p in patches),
328
+ "diff",
329
+ theme="ansi_dark",
330
+ background_color="default",
331
+ )
332
+ )
333
+ choice = Prompt.ask("Apply this change?", choices=["y", "n", "q"], default="n")
334
+ if choice == "q":
335
+ break
336
+ if choice != "y":
337
+ skipped.append(f.id)
338
+ continue
339
+ try:
340
+ changed = apply_finding(root, f)
341
+ applied.append(f.id)
342
+ out.print(Text("✔ applied → " + ", ".join(changed), style="green"))
343
+ except (EditConflict, OSError, ValueError) as exc:
344
+ out.print(Text(f"✘ not applied: {exc}", style="red"))
345
+ skipped.append(f.id)
346
+ out.print()
347
+ out.print(
348
+ f"[bold]{len(applied)}[/] applied, {len(skipped)} skipped. Re-run [bold]truthlint scan[/] to see the updated score."
349
+ )
350
+
351
+
352
+ @app.command()
353
+ def rules() -> None:
354
+ """List every rule truthlint checks."""
355
+ table = Table(box=box.SIMPLE_HEAD, show_lines=False)
356
+ table.add_column("Rule", style="bold cyan", no_wrap=True)
357
+ table.add_column("Title")
358
+ table.add_column("Category", style="dim")
359
+ table.add_column("Severity", no_wrap=True)
360
+ table.add_column("Mode", style="dim")
361
+ for r in sorted(RULES.values(), key=lambda r: r.id):
362
+ table.add_row(
363
+ r.id,
364
+ r.title,
365
+ r.category.value,
366
+ r.default_severity.value,
367
+ "deep" if r.id in DEEP_ONLY else "default",
368
+ )
369
+ out.print(table)
370
+ out.print("[dim]Run `truthlint explain TL201` for details on any rule.[/]")
371
+
372
+
373
+ @app.command()
374
+ def inspect(
375
+ path: PathArg = Path("."),
376
+ exclude: ExcludeOpt = None,
377
+ no_gitignore: NoIgnoreOpt = False,
378
+ ) -> None:
379
+ """Show what truthlint understands about a repository (languages, tooling, key files)."""
380
+ result = _do_scan(path, False, exclude, no_gitignore)
381
+ render_profile(out, result)
382
+ table = Table(box=box.SIMPLE_HEAD)
383
+ table.add_column("Group", style="bold")
384
+ table.add_column("Files")
385
+ for group, files in result.profile.key_files.items():
386
+ table.add_row(
387
+ group,
388
+ "\n".join(files[:12]) + (f"\n… {len(files) - 12} more" if len(files) > 12 else ""),
389
+ )
390
+ out.print(table)
391
+
392
+
393
+ def _stdin_is_tty() -> bool:
394
+ return sys.stdin.isatty()
395
+
396
+
397
+ def _utf8_stdio() -> None:
398
+ """Legacy Windows consoles default to cp1252, which cannot print the UI glyphs."""
399
+ for stream in (sys.stdout, sys.stderr):
400
+ enc = (getattr(stream, "encoding", None) or "").lower().replace("-", "")
401
+ if enc != "utf8" and hasattr(stream, "reconfigure"):
402
+ with contextlib.suppress(ValueError, OSError):
403
+ stream.reconfigure(encoding="utf-8", errors="replace")
404
+
405
+
406
+ def main() -> None:
407
+ _utf8_stdio()
408
+ app()
truthlint/config.py ADDED
@@ -0,0 +1,57 @@
1
+ """User configuration: ``.truthlint.toml`` or ``[tool.truthlint]`` in ``pyproject.toml``.
2
+
3
+ Example::
4
+
5
+ [tool.truthlint]
6
+ exclude = ["examples/**", "vendor/"]
7
+ disable = ["TL402"]
8
+ ignore_env = ["MY_CI_ONLY_VAR"]
9
+ min_severity = "low"
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import tomllib
15
+ from pathlib import Path
16
+
17
+ from pydantic import BaseModel, Field, ValidationError, field_validator
18
+
19
+ from .models import Severity
20
+
21
+
22
+ class TraceConfig(BaseModel):
23
+ exclude: list[str] = Field(default_factory=list)
24
+ disable: list[str] = Field(default_factory=list)
25
+ ignore_env: list[str] = Field(default_factory=list)
26
+ min_severity: Severity = Severity.INFO
27
+ max_file_size: int = 1_000_000
28
+ max_files: int = 50_000
29
+
30
+ @field_validator("disable")
31
+ @classmethod
32
+ def _upper(cls, v: list[str]) -> list[str]:
33
+ return [x.strip().upper() for x in v]
34
+
35
+
36
+ def load_config(root: Path) -> tuple[TraceConfig, str | None, str | None]:
37
+ """Return (config, source_path, error_message)."""
38
+ candidates = [root / ".truthlint.toml", root / "pyproject.toml"]
39
+ for path in candidates:
40
+ if not path.is_file():
41
+ continue
42
+ try:
43
+ data = tomllib.loads(path.read_text(encoding="utf-8", errors="replace"))
44
+ except tomllib.TOMLDecodeError as exc:
45
+ if path.name == ".truthlint.toml":
46
+ return TraceConfig(), path.name, f"invalid TOML: {exc}"
47
+ continue # malformed pyproject is reported by the TL001 rule instead
48
+ section = data if path.name == ".truthlint.toml" else data.get("tool", {}).get("truthlint")
49
+ if section is None:
50
+ continue
51
+ if path.name == ".truthlint.toml" and "tool" in section and "truthlint" in section["tool"]:
52
+ section = section["tool"]["truthlint"]
53
+ try:
54
+ return TraceConfig.model_validate(section), path.name, None
55
+ except ValidationError as exc:
56
+ return TraceConfig(), path.name, f"invalid configuration: {exc.errors()[0]['msg']}"
57
+ return TraceConfig(), None, None