invar-tools 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.
- invar/__init__.py +68 -0
- invar/contracts.py +152 -0
- invar/core/__init__.py +8 -0
- invar/core/contracts.py +375 -0
- invar/core/extraction.py +172 -0
- invar/core/formatter.py +281 -0
- invar/core/hypothesis_strategies.py +454 -0
- invar/core/inspect.py +154 -0
- invar/core/lambda_helpers.py +190 -0
- invar/core/models.py +289 -0
- invar/core/must_use.py +172 -0
- invar/core/parser.py +276 -0
- invar/core/property_gen.py +383 -0
- invar/core/purity.py +369 -0
- invar/core/purity_heuristics.py +184 -0
- invar/core/references.py +180 -0
- invar/core/rule_meta.py +203 -0
- invar/core/rules.py +435 -0
- invar/core/strategies.py +267 -0
- invar/core/suggestions.py +324 -0
- invar/core/tautology.py +137 -0
- invar/core/timeout_inference.py +114 -0
- invar/core/utils.py +364 -0
- invar/decorators.py +94 -0
- invar/invariant.py +57 -0
- invar/mcp/__init__.py +10 -0
- invar/mcp/__main__.py +13 -0
- invar/mcp/server.py +251 -0
- invar/py.typed +0 -0
- invar/resource.py +99 -0
- invar/shell/__init__.py +8 -0
- invar/shell/cli.py +358 -0
- invar/shell/config.py +248 -0
- invar/shell/fs.py +112 -0
- invar/shell/git.py +85 -0
- invar/shell/guard_helpers.py +324 -0
- invar/shell/guard_output.py +235 -0
- invar/shell/init_cmd.py +289 -0
- invar/shell/mcp_config.py +171 -0
- invar/shell/perception.py +125 -0
- invar/shell/property_tests.py +227 -0
- invar/shell/prove.py +460 -0
- invar/shell/prove_cache.py +133 -0
- invar/shell/prove_fallback.py +183 -0
- invar/shell/templates.py +443 -0
- invar/shell/test_cmd.py +117 -0
- invar/shell/testing.py +297 -0
- invar/shell/update_cmd.py +191 -0
- invar/templates/CLAUDE.md.template +58 -0
- invar/templates/INVAR.md +134 -0
- invar/templates/__init__.py +1 -0
- invar/templates/aider.conf.yml.template +29 -0
- invar/templates/context.md.template +51 -0
- invar/templates/cursorrules.template +28 -0
- invar/templates/examples/README.md +21 -0
- invar/templates/examples/contracts.py +111 -0
- invar/templates/examples/core_shell.py +121 -0
- invar/templates/pre-commit-config.yaml.template +44 -0
- invar/templates/proposal.md.template +93 -0
- invar_tools-1.0.0.dist-info/METADATA +321 -0
- invar_tools-1.0.0.dist-info/RECORD +64 -0
- invar_tools-1.0.0.dist-info/WHEEL +4 -0
- invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
invar/shell/cli.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands using Typer.
|
|
3
|
+
|
|
4
|
+
Shell module: handles user interaction and file I/O.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from returns.result import Failure, Result, Success
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _detect_agent_mode() -> bool:
|
|
19
|
+
"""Detect agent context: INVAR_MODE=agent OR non-TTY (pipe/redirect)."""
|
|
20
|
+
import sys
|
|
21
|
+
return os.getenv("INVAR_MODE") == "agent" or not sys.stdout.isatty()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
from invar import __version__
|
|
25
|
+
from invar.core.models import GuardReport, RuleConfig
|
|
26
|
+
from invar.core.rules import check_all_rules
|
|
27
|
+
from invar.core.utils import get_exit_code
|
|
28
|
+
from invar.shell.config import load_config
|
|
29
|
+
from invar.shell.fs import scan_project
|
|
30
|
+
from invar.shell.guard_output import output_agent, output_json, output_rich
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="invar",
|
|
34
|
+
help="AI-native software engineering framework",
|
|
35
|
+
add_completion=False,
|
|
36
|
+
)
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _count_core_functions(file_info) -> tuple[int, int]:
|
|
41
|
+
"""Count functions and functions with contracts in a Core file (P24)."""
|
|
42
|
+
from invar.core.models import SymbolKind
|
|
43
|
+
|
|
44
|
+
if not file_info.is_core:
|
|
45
|
+
return (0, 0)
|
|
46
|
+
|
|
47
|
+
total = 0
|
|
48
|
+
with_contracts = 0
|
|
49
|
+
for sym in file_info.symbols:
|
|
50
|
+
if sym.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
51
|
+
total += 1
|
|
52
|
+
if sym.contracts:
|
|
53
|
+
with_contracts += 1
|
|
54
|
+
return (total, with_contracts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _scan_and_check(
|
|
58
|
+
path: Path, config: RuleConfig, only_files: set[Path] | None = None
|
|
59
|
+
) -> Result[GuardReport, str]:
|
|
60
|
+
"""Scan project files and check against rules."""
|
|
61
|
+
report = GuardReport(files_checked=0)
|
|
62
|
+
for file_result in scan_project(path, only_files):
|
|
63
|
+
if isinstance(file_result, Failure):
|
|
64
|
+
console.print(f"[yellow]Warning:[/yellow] {file_result.failure()}")
|
|
65
|
+
continue
|
|
66
|
+
file_info = file_result.unwrap()
|
|
67
|
+
report.files_checked += 1
|
|
68
|
+
# P24: Track contract coverage for Core files
|
|
69
|
+
total, with_contracts = _count_core_functions(file_info)
|
|
70
|
+
report.update_coverage(total, with_contracts)
|
|
71
|
+
for violation in check_all_rules(file_info, config):
|
|
72
|
+
report.add_violation(violation)
|
|
73
|
+
return Success(report)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def guard(
|
|
78
|
+
path: Path = typer.Argument(
|
|
79
|
+
Path(), help="Project root directory", exists=True, file_okay=False, dir_okay=True
|
|
80
|
+
),
|
|
81
|
+
strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
|
|
82
|
+
no_strict_pure: bool = typer.Option(
|
|
83
|
+
False, "--no-strict-pure", help="Disable purity checks (internal imports, impure calls)"
|
|
84
|
+
),
|
|
85
|
+
pedantic: bool = typer.Option(
|
|
86
|
+
False, "--pedantic", help="Show all violations including off-by-default rules"
|
|
87
|
+
),
|
|
88
|
+
explain: bool = typer.Option(
|
|
89
|
+
False, "--explain", help="Show detailed explanations and limitations"
|
|
90
|
+
),
|
|
91
|
+
changed: bool = typer.Option(
|
|
92
|
+
False, "--changed", help="Only check git-modified files"
|
|
93
|
+
),
|
|
94
|
+
agent: bool = typer.Option(
|
|
95
|
+
False, "--agent", help="Output JSON with fix instructions for agents"
|
|
96
|
+
),
|
|
97
|
+
json_output: bool = typer.Option(
|
|
98
|
+
False, "--json", help="Output as JSON (simple format, no fix instructions)"
|
|
99
|
+
),
|
|
100
|
+
static: bool = typer.Option(
|
|
101
|
+
False, "--static", help="Static analysis only, skip all runtime tests"
|
|
102
|
+
),
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Check project against Invar architecture rules.
|
|
105
|
+
|
|
106
|
+
Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
|
|
107
|
+
Use --static for quick static-only checks (~0.5s vs ~5s full).
|
|
108
|
+
|
|
109
|
+
DX-19: Simplified to 2 levels (Zero decisions).
|
|
110
|
+
"""
|
|
111
|
+
from invar.shell.guard_helpers import (
|
|
112
|
+
collect_files_to_check,
|
|
113
|
+
handle_changed_mode,
|
|
114
|
+
output_verification_status,
|
|
115
|
+
run_crosshair_phase,
|
|
116
|
+
run_doctests_phase,
|
|
117
|
+
run_property_tests_phase,
|
|
118
|
+
)
|
|
119
|
+
from invar.shell.testing import VerificationLevel
|
|
120
|
+
|
|
121
|
+
# Load and configure
|
|
122
|
+
config_result = load_config(path)
|
|
123
|
+
if isinstance(config_result, Failure):
|
|
124
|
+
console.print(f"[red]Error:[/red] {config_result.failure()}")
|
|
125
|
+
raise typer.Exit(1)
|
|
126
|
+
|
|
127
|
+
config = config_result.unwrap()
|
|
128
|
+
if no_strict_pure:
|
|
129
|
+
config.strict_pure = False
|
|
130
|
+
if pedantic:
|
|
131
|
+
config.severity_overrides = {}
|
|
132
|
+
|
|
133
|
+
# Handle --changed mode
|
|
134
|
+
only_files: set[Path] | None = None
|
|
135
|
+
checked_files: list[Path] = []
|
|
136
|
+
if changed:
|
|
137
|
+
changed_result = handle_changed_mode(path)
|
|
138
|
+
if isinstance(changed_result, Failure):
|
|
139
|
+
if changed_result.failure() == "NO_CHANGES":
|
|
140
|
+
console.print("[green]No changed Python files.[/green]")
|
|
141
|
+
raise typer.Exit(0)
|
|
142
|
+
console.print(f"[red]Error:[/red] {changed_result.failure()}")
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
only_files, checked_files = changed_result.unwrap()
|
|
145
|
+
|
|
146
|
+
# Run static analysis
|
|
147
|
+
scan_result = _scan_and_check(path, config, only_files)
|
|
148
|
+
if isinstance(scan_result, Failure):
|
|
149
|
+
console.print(f"[red]Error:[/red] {scan_result.failure()}")
|
|
150
|
+
raise typer.Exit(1)
|
|
151
|
+
report = scan_result.unwrap()
|
|
152
|
+
|
|
153
|
+
# Determine output mode
|
|
154
|
+
use_agent_output, use_json_output = _determine_output_mode(
|
|
155
|
+
json_output, agent
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# DX-19: Simplified to 2 levels (STATIC or STANDARD)
|
|
159
|
+
verification_level = VerificationLevel.STATIC if static else VerificationLevel.STANDARD
|
|
160
|
+
level_name = "STATIC" if static else "STANDARD"
|
|
161
|
+
|
|
162
|
+
# Show verification level (human mode)
|
|
163
|
+
if not use_agent_output and not use_json_output:
|
|
164
|
+
_show_verification_level(verification_level)
|
|
165
|
+
|
|
166
|
+
# Run verification phases
|
|
167
|
+
static_exit_code = get_exit_code(report, strict)
|
|
168
|
+
doctest_passed, doctest_output = True, ""
|
|
169
|
+
crosshair_passed, crosshair_output = True, {}
|
|
170
|
+
property_passed, property_output = True, {}
|
|
171
|
+
|
|
172
|
+
# DX-19: STANDARD runs all verification phases
|
|
173
|
+
if verification_level == VerificationLevel.STANDARD and static_exit_code == 0:
|
|
174
|
+
checked_files = collect_files_to_check(path, checked_files)
|
|
175
|
+
|
|
176
|
+
# Phase 1: Doctests
|
|
177
|
+
doctest_passed, doctest_output = run_doctests_phase(checked_files, explain)
|
|
178
|
+
|
|
179
|
+
# Phase 2: CrossHair symbolic verification
|
|
180
|
+
crosshair_passed, crosshair_output = run_crosshair_phase(
|
|
181
|
+
path, checked_files, doctest_passed, static_exit_code,
|
|
182
|
+
changed_mode=changed,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Phase 3: Hypothesis property tests
|
|
186
|
+
property_passed, property_output = run_property_tests_phase(
|
|
187
|
+
checked_files, doctest_passed, static_exit_code
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Output results
|
|
191
|
+
if use_agent_output:
|
|
192
|
+
output_agent(
|
|
193
|
+
report, doctest_passed, doctest_output, crosshair_output, level_name,
|
|
194
|
+
property_output=property_output,
|
|
195
|
+
)
|
|
196
|
+
elif use_json_output:
|
|
197
|
+
output_json(report)
|
|
198
|
+
else:
|
|
199
|
+
output_rich(report, config.strict_pure, changed, pedantic, explain)
|
|
200
|
+
output_verification_status(
|
|
201
|
+
verification_level, static_exit_code, doctest_passed,
|
|
202
|
+
doctest_output, crosshair_output, explain,
|
|
203
|
+
property_output=property_output,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Exit with combined status
|
|
207
|
+
all_passed = doctest_passed and crosshair_passed and property_passed
|
|
208
|
+
final_exit = static_exit_code if all_passed else 1
|
|
209
|
+
raise typer.Exit(final_exit)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _determine_output_mode(json_output: bool, agent: bool) -> tuple[bool, bool]:
|
|
213
|
+
"""Determine output mode based on flags and context."""
|
|
214
|
+
if json_output:
|
|
215
|
+
return False, True
|
|
216
|
+
if agent or _detect_agent_mode():
|
|
217
|
+
return True, False
|
|
218
|
+
return False, False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _show_verification_level(verification_level) -> None:
|
|
222
|
+
"""Show verification level in human-readable format.
|
|
223
|
+
|
|
224
|
+
DX-19: Simplified to 2 levels.
|
|
225
|
+
"""
|
|
226
|
+
from invar.shell.testing import VerificationLevel
|
|
227
|
+
|
|
228
|
+
labels = {
|
|
229
|
+
VerificationLevel.STATIC: "[yellow]--static[/yellow] (static only)",
|
|
230
|
+
VerificationLevel.STANDARD: "default (static + doctests + CrossHair + Hypothesis)",
|
|
231
|
+
}
|
|
232
|
+
console.print(f"[dim]Verification: {labels[verification_level]}[/dim]")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def version() -> None:
|
|
237
|
+
"""Show Invar version."""
|
|
238
|
+
console.print(f"invar {__version__}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.command("map")
|
|
242
|
+
def map_command(
|
|
243
|
+
path: Path = typer.Argument(Path(), help="Project root directory"),
|
|
244
|
+
top: int = typer.Option(0, "--top", help="Show top N most-referenced symbols"),
|
|
245
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Generate symbol map with reference counts."""
|
|
248
|
+
from invar.shell.perception import run_map
|
|
249
|
+
|
|
250
|
+
# Phase 9 P11: Auto-detect agent mode
|
|
251
|
+
use_json = json_output or _detect_agent_mode()
|
|
252
|
+
result = run_map(path, top, use_json)
|
|
253
|
+
if isinstance(result, Failure):
|
|
254
|
+
console.print(f"[red]Error:[/red] {result.failure()}")
|
|
255
|
+
raise typer.Exit(1)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.command("sig")
|
|
259
|
+
def sig_command(
|
|
260
|
+
target: str = typer.Argument(..., help="File or file::symbol path"),
|
|
261
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Extract signatures from a file or symbol."""
|
|
264
|
+
from invar.shell.perception import run_sig
|
|
265
|
+
|
|
266
|
+
# Phase 9 P11: Auto-detect agent mode
|
|
267
|
+
use_json = json_output or _detect_agent_mode()
|
|
268
|
+
result = run_sig(target, use_json)
|
|
269
|
+
if isinstance(result, Failure):
|
|
270
|
+
console.print(f"[red]Error:[/red] {result.failure()}")
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@app.command()
|
|
275
|
+
def rules(
|
|
276
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
277
|
+
category: str = typer.Option(
|
|
278
|
+
None, "--category", "-c", help="Filter by category (size, contracts, purity, shell, docs)"
|
|
279
|
+
),
|
|
280
|
+
) -> None:
|
|
281
|
+
"""
|
|
282
|
+
List all Guard rules with their metadata.
|
|
283
|
+
|
|
284
|
+
Shows what each rule detects and its limitations.
|
|
285
|
+
"""
|
|
286
|
+
import json as json_lib
|
|
287
|
+
|
|
288
|
+
from invar.core.rule_meta import RULE_META, RuleCategory, get_rules_by_category
|
|
289
|
+
|
|
290
|
+
# Phase 9 P11: Auto-detect agent mode
|
|
291
|
+
use_json = json_output or _detect_agent_mode()
|
|
292
|
+
|
|
293
|
+
# Filter by category if specified
|
|
294
|
+
if category:
|
|
295
|
+
try:
|
|
296
|
+
cat = RuleCategory(category.lower())
|
|
297
|
+
rules_list = get_rules_by_category(cat)
|
|
298
|
+
except ValueError:
|
|
299
|
+
valid = ", ".join(c.value for c in RuleCategory)
|
|
300
|
+
console.print(f"[red]Error:[/red] Invalid category '{category}'. Valid: {valid}")
|
|
301
|
+
raise typer.Exit(1)
|
|
302
|
+
else:
|
|
303
|
+
rules_list = list(RULE_META.values())
|
|
304
|
+
|
|
305
|
+
if use_json:
|
|
306
|
+
# JSON output for agents
|
|
307
|
+
data = {
|
|
308
|
+
"rules": [
|
|
309
|
+
{
|
|
310
|
+
"name": r.name,
|
|
311
|
+
"severity": r.severity.value,
|
|
312
|
+
"category": r.category.value,
|
|
313
|
+
"detects": r.detects,
|
|
314
|
+
"cannot_detect": list(r.cannot_detect),
|
|
315
|
+
"hint": r.hint,
|
|
316
|
+
}
|
|
317
|
+
for r in rules_list
|
|
318
|
+
]
|
|
319
|
+
}
|
|
320
|
+
console.print(json_lib.dumps(data, indent=2))
|
|
321
|
+
else:
|
|
322
|
+
# Rich table output for humans
|
|
323
|
+
table = Table(title="Invar Guard Rules")
|
|
324
|
+
table.add_column("Rule", style="cyan")
|
|
325
|
+
table.add_column("Severity", style="yellow")
|
|
326
|
+
table.add_column("Category")
|
|
327
|
+
table.add_column("Detects")
|
|
328
|
+
table.add_column("Hint", style="green")
|
|
329
|
+
|
|
330
|
+
for r in rules_list:
|
|
331
|
+
sev_style = {"error": "red", "warning": "yellow", "info": "blue"}.get(
|
|
332
|
+
r.severity.value, ""
|
|
333
|
+
)
|
|
334
|
+
table.add_row(
|
|
335
|
+
r.name,
|
|
336
|
+
f"[{sev_style}]{r.severity.value.upper()}[/{sev_style}]",
|
|
337
|
+
r.category.value,
|
|
338
|
+
r.detects[:50] + "..." if len(r.detects) > 50 else r.detects,
|
|
339
|
+
r.hint[:40] + "..." if len(r.hint) > 40 else r.hint,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
console.print(table)
|
|
343
|
+
console.print(f"\n[dim]{len(rules_list)} rules total. Use --json for full details.[/dim]")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# Import commands from separate modules to reduce file size
|
|
347
|
+
from invar.shell.init_cmd import init
|
|
348
|
+
from invar.shell.test_cmd import test, verify
|
|
349
|
+
from invar.shell.update_cmd import update
|
|
350
|
+
|
|
351
|
+
app.command()(init)
|
|
352
|
+
app.command()(update)
|
|
353
|
+
app.command()(test)
|
|
354
|
+
app.command()(verify)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__":
|
|
358
|
+
app()
|
invar/shell/config.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loading from multiple sources.
|
|
3
|
+
|
|
4
|
+
Shell module: performs file I/O to load configuration.
|
|
5
|
+
|
|
6
|
+
Configuration sources (priority order):
|
|
7
|
+
1. pyproject.toml [tool.invar.guard]
|
|
8
|
+
2. invar.toml [guard]
|
|
9
|
+
3. .invar/config.toml [guard]
|
|
10
|
+
4. Built-in defaults
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tomllib
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
17
|
+
|
|
18
|
+
from returns.result import Failure, Result, Success
|
|
19
|
+
|
|
20
|
+
from invar.core.models import RuleConfig
|
|
21
|
+
from invar.core.utils import (
|
|
22
|
+
extract_guard_section,
|
|
23
|
+
matches_path_prefix,
|
|
24
|
+
matches_pattern,
|
|
25
|
+
parse_guard_config,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
ConfigSource = Literal["pyproject", "invar", "invar_dir", "default"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _find_config_source(project_root: Path) -> Result[tuple[Path | None, ConfigSource], str]:
|
|
35
|
+
"""
|
|
36
|
+
Find the first available config file.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Result containing tuple of (config_path, source_type)
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
>>> from pathlib import Path
|
|
43
|
+
>>> import tempfile
|
|
44
|
+
>>> with tempfile.TemporaryDirectory() as tmpdir:
|
|
45
|
+
... root = Path(tmpdir)
|
|
46
|
+
... result = _find_config_source(root)
|
|
47
|
+
... result.unwrap()[1]
|
|
48
|
+
'default'
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
pyproject = project_root / "pyproject.toml"
|
|
52
|
+
if pyproject.exists():
|
|
53
|
+
return Success((pyproject, "pyproject"))
|
|
54
|
+
|
|
55
|
+
invar_toml = project_root / "invar.toml"
|
|
56
|
+
if invar_toml.exists():
|
|
57
|
+
return Success((invar_toml, "invar"))
|
|
58
|
+
|
|
59
|
+
invar_config = project_root / ".invar" / "config.toml"
|
|
60
|
+
if invar_config.exists():
|
|
61
|
+
return Success((invar_config, "invar_dir"))
|
|
62
|
+
|
|
63
|
+
return Success((None, "default"))
|
|
64
|
+
except OSError as e:
|
|
65
|
+
return Failure(f"Failed to find config: {e}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_toml(path: Path) -> Result[dict[str, Any], str]:
|
|
69
|
+
"""Read and parse a TOML file."""
|
|
70
|
+
try:
|
|
71
|
+
content = path.read_text(encoding="utf-8")
|
|
72
|
+
return Success(tomllib.loads(content))
|
|
73
|
+
except tomllib.TOMLDecodeError as e:
|
|
74
|
+
return Failure(f"Invalid TOML in {path.name}: {e}")
|
|
75
|
+
except OSError as e:
|
|
76
|
+
return Failure(f"Failed to read {path.name}: {e}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_config(project_root: Path) -> Result[RuleConfig, str]:
|
|
80
|
+
"""
|
|
81
|
+
Load Invar configuration from available sources.
|
|
82
|
+
|
|
83
|
+
Tries sources in priority order:
|
|
84
|
+
1. pyproject.toml [tool.invar.guard]
|
|
85
|
+
2. invar.toml [guard]
|
|
86
|
+
3. .invar/config.toml [guard]
|
|
87
|
+
4. Built-in defaults
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
project_root: Path to project root directory
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Result containing RuleConfig or error message
|
|
94
|
+
"""
|
|
95
|
+
find_result = _find_config_source(project_root)
|
|
96
|
+
if isinstance(find_result, Failure):
|
|
97
|
+
return find_result
|
|
98
|
+
config_path, source = find_result.unwrap()
|
|
99
|
+
|
|
100
|
+
if source == "default":
|
|
101
|
+
return Success(RuleConfig())
|
|
102
|
+
|
|
103
|
+
assert config_path is not None # source != "default" guarantees path exists
|
|
104
|
+
result = _read_toml(config_path)
|
|
105
|
+
|
|
106
|
+
if isinstance(result, Failure):
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
data = result.unwrap()
|
|
110
|
+
guard_config = extract_guard_section(data, source)
|
|
111
|
+
|
|
112
|
+
# For pyproject.toml, if no [tool.invar.guard] section, use defaults
|
|
113
|
+
if source == "pyproject" and not guard_config:
|
|
114
|
+
return Success(RuleConfig())
|
|
115
|
+
|
|
116
|
+
return Success(parse_guard_config(guard_config))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Default paths for Core/Shell classification
|
|
120
|
+
_DEFAULT_CORE_PATHS = ["src/core", "core"]
|
|
121
|
+
_DEFAULT_SHELL_PATHS = ["src/shell", "shell"]
|
|
122
|
+
|
|
123
|
+
# Default exclude paths
|
|
124
|
+
_DEFAULT_EXCLUDE_PATHS = [
|
|
125
|
+
"tests",
|
|
126
|
+
"test",
|
|
127
|
+
"scripts",
|
|
128
|
+
".venv",
|
|
129
|
+
"venv",
|
|
130
|
+
".env",
|
|
131
|
+
"__pycache__",
|
|
132
|
+
".pytest_cache",
|
|
133
|
+
".mypy_cache",
|
|
134
|
+
".ruff_cache",
|
|
135
|
+
".git",
|
|
136
|
+
".hg",
|
|
137
|
+
".svn",
|
|
138
|
+
"node_modules",
|
|
139
|
+
"dist",
|
|
140
|
+
"build",
|
|
141
|
+
".tox",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _get_classification_config(project_root: Path) -> Result[dict[str, Any], str]:
|
|
146
|
+
"""Get classification-related config (paths and patterns)."""
|
|
147
|
+
find_result = _find_config_source(project_root)
|
|
148
|
+
if isinstance(find_result, Failure):
|
|
149
|
+
return Success({}) # Return empty on error
|
|
150
|
+
config_path, source = find_result.unwrap()
|
|
151
|
+
|
|
152
|
+
if source == "default":
|
|
153
|
+
return Success({})
|
|
154
|
+
|
|
155
|
+
assert config_path is not None
|
|
156
|
+
result = _read_toml(config_path)
|
|
157
|
+
|
|
158
|
+
if isinstance(result, Failure):
|
|
159
|
+
return Success({}) # Return empty on error
|
|
160
|
+
|
|
161
|
+
data = result.unwrap()
|
|
162
|
+
return Success(extract_guard_section(data, source))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_path_classification(project_root: Path) -> Result[tuple[list[str], list[str]], str]:
|
|
166
|
+
"""
|
|
167
|
+
Get Core and Shell path prefixes from configuration.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Result containing tuple of (core_paths, shell_paths)
|
|
171
|
+
"""
|
|
172
|
+
config_result = _get_classification_config(project_root)
|
|
173
|
+
guard_config = config_result.unwrap() if isinstance(config_result, Success) else {}
|
|
174
|
+
|
|
175
|
+
core_paths = guard_config.get("core_paths", _DEFAULT_CORE_PATHS)
|
|
176
|
+
shell_paths = guard_config.get("shell_paths", _DEFAULT_SHELL_PATHS)
|
|
177
|
+
|
|
178
|
+
return Success((core_paths, shell_paths))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_pattern_classification(project_root: Path) -> Result[tuple[list[str], list[str]], str]:
|
|
182
|
+
"""
|
|
183
|
+
Get Core and Shell glob patterns from configuration.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Result containing tuple of (core_patterns, shell_patterns)
|
|
187
|
+
"""
|
|
188
|
+
config_result = _get_classification_config(project_root)
|
|
189
|
+
guard_config = config_result.unwrap() if isinstance(config_result, Success) else {}
|
|
190
|
+
|
|
191
|
+
core_patterns = guard_config.get("core_patterns", [])
|
|
192
|
+
shell_patterns = guard_config.get("shell_patterns", [])
|
|
193
|
+
|
|
194
|
+
return Success((core_patterns, shell_patterns))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_exclude_paths(project_root: Path) -> Result[list[str], str]:
|
|
198
|
+
"""
|
|
199
|
+
Get paths to exclude from checking.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Result containing list of path patterns to exclude
|
|
203
|
+
"""
|
|
204
|
+
config_result = _get_classification_config(project_root)
|
|
205
|
+
guard_config = config_result.unwrap() if isinstance(config_result, Success) else {}
|
|
206
|
+
return Success(guard_config.get("exclude_paths", _DEFAULT_EXCLUDE_PATHS.copy()))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def classify_file(file_path: str, project_root: Path) -> Result[tuple[bool, bool], str]:
|
|
210
|
+
"""
|
|
211
|
+
Classify a file as Core, Shell, or neither.
|
|
212
|
+
|
|
213
|
+
Priority: patterns > paths > uncategorized.
|
|
214
|
+
|
|
215
|
+
Examples:
|
|
216
|
+
>>> import tempfile
|
|
217
|
+
>>> from pathlib import Path
|
|
218
|
+
>>> with tempfile.TemporaryDirectory() as tmpdir:
|
|
219
|
+
... root = Path(tmpdir)
|
|
220
|
+
... result = classify_file("src/core/logic.py", root)
|
|
221
|
+
... result.unwrap()[0]
|
|
222
|
+
True
|
|
223
|
+
"""
|
|
224
|
+
pattern_result = get_pattern_classification(project_root)
|
|
225
|
+
core_patterns, shell_patterns = (
|
|
226
|
+
pattern_result.unwrap() if isinstance(pattern_result, Success) else ([], [])
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
path_result = get_path_classification(project_root)
|
|
230
|
+
core_paths, shell_paths = (
|
|
231
|
+
path_result.unwrap()
|
|
232
|
+
if isinstance(path_result, Success)
|
|
233
|
+
else (_DEFAULT_CORE_PATHS, _DEFAULT_SHELL_PATHS)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Priority 1: Pattern-based classification
|
|
237
|
+
if core_patterns and matches_pattern(file_path, core_patterns):
|
|
238
|
+
return Success((True, False))
|
|
239
|
+
if shell_patterns and matches_pattern(file_path, shell_patterns):
|
|
240
|
+
return Success((False, True))
|
|
241
|
+
|
|
242
|
+
# Priority 2: Path-based classification
|
|
243
|
+
if matches_path_prefix(file_path, core_paths):
|
|
244
|
+
return Success((True, False))
|
|
245
|
+
if matches_path_prefix(file_path, shell_paths):
|
|
246
|
+
return Success((False, True))
|
|
247
|
+
|
|
248
|
+
return Success((False, False))
|