invar-tools 1.4.0__py3-none-any.whl → 1.6.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 +7 -1
- invar/core/entry_points.py +12 -10
- invar/core/formatter.py +21 -1
- invar/core/models.py +98 -0
- invar/core/patterns/__init__.py +53 -0
- invar/core/patterns/detector.py +249 -0
- invar/core/patterns/p0_exhaustive.py +207 -0
- invar/core/patterns/p0_literal.py +307 -0
- invar/core/patterns/p0_newtype.py +211 -0
- invar/core/patterns/p0_nonempty.py +307 -0
- invar/core/patterns/p0_validation.py +278 -0
- invar/core/patterns/registry.py +234 -0
- invar/core/patterns/types.py +167 -0
- invar/core/trivial_detection.py +189 -0
- invar/mcp/server.py +4 -0
- invar/shell/commands/guard.py +100 -8
- invar/shell/config.py +46 -0
- invar/shell/contract_coverage.py +358 -0
- invar/shell/guard_output.py +15 -0
- invar/shell/pattern_integration.py +234 -0
- invar/shell/testing.py +13 -2
- invar/templates/CLAUDE.md.template +18 -10
- invar/templates/config/CLAUDE.md.jinja +52 -30
- invar/templates/config/context.md.jinja +14 -0
- invar/templates/protocol/INVAR.md +1 -0
- invar/templates/skills/develop/SKILL.md.jinja +51 -1
- invar/templates/skills/review/SKILL.md.jinja +196 -31
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/METADATA +12 -8
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/RECORD +34 -22
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Trivial contract detection for DX-63.
|
|
2
|
+
|
|
3
|
+
Pure logic module - no I/O. Detects contracts that provide no real constraint.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from deal import post, pre
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TrivialContract:
|
|
17
|
+
"""A trivial contract that provides no real constraint.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
>>> tc = TrivialContract(
|
|
21
|
+
... file="src/core/calc.py",
|
|
22
|
+
... line=10,
|
|
23
|
+
... function_name="process",
|
|
24
|
+
... contract_type="post",
|
|
25
|
+
... expression="lambda: True"
|
|
26
|
+
... )
|
|
27
|
+
>>> tc.contract_type
|
|
28
|
+
'post'
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
file: str
|
|
32
|
+
line: int
|
|
33
|
+
function_name: str
|
|
34
|
+
contract_type: str # "pre" or "post"
|
|
35
|
+
expression: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Patterns that match trivial contracts
|
|
39
|
+
TRIVIAL_PATTERNS: list[re.Pattern[str]] = [
|
|
40
|
+
re.compile(r"^\s*lambda\s*:\s*True\s*$"), # lambda: True
|
|
41
|
+
re.compile(r"^\s*lambda\s+\w+\s*:\s*True\s*$"), # lambda x: True
|
|
42
|
+
re.compile(r"^\s*lambda\s+[\w,\s]+:\s*True\s*$"), # lambda x, y: True
|
|
43
|
+
re.compile(r"^\s*lambda\s+\*\w+\s*:\s*True\s*$"), # lambda *args: True
|
|
44
|
+
re.compile(r"^\s*lambda\s+\*\*\w+\s*:\s*True\s*$"), # lambda **kwargs: True
|
|
45
|
+
re.compile(r"^\s*lambda\s+result\s*:\s*True\s*$"), # lambda result: True
|
|
46
|
+
re.compile(r"^\s*lambda\s+_\s*:\s*True\s*$"), # lambda _: True
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pre(lambda expression: len(expression.strip()) > 0)
|
|
51
|
+
@post(lambda result: isinstance(result, bool))
|
|
52
|
+
def is_trivial_contract(expression: str) -> bool:
|
|
53
|
+
"""Check if a contract expression is trivial (provides no constraint).
|
|
54
|
+
|
|
55
|
+
Trivial contracts always return True regardless of input, providing
|
|
56
|
+
no actual constraint on the function's behavior.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
>>> is_trivial_contract("lambda: True")
|
|
60
|
+
True
|
|
61
|
+
>>> is_trivial_contract("lambda x: True")
|
|
62
|
+
True
|
|
63
|
+
>>> is_trivial_contract("lambda x, y: True")
|
|
64
|
+
True
|
|
65
|
+
>>> is_trivial_contract("lambda result: True")
|
|
66
|
+
True
|
|
67
|
+
>>> is_trivial_contract("lambda x: x > 0")
|
|
68
|
+
False
|
|
69
|
+
>>> is_trivial_contract("lambda items: len(items) > 0")
|
|
70
|
+
False
|
|
71
|
+
>>> is_trivial_contract("lambda result: result >= 0")
|
|
72
|
+
False
|
|
73
|
+
"""
|
|
74
|
+
expr = expression.strip()
|
|
75
|
+
return any(pattern.match(expr) for pattern in TRIVIAL_PATTERNS)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pre(lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)))
|
|
79
|
+
@post(lambda result: all(t[0] in ("pre", "post") for t in result))
|
|
80
|
+
def extract_contracts_from_decorators(
|
|
81
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
82
|
+
) -> list[tuple[str, str]]:
|
|
83
|
+
"""Extract contract expressions from function decorators.
|
|
84
|
+
|
|
85
|
+
Returns list of (contract_type, expression) tuples.
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
>>> import ast
|
|
89
|
+
>>> code = '''
|
|
90
|
+
... @pre(lambda x: x > 0)
|
|
91
|
+
... @post(lambda result: result >= 0)
|
|
92
|
+
... def calc(x): return x * 2
|
|
93
|
+
... '''
|
|
94
|
+
>>> tree = ast.parse(code)
|
|
95
|
+
>>> func = tree.body[0]
|
|
96
|
+
>>> contracts = extract_contracts_from_decorators(func)
|
|
97
|
+
>>> len(contracts)
|
|
98
|
+
2
|
|
99
|
+
>>> contracts[0][0]
|
|
100
|
+
'pre'
|
|
101
|
+
"""
|
|
102
|
+
contracts = []
|
|
103
|
+
|
|
104
|
+
for decorator in node.decorator_list:
|
|
105
|
+
if isinstance(decorator, ast.Call):
|
|
106
|
+
# Get decorator name
|
|
107
|
+
if isinstance(decorator.func, ast.Name):
|
|
108
|
+
name = decorator.func.id
|
|
109
|
+
elif isinstance(decorator.func, ast.Attribute):
|
|
110
|
+
name = decorator.func.attr
|
|
111
|
+
else:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Check if it's a contract decorator
|
|
115
|
+
if name in ("pre", "post"):
|
|
116
|
+
# Get the first argument (the lambda or condition)
|
|
117
|
+
if decorator.args:
|
|
118
|
+
arg = decorator.args[0]
|
|
119
|
+
if isinstance(arg, ast.Lambda):
|
|
120
|
+
# Convert lambda back to source
|
|
121
|
+
expr = ast.unparse(arg)
|
|
122
|
+
contracts.append((name, expr))
|
|
123
|
+
|
|
124
|
+
return contracts
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pre(lambda source, file_path: len(source) >= 0 and len(file_path) > 0)
|
|
128
|
+
@post(lambda result: result[0] >= 0 and result[1] >= 0 and result[1] <= result[0])
|
|
129
|
+
def analyze_contracts_in_source(
|
|
130
|
+
source: str, file_path: str
|
|
131
|
+
) -> tuple[int, int, list[TrivialContract]]:
|
|
132
|
+
"""Analyze contracts in Python source code.
|
|
133
|
+
|
|
134
|
+
Pure function - receives source as string, no file I/O.
|
|
135
|
+
|
|
136
|
+
Returns: (total_functions, functions_with_contracts, trivial_contracts)
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
>>> source = '''
|
|
140
|
+
... from deal import pre, post
|
|
141
|
+
... @pre(lambda x: x > 0)
|
|
142
|
+
... def good(x): return x
|
|
143
|
+
... def no_contract(x): return x
|
|
144
|
+
... @post(lambda: True)
|
|
145
|
+
... def trivial(x): return x
|
|
146
|
+
... '''
|
|
147
|
+
>>> total, with_c, trivials = analyze_contracts_in_source(source, "test.py")
|
|
148
|
+
>>> total
|
|
149
|
+
3
|
|
150
|
+
>>> with_c
|
|
151
|
+
2
|
|
152
|
+
>>> len(trivials)
|
|
153
|
+
1
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
tree = ast.parse(source)
|
|
157
|
+
except SyntaxError:
|
|
158
|
+
return (0, 0, [])
|
|
159
|
+
|
|
160
|
+
total_functions = 0
|
|
161
|
+
functions_with_contracts = 0
|
|
162
|
+
trivial_contracts: list[TrivialContract] = []
|
|
163
|
+
|
|
164
|
+
for node in ast.walk(tree):
|
|
165
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
166
|
+
# Skip private/dunder methods
|
|
167
|
+
if node.name.startswith("_"):
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
total_functions += 1
|
|
171
|
+
contracts = extract_contracts_from_decorators(node)
|
|
172
|
+
|
|
173
|
+
if contracts:
|
|
174
|
+
functions_with_contracts += 1
|
|
175
|
+
|
|
176
|
+
# Check for trivial contracts
|
|
177
|
+
for contract_type, expr in contracts:
|
|
178
|
+
if is_trivial_contract(expr):
|
|
179
|
+
trivial_contracts.append(
|
|
180
|
+
TrivialContract(
|
|
181
|
+
file=file_path,
|
|
182
|
+
line=node.lineno,
|
|
183
|
+
function_name=node.name,
|
|
184
|
+
contract_type=contract_type,
|
|
185
|
+
expression=expr,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return (total_functions, functions_with_contracts, trivial_contracts)
|
invar/mcp/server.py
CHANGED
|
@@ -137,6 +137,7 @@ def _get_guard_tool() -> Tool:
|
|
|
137
137
|
"changed": {"type": "boolean", "description": "Only verify git-changed files", "default": True},
|
|
138
138
|
"strict": {"type": "boolean", "description": "Treat warnings as errors", "default": False},
|
|
139
139
|
"coverage": {"type": "boolean", "description": "DX-37: Collect branch coverage from doctest + hypothesis", "default": False},
|
|
140
|
+
"contracts_only": {"type": "boolean", "description": "DX-63: Contract coverage check only (skip tests)", "default": False},
|
|
140
141
|
},
|
|
141
142
|
},
|
|
142
143
|
)
|
|
@@ -223,6 +224,9 @@ async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
|
|
|
223
224
|
# DX-37: Optional coverage collection
|
|
224
225
|
if args.get("coverage", False):
|
|
225
226
|
cmd.append("--coverage")
|
|
227
|
+
# DX-63: Contract coverage check only
|
|
228
|
+
if args.get("contracts_only", False):
|
|
229
|
+
cmd.append("--contracts-only")
|
|
226
230
|
|
|
227
231
|
# DX-26: TTY auto-detection - MCP runs in non-TTY, so agent JSON output is automatic
|
|
228
232
|
# No explicit flag needed
|
invar/shell/commands/guard.py
CHANGED
|
@@ -25,7 +25,7 @@ from invar import __version__
|
|
|
25
25
|
from invar.core.models import GuardReport, RuleConfig
|
|
26
26
|
from invar.core.rules import check_all_rules
|
|
27
27
|
from invar.core.utils import get_exit_code
|
|
28
|
-
from invar.shell.config import load_config
|
|
28
|
+
from invar.shell.config import find_project_root, load_config
|
|
29
29
|
from invar.shell.fs import scan_project
|
|
30
30
|
from invar.shell.guard_output import output_agent, output_rich
|
|
31
31
|
|
|
@@ -56,13 +56,13 @@ def _count_core_functions(file_info) -> tuple[int, int]:
|
|
|
56
56
|
return (total, with_contracts)
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
# @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
|
|
60
59
|
# @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
|
|
61
60
|
def _scan_and_check(
|
|
62
61
|
path: Path, config: RuleConfig, only_files: set[Path] | None = None
|
|
63
62
|
) -> Result[GuardReport, str]:
|
|
64
63
|
"""Scan project files and check against rules."""
|
|
65
64
|
from invar.core.entry_points import extract_escape_hatches
|
|
65
|
+
from invar.core.models import EscapeHatchDetail
|
|
66
66
|
from invar.core.review_trigger import check_duplicate_escape_reasons
|
|
67
67
|
from invar.core.shell_architecture import check_complexity_debt
|
|
68
68
|
|
|
@@ -80,10 +80,17 @@ def _scan_and_check(
|
|
|
80
80
|
report.update_coverage(total, with_contracts)
|
|
81
81
|
for violation in check_all_rules(file_info, config):
|
|
82
82
|
report.add_violation(violation)
|
|
83
|
-
# DX-33: Collect escape hatches for cross-file analysis
|
|
83
|
+
# DX-33 + DX-66: Collect escape hatches for cross-file analysis and visibility
|
|
84
84
|
if file_info.source:
|
|
85
|
-
for rule, reason in extract_escape_hatches(file_info.source):
|
|
85
|
+
for rule, reason, line in extract_escape_hatches(file_info.source):
|
|
86
86
|
all_escapes.append((file_info.path, rule, reason))
|
|
87
|
+
# DX-66: Add to escape hatch summary
|
|
88
|
+
report.escape_hatches.add(EscapeHatchDetail(
|
|
89
|
+
file=file_info.path,
|
|
90
|
+
line=line,
|
|
91
|
+
rule=rule,
|
|
92
|
+
reason=reason,
|
|
93
|
+
))
|
|
87
94
|
|
|
88
95
|
# DX-22: Check project-level complexity debt (Fix-or-Explain enforcement)
|
|
89
96
|
for debt_violation in check_complexity_debt(
|
|
@@ -102,7 +109,11 @@ def _scan_and_check(
|
|
|
102
109
|
@app.command()
|
|
103
110
|
def guard(
|
|
104
111
|
path: Path = typer.Argument(
|
|
105
|
-
Path(),
|
|
112
|
+
Path(),
|
|
113
|
+
help="Project directory or single Python file",
|
|
114
|
+
exists=True,
|
|
115
|
+
file_okay=True,
|
|
116
|
+
dir_okay=True,
|
|
106
117
|
),
|
|
107
118
|
strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
|
|
108
119
|
changed: bool = typer.Option(
|
|
@@ -133,11 +144,19 @@ def guard(
|
|
|
133
144
|
coverage: bool = typer.Option(
|
|
134
145
|
False, "--coverage", help="DX-37: Collect branch coverage from doctest + hypothesis"
|
|
135
146
|
),
|
|
147
|
+
suggest: bool = typer.Option(
|
|
148
|
+
False, "--suggest", help="DX-61: Show functional pattern suggestions"
|
|
149
|
+
),
|
|
150
|
+
contracts_only: bool = typer.Option(
|
|
151
|
+
False, "--contracts-only", "-c", help="DX-63: Contract coverage check only"
|
|
152
|
+
),
|
|
136
153
|
) -> None:
|
|
137
154
|
"""Check project against Invar architecture rules.
|
|
138
155
|
|
|
139
156
|
Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
|
|
140
157
|
Use --static for quick static-only checks (~0.5s vs ~5s full).
|
|
158
|
+
Use --suggest to get functional pattern suggestions (NewType, Validation, etc.).
|
|
159
|
+
Use --contracts-only (-c) to check contract coverage without running tests (DX-63).
|
|
141
160
|
"""
|
|
142
161
|
from invar.shell.guard_helpers import (
|
|
143
162
|
collect_files_to_check,
|
|
@@ -149,6 +168,16 @@ def guard(
|
|
|
149
168
|
)
|
|
150
169
|
from invar.shell.testing import VerificationLevel
|
|
151
170
|
|
|
171
|
+
# DX-65: Handle single file mode
|
|
172
|
+
single_file_mode = path.is_file()
|
|
173
|
+
single_file: Path | None = None
|
|
174
|
+
if single_file_mode:
|
|
175
|
+
if path.suffix != ".py":
|
|
176
|
+
console.print(f"[red]Error:[/red] {path} is not a Python file")
|
|
177
|
+
raise typer.Exit(1)
|
|
178
|
+
single_file = path.resolve()
|
|
179
|
+
path = find_project_root(path)
|
|
180
|
+
|
|
152
181
|
# Load and configure
|
|
153
182
|
config_result = load_config(path)
|
|
154
183
|
if isinstance(config_result, Failure):
|
|
@@ -161,10 +190,41 @@ def guard(
|
|
|
161
190
|
if pedantic:
|
|
162
191
|
config.severity_overrides = {}
|
|
163
192
|
|
|
164
|
-
#
|
|
193
|
+
# DX-63: Contract coverage check only mode
|
|
194
|
+
if contracts_only:
|
|
195
|
+
import json
|
|
196
|
+
|
|
197
|
+
from invar.shell.contract_coverage import (
|
|
198
|
+
calculate_contract_coverage,
|
|
199
|
+
format_contract_coverage_agent,
|
|
200
|
+
format_contract_coverage_report,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# DX-65: Use single file path if in single file mode
|
|
204
|
+
coverage_path = single_file if single_file else path
|
|
205
|
+
coverage_result = calculate_contract_coverage(coverage_path, changed_only=changed)
|
|
206
|
+
if isinstance(coverage_result, Failure):
|
|
207
|
+
console.print(f"[red]Error:[/red] {coverage_result.failure()}")
|
|
208
|
+
raise typer.Exit(1)
|
|
209
|
+
|
|
210
|
+
report_data = coverage_result.unwrap()
|
|
211
|
+
use_agent_output = _determine_output_mode(human, agent, json_output)
|
|
212
|
+
|
|
213
|
+
if use_agent_output:
|
|
214
|
+
console.print(json.dumps(format_contract_coverage_agent(report_data)))
|
|
215
|
+
else:
|
|
216
|
+
console.print(format_contract_coverage_report(report_data))
|
|
217
|
+
|
|
218
|
+
raise typer.Exit(0 if report_data.ready_for_build else 1)
|
|
219
|
+
|
|
220
|
+
# Handle --changed mode or single file mode (DX-65)
|
|
165
221
|
only_files: set[Path] | None = None
|
|
166
222
|
checked_files: list[Path] = []
|
|
167
|
-
if
|
|
223
|
+
if single_file:
|
|
224
|
+
# DX-65: Single file mode - only check the specified file
|
|
225
|
+
only_files = {single_file}
|
|
226
|
+
checked_files = [single_file]
|
|
227
|
+
elif changed:
|
|
168
228
|
changed_result = handle_changed_mode(path)
|
|
169
229
|
if isinstance(changed_result, Failure):
|
|
170
230
|
if changed_result.failure() == "NO_CHANGES":
|
|
@@ -181,6 +241,25 @@ def guard(
|
|
|
181
241
|
raise typer.Exit(1)
|
|
182
242
|
report = scan_result.unwrap()
|
|
183
243
|
|
|
244
|
+
# DX-61: Run pattern detection if --suggest flag is set
|
|
245
|
+
pattern_suggestions: list = []
|
|
246
|
+
if suggest:
|
|
247
|
+
from invar.shell.pattern_integration import (
|
|
248
|
+
filter_suggestions,
|
|
249
|
+
run_pattern_detection,
|
|
250
|
+
suggestions_to_violations,
|
|
251
|
+
)
|
|
252
|
+
# Run pattern detection on checked files
|
|
253
|
+
files_to_check = list(only_files) if only_files else None
|
|
254
|
+
pattern_result = run_pattern_detection(path, files_to_check)
|
|
255
|
+
if isinstance(pattern_result, Success):
|
|
256
|
+
raw_suggestions = pattern_result.unwrap()
|
|
257
|
+
# DX-61: Apply config-based filtering
|
|
258
|
+
pattern_suggestions = filter_suggestions(raw_suggestions, config)
|
|
259
|
+
# Add suggestions to report as SUGGEST-level violations
|
|
260
|
+
for violation in suggestions_to_violations(pattern_suggestions):
|
|
261
|
+
report.add_violation(violation)
|
|
262
|
+
|
|
184
263
|
# DX-26: Simplified output mode (TTY auto-detect + --human override)
|
|
185
264
|
use_agent_output = _determine_output_mode(human, agent, json_output)
|
|
186
265
|
|
|
@@ -327,7 +406,7 @@ def _show_verification_level(verification_level) -> None:
|
|
|
327
406
|
@app.command()
|
|
328
407
|
def version() -> None:
|
|
329
408
|
"""Show Invar version."""
|
|
330
|
-
console.print(f"invar {__version__}")
|
|
409
|
+
console.print(f"invar-tools {__version__}")
|
|
331
410
|
|
|
332
411
|
|
|
333
412
|
@app.command("map")
|
|
@@ -464,5 +543,18 @@ app.add_typer(dev_app)
|
|
|
464
543
|
app.command("sync-self", hidden=True)(sync_self)
|
|
465
544
|
|
|
466
545
|
|
|
546
|
+
# MCP server command for Claude Code integration
|
|
547
|
+
@app.command()
|
|
548
|
+
def mcp() -> None:
|
|
549
|
+
"""Start Invar MCP server for AI agent integration.
|
|
550
|
+
|
|
551
|
+
This runs the MCP server using stdio transport.
|
|
552
|
+
Used by Claude Code and other MCP-compatible AI agents.
|
|
553
|
+
"""
|
|
554
|
+
from invar.mcp.server import run_server
|
|
555
|
+
|
|
556
|
+
run_server()
|
|
557
|
+
|
|
558
|
+
|
|
467
559
|
if __name__ == "__main__":
|
|
468
560
|
app()
|
invar/shell/config.py
CHANGED
|
@@ -267,6 +267,52 @@ def _find_config_source(project_root: Path) -> Result[tuple[Path | None, ConfigS
|
|
|
267
267
|
return Failure(f"Failed to find config: {e}")
|
|
268
268
|
|
|
269
269
|
|
|
270
|
+
# @shell_complexity: Project root discovery requires checking multiple markers
|
|
271
|
+
def find_project_root(start_path: "Path") -> "Path": # noqa: UP037
|
|
272
|
+
"""
|
|
273
|
+
Find project root by walking up from start_path looking for config files.
|
|
274
|
+
|
|
275
|
+
Looks for (in order): pyproject.toml, invar.toml, .invar/, .git/
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
start_path: Starting path (file or directory)
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Project root directory (absolute path), or start_path's parent if no markers found
|
|
282
|
+
|
|
283
|
+
Examples:
|
|
284
|
+
>>> from pathlib import Path
|
|
285
|
+
>>> import tempfile
|
|
286
|
+
>>> with tempfile.TemporaryDirectory() as tmpdir:
|
|
287
|
+
... root = Path(tmpdir).resolve()
|
|
288
|
+
... (root / "pyproject.toml").touch()
|
|
289
|
+
... subdir = root / "src" / "core"
|
|
290
|
+
... subdir.mkdir(parents=True)
|
|
291
|
+
... found = find_project_root(subdir / "file.py")
|
|
292
|
+
... found == root
|
|
293
|
+
True
|
|
294
|
+
"""
|
|
295
|
+
from pathlib import Path
|
|
296
|
+
|
|
297
|
+
current = Path(start_path).resolve() # Resolve to absolute path
|
|
298
|
+
if current.is_file():
|
|
299
|
+
current = current.parent
|
|
300
|
+
|
|
301
|
+
# Walk up looking for project markers
|
|
302
|
+
for parent in [current, *current.parents]:
|
|
303
|
+
if (parent / "pyproject.toml").exists():
|
|
304
|
+
return parent
|
|
305
|
+
if (parent / "invar.toml").exists():
|
|
306
|
+
return parent
|
|
307
|
+
if (parent / ".invar").is_dir():
|
|
308
|
+
return parent
|
|
309
|
+
if (parent / ".git").exists():
|
|
310
|
+
return parent
|
|
311
|
+
|
|
312
|
+
# Fallback to the starting directory
|
|
313
|
+
return current
|
|
314
|
+
|
|
315
|
+
|
|
270
316
|
def _read_toml(path: Path) -> Result[dict[str, Any], str]:
|
|
271
317
|
"""Read and parse a TOML file."""
|
|
272
318
|
try:
|