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.
- truthlint/__init__.py +10 -0
- truthlint/__main__.py +4 -0
- truthlint/cli.py +408 -0
- truthlint/config.py +57 -0
- truthlint/context.py +440 -0
- truthlint/engine.py +131 -0
- truthlint/fixes.py +132 -0
- truthlint/ignore.py +129 -0
- truthlint/intel.py +237 -0
- truthlint/models.py +198 -0
- truthlint/onboarding.py +227 -0
- truthlint/parsers/__init__.py +1 -0
- truthlint/parsers/ci.py +201 -0
- truthlint/parsers/compose.py +101 -0
- truthlint/parsers/dockerfile.py +150 -0
- truthlint/parsers/envfile.py +41 -0
- truthlint/parsers/lines.py +78 -0
- truthlint/parsers/makefile.py +119 -0
- truthlint/parsers/markdown.py +193 -0
- truthlint/parsers/node_meta.py +139 -0
- truthlint/parsers/python_meta.py +467 -0
- truthlint/parsers/shell.py +235 -0
- truthlint/parsers/source.py +506 -0
- truthlint/parsers/versions.py +273 -0
- truthlint/parsers/yamlx.py +65 -0
- truthlint/py.typed +0 -0
- truthlint/render/__init__.py +1 -0
- truthlint/render/json_report.py +36 -0
- truthlint/render/markdown.py +158 -0
- truthlint/render/terminal.py +355 -0
- truthlint/rules/__init__.py +34 -0
- truthlint/rules/base.py +85 -0
- truthlint/rules/build.py +188 -0
- truthlint/rules/commands.py +1671 -0
- truthlint/rules/containers.py +403 -0
- truthlint/rules/deps.py +713 -0
- truthlint/rules/docs.py +416 -0
- truthlint/rules/env.py +380 -0
- truthlint/rules/runtime.py +552 -0
- truthlint/scoring.py +83 -0
- truthlint-0.1.0.dist-info/METADATA +281 -0
- truthlint-0.1.0.dist-info/RECORD +46 -0
- truthlint-0.1.0.dist-info/WHEEL +5 -0
- truthlint-0.1.0.dist-info/entry_points.txt +2 -0
- truthlint-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
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
|