invar-tools 1.0.0__py3-none-any.whl → 1.2.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/core/contracts.py +75 -5
- invar/core/entry_points.py +294 -0
- invar/core/format_specs.py +196 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +27 -4
- invar/core/hypothesis_strategies.py +47 -5
- invar/core/lambda_helpers.py +1 -0
- invar/core/models.py +23 -17
- invar/core/parser.py +6 -2
- invar/core/property_gen.py +81 -40
- invar/core/purity.py +10 -4
- invar/core/review_trigger.py +298 -0
- invar/core/rule_meta.py +61 -2
- invar/core/rules.py +83 -19
- invar/core/shell_analysis.py +252 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/suggestions.py +6 -0
- invar/core/tautology.py +1 -0
- invar/core/utils.py +51 -4
- invar/core/verification_routing.py +158 -0
- invar/invariant.py +1 -0
- invar/mcp/server.py +20 -3
- invar/shell/cli.py +59 -31
- invar/shell/config.py +259 -10
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +78 -3
- invar/shell/guard_output.py +100 -24
- invar/shell/init_cmd.py +27 -7
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutate_cmd.py +184 -0
- invar/shell/mutation.py +314 -0
- invar/shell/perception.py +2 -0
- invar/shell/property_tests.py +17 -2
- invar/shell/prove.py +35 -3
- invar/shell/prove_accept.py +113 -0
- invar/shell/prove_fallback.py +148 -46
- invar/shell/templates.py +34 -0
- invar/shell/test_cmd.py +3 -1
- invar/shell/testing.py +6 -17
- invar/shell/update_cmd.py +2 -0
- invar/templates/CLAUDE.md.template +65 -9
- invar/templates/INVAR.md +96 -23
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/review.md +200 -0
- invar/templates/cursorrules.template +22 -13
- invar/templates/examples/contracts.py +3 -1
- invar/templates/examples/core_shell.py +3 -1
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/METADATA +81 -15
- invar_tools-1.2.0.dist-info/RECORD +77 -0
- invar_tools-1.2.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.2.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.2.0.dist-info/licenses/NOTICE +63 -0
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/entry_points.txt +0 -0
invar/mcp/server.py
CHANGED
|
@@ -15,7 +15,7 @@ from typing import Any
|
|
|
15
15
|
from mcp.server import Server
|
|
16
16
|
from mcp.types import TextContent, Tool
|
|
17
17
|
|
|
18
|
-
# Strong instructions for agent behavior (DX-16 + DX-17)
|
|
18
|
+
# Strong instructions for agent behavior (DX-16 + DX-17 + DX-26)
|
|
19
19
|
INVAR_INSTRUCTIONS = """
|
|
20
20
|
## Invar Tool Usage (MANDATORY)
|
|
21
21
|
|
|
@@ -83,6 +83,8 @@ the MCP tools and may not follow the correct workflow.
|
|
|
83
83
|
"""
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
# @shell_orchestration: MCP tool factory - creates Tool objects
|
|
87
|
+
# @invar:allow shell_result: MCP framework API returns Tool
|
|
86
88
|
def _get_guard_tool() -> Tool:
|
|
87
89
|
"""Define the invar_guard tool."""
|
|
88
90
|
return Tool(
|
|
@@ -103,6 +105,8 @@ def _get_guard_tool() -> Tool:
|
|
|
103
105
|
)
|
|
104
106
|
|
|
105
107
|
|
|
108
|
+
# @shell_orchestration: MCP tool factory - creates Tool objects
|
|
109
|
+
# @invar:allow shell_result: MCP framework API returns Tool
|
|
106
110
|
def _get_sig_tool() -> Tool:
|
|
107
111
|
"""Define the invar_sig tool."""
|
|
108
112
|
return Tool(
|
|
@@ -121,6 +125,8 @@ def _get_sig_tool() -> Tool:
|
|
|
121
125
|
)
|
|
122
126
|
|
|
123
127
|
|
|
128
|
+
# @shell_orchestration: MCP tool factory - creates Tool objects
|
|
129
|
+
# @invar:allow shell_result: MCP framework API returns Tool
|
|
124
130
|
def _get_map_tool() -> Tool:
|
|
125
131
|
"""Define the invar_map tool."""
|
|
126
132
|
return Tool(
|
|
@@ -139,6 +145,8 @@ def _get_map_tool() -> Tool:
|
|
|
139
145
|
)
|
|
140
146
|
|
|
141
147
|
|
|
148
|
+
# @shell_orchestration: MCP server setup - registers handlers with framework
|
|
149
|
+
# @invar:allow shell_result: MCP framework API returns Server
|
|
142
150
|
def create_server() -> Server:
|
|
143
151
|
"""Create and configure the Invar MCP server."""
|
|
144
152
|
server = Server(name="invar", version="0.1.0", instructions=INVAR_INSTRUCTIONS)
|
|
@@ -158,6 +166,8 @@ def create_server() -> Server:
|
|
|
158
166
|
return server
|
|
159
167
|
|
|
160
168
|
|
|
169
|
+
# @shell_orchestration: MCP handler - subprocess is called inside
|
|
170
|
+
# @invar:allow shell_result: MCP framework API returns list[TextContent]
|
|
161
171
|
async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
|
|
162
172
|
"""Run invar guard command."""
|
|
163
173
|
cmd = [sys.executable, "-m", "invar.shell.cli", "guard"]
|
|
@@ -170,12 +180,14 @@ async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
|
|
|
170
180
|
if args.get("strict", False):
|
|
171
181
|
cmd.append("--strict")
|
|
172
182
|
|
|
173
|
-
#
|
|
174
|
-
|
|
183
|
+
# DX-26: TTY auto-detection - MCP runs in non-TTY, so agent JSON output is automatic
|
|
184
|
+
# No explicit flag needed
|
|
175
185
|
|
|
176
186
|
return await _execute_command(cmd)
|
|
177
187
|
|
|
178
188
|
|
|
189
|
+
# @shell_orchestration: MCP handler - subprocess is called inside
|
|
190
|
+
# @invar:allow shell_result: MCP framework API returns list[TextContent]
|
|
179
191
|
async def _run_sig(args: dict[str, Any]) -> list[TextContent]:
|
|
180
192
|
"""Run invar sig command."""
|
|
181
193
|
target = args.get("target", "")
|
|
@@ -186,6 +198,8 @@ async def _run_sig(args: dict[str, Any]) -> list[TextContent]:
|
|
|
186
198
|
return await _execute_command(cmd)
|
|
187
199
|
|
|
188
200
|
|
|
201
|
+
# @shell_orchestration: MCP handler - subprocess is called inside
|
|
202
|
+
# @invar:allow shell_result: MCP framework API returns list[TextContent]
|
|
189
203
|
async def _run_map(args: dict[str, Any]) -> list[TextContent]:
|
|
190
204
|
"""Run invar map command."""
|
|
191
205
|
cmd = [sys.executable, "-m", "invar.shell.cli", "map"]
|
|
@@ -200,6 +214,8 @@ async def _run_map(args: dict[str, Any]) -> list[TextContent]:
|
|
|
200
214
|
return await _execute_command(cmd)
|
|
201
215
|
|
|
202
216
|
|
|
217
|
+
# @shell_complexity: Command execution with error handling branches
|
|
218
|
+
# @invar:allow shell_result: MCP framework API returns list[TextContent]
|
|
203
219
|
async def _execute_command(cmd: list[str]) -> list[TextContent]:
|
|
204
220
|
"""Execute a command and return the result."""
|
|
205
221
|
try:
|
|
@@ -229,6 +245,7 @@ async def _execute_command(cmd: list[str]) -> list[TextContent]:
|
|
|
229
245
|
return [TextContent(type="text", text=f"Error: {e}")]
|
|
230
246
|
|
|
231
247
|
|
|
248
|
+
# @shell_orchestration: MCP server entry point - runs async server
|
|
232
249
|
def run_server() -> None:
|
|
233
250
|
"""Run the Invar MCP server."""
|
|
234
251
|
import asyncio
|
invar/shell/cli.py
CHANGED
|
@@ -27,7 +27,7 @@ from invar.core.rules import check_all_rules
|
|
|
27
27
|
from invar.core.utils import get_exit_code
|
|
28
28
|
from invar.shell.config import load_config
|
|
29
29
|
from invar.shell.fs import scan_project
|
|
30
|
-
from invar.shell.guard_output import output_agent,
|
|
30
|
+
from invar.shell.guard_output import output_agent, output_rich
|
|
31
31
|
|
|
32
32
|
app = typer.Typer(
|
|
33
33
|
name="invar",
|
|
@@ -37,6 +37,8 @@ app = typer.Typer(
|
|
|
37
37
|
console = Console()
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
# @shell_orchestration: Statistics helper for CLI guard output
|
|
41
|
+
# @shell_complexity: Iterates symbols checking kind and contracts (4 branches minimal)
|
|
40
42
|
def _count_core_functions(file_info) -> tuple[int, int]:
|
|
41
43
|
"""Count functions and functions with contracts in a Core file (P24)."""
|
|
42
44
|
from invar.core.models import SymbolKind
|
|
@@ -54,10 +56,13 @@ def _count_core_functions(file_info) -> tuple[int, int]:
|
|
|
54
56
|
return (total, with_contracts)
|
|
55
57
|
|
|
56
58
|
|
|
59
|
+
# @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
|
|
57
60
|
def _scan_and_check(
|
|
58
61
|
path: Path, config: RuleConfig, only_files: set[Path] | None = None
|
|
59
62
|
) -> Result[GuardReport, str]:
|
|
60
63
|
"""Scan project files and check against rules."""
|
|
64
|
+
from invar.core.shell_architecture import check_complexity_debt
|
|
65
|
+
|
|
61
66
|
report = GuardReport(files_checked=0)
|
|
62
67
|
for file_result in scan_project(path, only_files):
|
|
63
68
|
if isinstance(file_result, Failure):
|
|
@@ -70,43 +75,53 @@ def _scan_and_check(
|
|
|
70
75
|
report.update_coverage(total, with_contracts)
|
|
71
76
|
for violation in check_all_rules(file_info, config):
|
|
72
77
|
report.add_violation(violation)
|
|
78
|
+
|
|
79
|
+
# DX-22: Check project-level complexity debt (Fix-or-Explain enforcement)
|
|
80
|
+
for debt_violation in check_complexity_debt(
|
|
81
|
+
report.violations, config.shell_complexity_debt_limit
|
|
82
|
+
):
|
|
83
|
+
report.add_violation(debt_violation)
|
|
84
|
+
|
|
73
85
|
return Success(report)
|
|
74
86
|
|
|
75
87
|
|
|
88
|
+
# @invar:allow entry_point_too_thick: Main CLI entry point, orchestrates all verification phases
|
|
76
89
|
@app.command()
|
|
77
90
|
def guard(
|
|
78
91
|
path: Path = typer.Argument(
|
|
79
92
|
Path(), help="Project root directory", exists=True, file_okay=False, dir_okay=True
|
|
80
93
|
),
|
|
81
94
|
strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
|
|
95
|
+
changed: bool = typer.Option(
|
|
96
|
+
False, "--changed", help="Only check git-modified files"
|
|
97
|
+
),
|
|
98
|
+
static: bool = typer.Option(
|
|
99
|
+
False, "--static", help="Static analysis only, skip all runtime tests"
|
|
100
|
+
),
|
|
101
|
+
human: bool = typer.Option(
|
|
102
|
+
False, "--human", help="Force human-readable output (for testing/debugging)"
|
|
103
|
+
),
|
|
104
|
+
# DX-26: Deprecated flags kept for backward compatibility
|
|
82
105
|
no_strict_pure: bool = typer.Option(
|
|
83
|
-
False, "--no-strict-pure", help="Disable purity checks
|
|
106
|
+
False, "--no-strict-pure", hidden=True, help="[Deprecated] Disable purity checks"
|
|
84
107
|
),
|
|
85
108
|
pedantic: bool = typer.Option(
|
|
86
|
-
False, "--pedantic", help="Show
|
|
109
|
+
False, "--pedantic", hidden=True, help="[Deprecated] Show off-by-default rules"
|
|
87
110
|
),
|
|
88
111
|
explain: bool = typer.Option(
|
|
89
|
-
False, "--explain", help="Show detailed explanations
|
|
90
|
-
),
|
|
91
|
-
changed: bool = typer.Option(
|
|
92
|
-
False, "--changed", help="Only check git-modified files"
|
|
112
|
+
False, "--explain", hidden=True, help="[Deprecated] Show detailed explanations"
|
|
93
113
|
),
|
|
94
114
|
agent: bool = typer.Option(
|
|
95
|
-
False, "--agent", help="
|
|
115
|
+
False, "--agent", help="Force JSON output (for inspecting agent format)"
|
|
96
116
|
),
|
|
97
117
|
json_output: bool = typer.Option(
|
|
98
|
-
False, "--json", help="
|
|
99
|
-
),
|
|
100
|
-
static: bool = typer.Option(
|
|
101
|
-
False, "--static", help="Static analysis only, skip all runtime tests"
|
|
118
|
+
False, "--json", hidden=True, help="[Deprecated] Use TTY auto-detection instead"
|
|
102
119
|
),
|
|
103
120
|
) -> None:
|
|
104
121
|
"""Check project against Invar architecture rules.
|
|
105
122
|
|
|
106
123
|
Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
|
|
107
124
|
Use --static for quick static-only checks (~0.5s vs ~5s full).
|
|
108
|
-
|
|
109
|
-
DX-19: Simplified to 2 levels (Zero decisions).
|
|
110
125
|
"""
|
|
111
126
|
from invar.shell.guard_helpers import (
|
|
112
127
|
collect_files_to_check,
|
|
@@ -150,17 +165,15 @@ def guard(
|
|
|
150
165
|
raise typer.Exit(1)
|
|
151
166
|
report = scan_result.unwrap()
|
|
152
167
|
|
|
153
|
-
#
|
|
154
|
-
use_agent_output
|
|
155
|
-
json_output, agent
|
|
156
|
-
)
|
|
168
|
+
# DX-26: Simplified output mode (TTY auto-detect + --human override)
|
|
169
|
+
use_agent_output = _determine_output_mode(human, agent, json_output)
|
|
157
170
|
|
|
158
171
|
# DX-19: Simplified to 2 levels (STATIC or STANDARD)
|
|
159
172
|
verification_level = VerificationLevel.STATIC if static else VerificationLevel.STANDARD
|
|
160
173
|
level_name = "STATIC" if static else "STANDARD"
|
|
161
174
|
|
|
162
175
|
# Show verification level (human mode)
|
|
163
|
-
if not use_agent_output
|
|
176
|
+
if not use_agent_output:
|
|
164
177
|
_show_verification_level(verification_level)
|
|
165
178
|
|
|
166
179
|
# Run verification phases
|
|
@@ -187,20 +200,19 @@ def guard(
|
|
|
187
200
|
checked_files, doctest_passed, static_exit_code
|
|
188
201
|
)
|
|
189
202
|
|
|
190
|
-
#
|
|
203
|
+
# DX-26: Unified output (agent JSON or human Rich)
|
|
191
204
|
if use_agent_output:
|
|
192
205
|
output_agent(
|
|
193
|
-
report, doctest_passed, doctest_output, crosshair_output, level_name,
|
|
206
|
+
report, strict, doctest_passed, doctest_output, crosshair_output, level_name,
|
|
194
207
|
property_output=property_output,
|
|
195
208
|
)
|
|
196
|
-
elif use_json_output:
|
|
197
|
-
output_json(report)
|
|
198
209
|
else:
|
|
199
|
-
output_rich(report, config.strict_pure, changed, pedantic, explain)
|
|
210
|
+
output_rich(report, config.strict_pure, changed, pedantic, explain, static)
|
|
200
211
|
output_verification_status(
|
|
201
212
|
verification_level, static_exit_code, doctest_passed,
|
|
202
213
|
doctest_output, crosshair_output, explain,
|
|
203
214
|
property_output=property_output,
|
|
215
|
+
strict=strict,
|
|
204
216
|
)
|
|
205
217
|
|
|
206
218
|
# Exit with combined status
|
|
@@ -209,13 +221,26 @@ def guard(
|
|
|
209
221
|
raise typer.Exit(final_exit)
|
|
210
222
|
|
|
211
223
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
224
|
+
# @shell_orchestration: Output mode decision helper for CLI
|
|
225
|
+
def _determine_output_mode(human: bool, agent: bool = False, json_output: bool = False) -> bool:
|
|
226
|
+
"""Determine if agent JSON output should be used (DX-26).
|
|
227
|
+
|
|
228
|
+
DX-26: TTY auto-detection with --human override.
|
|
229
|
+
- --human flag → human output (for testing/debugging)
|
|
230
|
+
- TTY (terminal) → human output
|
|
231
|
+
- Non-TTY (pipe/redirect) → agent JSON output
|
|
232
|
+
- Deprecated --agent/--json flags → still work for backward compat
|
|
233
|
+
"""
|
|
234
|
+
# --human flag always forces human output
|
|
235
|
+
if human:
|
|
236
|
+
return False # use_agent = False
|
|
237
|
+
|
|
238
|
+
# Deprecated flags (backward compat)
|
|
239
|
+
if json_output or agent:
|
|
240
|
+
return True # use_agent = True
|
|
241
|
+
|
|
242
|
+
# TTY auto-detection
|
|
243
|
+
return _detect_agent_mode() # Returns True if non-TTY
|
|
219
244
|
|
|
220
245
|
|
|
221
246
|
def _show_verification_level(verification_level) -> None:
|
|
@@ -271,6 +296,7 @@ def sig_command(
|
|
|
271
296
|
raise typer.Exit(1)
|
|
272
297
|
|
|
273
298
|
|
|
299
|
+
# @invar:allow entry_point_too_thick: Rules display with filtering and dual output modes
|
|
274
300
|
@app.command()
|
|
275
301
|
def rules(
|
|
276
302
|
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
@@ -345,6 +371,7 @@ def rules(
|
|
|
345
371
|
|
|
346
372
|
# Import commands from separate modules to reduce file size
|
|
347
373
|
from invar.shell.init_cmd import init
|
|
374
|
+
from invar.shell.mutate_cmd import mutate # DX-28
|
|
348
375
|
from invar.shell.test_cmd import test, verify
|
|
349
376
|
from invar.shell.update_cmd import update
|
|
350
377
|
|
|
@@ -352,6 +379,7 @@ app.command()(init)
|
|
|
352
379
|
app.command()(update)
|
|
353
380
|
app.command()(test)
|
|
354
381
|
app.command()(verify)
|
|
382
|
+
app.command()(mutate) # DX-28: Mutation testing
|
|
355
383
|
|
|
356
384
|
|
|
357
385
|
if __name__ == "__main__":
|
invar/shell/config.py
CHANGED
|
@@ -8,11 +8,15 @@ Configuration sources (priority order):
|
|
|
8
8
|
2. invar.toml [guard]
|
|
9
9
|
3. .invar/config.toml [guard]
|
|
10
10
|
4. Built-in defaults
|
|
11
|
+
|
|
12
|
+
DX-22: Added content-based auto-detection for Core/Shell classification.
|
|
11
13
|
"""
|
|
12
14
|
|
|
13
15
|
from __future__ import annotations
|
|
14
16
|
|
|
17
|
+
import ast
|
|
15
18
|
import tomllib
|
|
19
|
+
from enum import Enum
|
|
16
20
|
from typing import TYPE_CHECKING, Any, Literal
|
|
17
21
|
|
|
18
22
|
from returns.result import Failure, Result, Success
|
|
@@ -25,12 +29,223 @@ from invar.core.utils import (
|
|
|
25
29
|
parse_guard_config,
|
|
26
30
|
)
|
|
27
31
|
|
|
32
|
+
|
|
33
|
+
class ModuleType(Enum):
|
|
34
|
+
"""DX-22: Module type for auto-detection."""
|
|
35
|
+
|
|
36
|
+
CORE = "core"
|
|
37
|
+
SHELL = "shell"
|
|
38
|
+
UNKNOWN = "unknown"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# I/O libraries that indicate Shell module (for AST import checking)
|
|
42
|
+
_IO_LIBRARIES = frozenset(
|
|
43
|
+
[
|
|
44
|
+
"os",
|
|
45
|
+
"sys",
|
|
46
|
+
"subprocess",
|
|
47
|
+
"pathlib",
|
|
48
|
+
"shutil",
|
|
49
|
+
"io",
|
|
50
|
+
"socket",
|
|
51
|
+
"requests",
|
|
52
|
+
"aiohttp",
|
|
53
|
+
"httpx",
|
|
54
|
+
"urllib",
|
|
55
|
+
"sqlite3",
|
|
56
|
+
"psycopg2",
|
|
57
|
+
"pymongo",
|
|
58
|
+
"sqlalchemy",
|
|
59
|
+
"typer",
|
|
60
|
+
"click",
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Contract decorator names
|
|
65
|
+
_CONTRACT_DECORATORS = frozenset(["pre", "post", "invariant"])
|
|
66
|
+
|
|
67
|
+
# Result monad types
|
|
68
|
+
_RESULT_TYPES = frozenset(["Result", "Success", "Failure"])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# @shell_orchestration: AST analysis helpers for module classification
|
|
72
|
+
def _has_contract_decorators(tree: ast.Module) -> bool:
|
|
73
|
+
"""
|
|
74
|
+
Check if AST contains @pre/@post contract decorators.
|
|
75
|
+
|
|
76
|
+
Uses AST to only detect real decorators, not strings in docstrings.
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
>>> import ast
|
|
80
|
+
>>> tree = ast.parse("@pre(lambda x: x > 0)\\ndef foo(x): pass")
|
|
81
|
+
>>> _has_contract_decorators(tree)
|
|
82
|
+
True
|
|
83
|
+
>>> tree = ast.parse("def foo():\\n '''>>> @pre(x)'''\\n pass")
|
|
84
|
+
>>> _has_contract_decorators(tree)
|
|
85
|
+
False
|
|
86
|
+
"""
|
|
87
|
+
for node in ast.walk(tree):
|
|
88
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
89
|
+
for decorator in node.decorator_list:
|
|
90
|
+
# @pre(...) or @post(...)
|
|
91
|
+
if isinstance(decorator, ast.Call):
|
|
92
|
+
func = decorator.func
|
|
93
|
+
if isinstance(func, ast.Name) and func.id in _CONTRACT_DECORATORS:
|
|
94
|
+
return True
|
|
95
|
+
if isinstance(func, ast.Attribute) and func.attr in _CONTRACT_DECORATORS:
|
|
96
|
+
return True
|
|
97
|
+
# @pre (without call - rare but possible)
|
|
98
|
+
elif isinstance(decorator, ast.Name) and decorator.id in _CONTRACT_DECORATORS:
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# @shell_orchestration: AST analysis helper for module classification
|
|
104
|
+
def _has_io_imports(tree: ast.Module) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Check if AST contains imports of I/O libraries.
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
>>> import ast
|
|
110
|
+
>>> tree = ast.parse("import os")
|
|
111
|
+
>>> _has_io_imports(tree)
|
|
112
|
+
True
|
|
113
|
+
>>> tree = ast.parse("from pathlib import Path")
|
|
114
|
+
>>> _has_io_imports(tree)
|
|
115
|
+
True
|
|
116
|
+
>>> tree = ast.parse("import json")
|
|
117
|
+
>>> _has_io_imports(tree)
|
|
118
|
+
False
|
|
119
|
+
>>> tree = ast.parse("def foo():\\n '''import os'''\\n pass")
|
|
120
|
+
>>> _has_io_imports(tree)
|
|
121
|
+
False
|
|
122
|
+
"""
|
|
123
|
+
for node in ast.walk(tree):
|
|
124
|
+
if isinstance(node, ast.Import):
|
|
125
|
+
for alias in node.names:
|
|
126
|
+
lib = alias.name.split(".")[0]
|
|
127
|
+
if lib in _IO_LIBRARIES:
|
|
128
|
+
return True
|
|
129
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
130
|
+
lib = node.module.split(".")[0]
|
|
131
|
+
if lib in _IO_LIBRARIES:
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# @shell_orchestration: AST analysis helper for module classification
|
|
137
|
+
def _has_result_types(tree: ast.Module) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Check if AST contains Result/Success/Failure usage.
|
|
140
|
+
|
|
141
|
+
Checks:
|
|
142
|
+
- Return type annotations: -> Result[T, E]
|
|
143
|
+
- Imports: from returns.result import Success
|
|
144
|
+
- Function calls: Success(...), Failure(...)
|
|
145
|
+
|
|
146
|
+
Examples:
|
|
147
|
+
>>> import ast
|
|
148
|
+
>>> tree = ast.parse("from returns.result import Success")
|
|
149
|
+
>>> _has_result_types(tree)
|
|
150
|
+
True
|
|
151
|
+
>>> tree = ast.parse("def foo() -> Result[int, str]: pass")
|
|
152
|
+
>>> _has_result_types(tree)
|
|
153
|
+
True
|
|
154
|
+
>>> tree = ast.parse("return Success(42)")
|
|
155
|
+
>>> _has_result_types(tree)
|
|
156
|
+
True
|
|
157
|
+
>>> tree = ast.parse("def foo():\\n '''Success'''\\n pass")
|
|
158
|
+
>>> _has_result_types(tree)
|
|
159
|
+
False
|
|
160
|
+
"""
|
|
161
|
+
for node in ast.walk(tree):
|
|
162
|
+
# Check imports: from returns.result import Success
|
|
163
|
+
if isinstance(node, ast.ImportFrom):
|
|
164
|
+
if node.module and "returns" in node.module:
|
|
165
|
+
for alias in node.names:
|
|
166
|
+
if alias.name in _RESULT_TYPES:
|
|
167
|
+
return True
|
|
168
|
+
# Check function calls: Success(...), Failure(...)
|
|
169
|
+
elif isinstance(node, ast.Call):
|
|
170
|
+
if isinstance(node.func, ast.Name) and node.func.id in _RESULT_TYPES:
|
|
171
|
+
return True
|
|
172
|
+
# Check type annotations: -> Result[T, E]
|
|
173
|
+
elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
174
|
+
if node.returns:
|
|
175
|
+
ann = node.returns
|
|
176
|
+
if isinstance(ann, ast.Subscript):
|
|
177
|
+
if isinstance(ann.value, ast.Name) and ann.value.id == "Result":
|
|
178
|
+
return True
|
|
179
|
+
elif isinstance(ann, ast.Name) and ann.id in _RESULT_TYPES:
|
|
180
|
+
return True
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# @shell_complexity: Classification decision tree requires multiple conditions
|
|
185
|
+
def auto_detect_module_type(source: str, file_path: str = "") -> ModuleType:
|
|
186
|
+
"""
|
|
187
|
+
Automatically detect module type from source content using AST.
|
|
188
|
+
|
|
189
|
+
DX-22: Content-based classification when path-based is inconclusive.
|
|
190
|
+
Uses AST parsing to avoid false positives from docstrings/comments.
|
|
191
|
+
|
|
192
|
+
Priority:
|
|
193
|
+
1. Path convention (**/core/** or **/shell/**)
|
|
194
|
+
2. Content features via AST (contracts, Result types, I/O imports)
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
source: Python source code as string
|
|
198
|
+
file_path: Optional file path for path-based hints
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
ModuleType indicating Core, Shell, or Unknown
|
|
202
|
+
|
|
203
|
+
Examples:
|
|
204
|
+
>>> auto_detect_module_type("@pre(lambda x: x > 0)\\ndef foo(x): pass")
|
|
205
|
+
<ModuleType.CORE: 'core'>
|
|
206
|
+
>>> auto_detect_module_type("from returns.result import Success\\ndef load(): return Success('ok')")
|
|
207
|
+
<ModuleType.SHELL: 'shell'>
|
|
208
|
+
>>> auto_detect_module_type("def helper(): pass")
|
|
209
|
+
<ModuleType.UNKNOWN: 'unknown'>
|
|
210
|
+
>>> auto_detect_module_type("def foo():\\n '''>>> @pre(x)'''\\n pass")
|
|
211
|
+
<ModuleType.UNKNOWN: 'unknown'>
|
|
212
|
+
"""
|
|
213
|
+
# Priority 1: Path convention
|
|
214
|
+
if file_path:
|
|
215
|
+
path_lower = file_path.lower()
|
|
216
|
+
if "/core/" in path_lower or path_lower.endswith("/core"):
|
|
217
|
+
return ModuleType.CORE
|
|
218
|
+
if "/shell/" in path_lower or path_lower.endswith("/shell"):
|
|
219
|
+
return ModuleType.SHELL
|
|
220
|
+
|
|
221
|
+
# Priority 2: Content features via AST
|
|
222
|
+
try:
|
|
223
|
+
tree = ast.parse(source)
|
|
224
|
+
except SyntaxError:
|
|
225
|
+
return ModuleType.UNKNOWN
|
|
226
|
+
|
|
227
|
+
has_contracts = _has_contract_decorators(tree)
|
|
228
|
+
has_io = _has_io_imports(tree)
|
|
229
|
+
has_result = _has_result_types(tree)
|
|
230
|
+
|
|
231
|
+
# Core: has contracts AND no I/O
|
|
232
|
+
if has_contracts and not has_io:
|
|
233
|
+
return ModuleType.CORE
|
|
234
|
+
|
|
235
|
+
# Shell: has I/O or Result types
|
|
236
|
+
if has_io or has_result:
|
|
237
|
+
return ModuleType.SHELL
|
|
238
|
+
|
|
239
|
+
# Unknown: neither clear pattern
|
|
240
|
+
return ModuleType.UNKNOWN
|
|
241
|
+
|
|
28
242
|
if TYPE_CHECKING:
|
|
29
243
|
from pathlib import Path
|
|
30
244
|
|
|
31
245
|
ConfigSource = Literal["pyproject", "invar", "invar_dir", "default"]
|
|
32
246
|
|
|
33
247
|
|
|
248
|
+
# @shell_complexity: Config cascade checks multiple sources with fallback
|
|
34
249
|
def _find_config_source(project_root: Path) -> Result[tuple[Path | None, ConfigSource], str]:
|
|
35
250
|
"""
|
|
36
251
|
Find the first available config file.
|
|
@@ -76,6 +291,7 @@ def _read_toml(path: Path) -> Result[dict[str, Any], str]:
|
|
|
76
291
|
return Failure(f"Failed to read {path.name}: {e}")
|
|
77
292
|
|
|
78
293
|
|
|
294
|
+
# @shell_complexity: Config loading with multiple sources and parse error handling
|
|
79
295
|
def load_config(project_root: Path) -> Result[RuleConfig, str]:
|
|
80
296
|
"""
|
|
81
297
|
Load Invar configuration from available sources.
|
|
@@ -139,6 +355,9 @@ _DEFAULT_EXCLUDE_PATHS = [
|
|
|
139
355
|
"dist",
|
|
140
356
|
"build",
|
|
141
357
|
".tox",
|
|
358
|
+
# Templates and examples are documentation, not enforced code
|
|
359
|
+
"templates",
|
|
360
|
+
".invar/examples",
|
|
142
361
|
]
|
|
143
362
|
|
|
144
363
|
|
|
@@ -206,11 +425,19 @@ def get_exclude_paths(project_root: Path) -> Result[list[str], str]:
|
|
|
206
425
|
return Success(guard_config.get("exclude_paths", _DEFAULT_EXCLUDE_PATHS.copy()))
|
|
207
426
|
|
|
208
427
|
|
|
209
|
-
|
|
428
|
+
# @invar:allow entry_point_too_thick: False positive - .get() matches router.get pattern
|
|
429
|
+
def classify_file(
|
|
430
|
+
file_path: str, project_root: Path, source: str = ""
|
|
431
|
+
) -> Result[tuple[bool, bool], str]:
|
|
210
432
|
"""
|
|
211
433
|
Classify a file as Core, Shell, or neither.
|
|
212
434
|
|
|
213
|
-
|
|
435
|
+
DX-22 Part 5: Priority order is patterns > paths > content > uncategorized.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
file_path: Relative path to the file
|
|
439
|
+
project_root: Project root directory
|
|
440
|
+
source: Optional source content for content-based detection
|
|
214
441
|
|
|
215
442
|
Examples:
|
|
216
443
|
>>> import tempfile
|
|
@@ -220,18 +447,32 @@ def classify_file(file_path: str, project_root: Path) -> Result[tuple[bool, bool
|
|
|
220
447
|
... result = classify_file("src/core/logic.py", root)
|
|
221
448
|
... result.unwrap()[0]
|
|
222
449
|
True
|
|
450
|
+
>>> classify_file("lib/utils.py", Path("."), "@pre(lambda x: x > 0)\\ndef foo(x): pass").unwrap()
|
|
451
|
+
(True, False)
|
|
452
|
+
>>> classify_file("lib/io.py", Path("."), "def read() -> Result[str, str]: return Success('ok')").unwrap()
|
|
453
|
+
(False, True)
|
|
223
454
|
"""
|
|
224
455
|
pattern_result = get_pattern_classification(project_root)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
456
|
+
if isinstance(pattern_result, Success):
|
|
457
|
+
core_patterns, shell_patterns = pattern_result.unwrap()
|
|
458
|
+
else:
|
|
459
|
+
# Log warning about config error, use defaults
|
|
460
|
+
import logging
|
|
461
|
+
logging.getLogger(__name__).debug(
|
|
462
|
+
"Pattern classification failed: %s, using defaults", pattern_result.failure()
|
|
463
|
+
)
|
|
464
|
+
core_patterns, shell_patterns = ([], [])
|
|
228
465
|
|
|
229
466
|
path_result = get_path_classification(project_root)
|
|
230
|
-
|
|
231
|
-
path_result.unwrap()
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
467
|
+
if isinstance(path_result, Success):
|
|
468
|
+
core_paths, shell_paths = path_result.unwrap()
|
|
469
|
+
else:
|
|
470
|
+
# Log warning about config error, use defaults
|
|
471
|
+
import logging
|
|
472
|
+
logging.getLogger(__name__).debug(
|
|
473
|
+
"Path classification failed: %s, using defaults", path_result.failure()
|
|
474
|
+
)
|
|
475
|
+
core_paths, shell_paths = (_DEFAULT_CORE_PATHS, _DEFAULT_SHELL_PATHS)
|
|
235
476
|
|
|
236
477
|
# Priority 1: Pattern-based classification
|
|
237
478
|
if core_patterns and matches_pattern(file_path, core_patterns):
|
|
@@ -245,4 +486,12 @@ def classify_file(file_path: str, project_root: Path) -> Result[tuple[bool, bool
|
|
|
245
486
|
if matches_path_prefix(file_path, shell_paths):
|
|
246
487
|
return Success((False, True))
|
|
247
488
|
|
|
489
|
+
# Priority 3: Content-based auto-detection (DX-22 Part 5)
|
|
490
|
+
if source:
|
|
491
|
+
module_type = auto_detect_module_type(source, file_path)
|
|
492
|
+
if module_type == ModuleType.CORE:
|
|
493
|
+
return Success((True, False))
|
|
494
|
+
if module_type == ModuleType.SHELL:
|
|
495
|
+
return Success((False, True))
|
|
496
|
+
|
|
248
497
|
return Success((False, False))
|
invar/shell/fs.py
CHANGED
|
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
# @shell_complexity: Recursive file discovery with gitignore and exclusions
|
|
22
23
|
def discover_python_files(
|
|
23
24
|
project_root: Path,
|
|
24
25
|
exclude_patterns: list[str] | None = None,
|
|
@@ -52,6 +53,7 @@ def discover_python_files(
|
|
|
52
53
|
yield py_file
|
|
53
54
|
|
|
54
55
|
|
|
56
|
+
# @shell_complexity: File reading with AST parsing and error handling
|
|
55
57
|
def read_and_parse_file(file_path: Path, project_root: Path) -> Result[FileInfo, str]:
|
|
56
58
|
"""
|
|
57
59
|
Read a Python file and parse it into FileInfo.
|
|
@@ -79,8 +81,8 @@ def read_and_parse_file(file_path: Path, project_root: Path) -> Result[FileInfo,
|
|
|
79
81
|
if file_info is None:
|
|
80
82
|
return Failure(f"Syntax error in {file_path}")
|
|
81
83
|
|
|
82
|
-
# Classify as Core or Shell based on patterns and
|
|
83
|
-
classify_result = classify_file(relative_path, project_root)
|
|
84
|
+
# Classify as Core or Shell based on patterns, paths, and content (DX-22 Part 5)
|
|
85
|
+
classify_result = classify_file(relative_path, project_root, file_info.source)
|
|
84
86
|
file_info.is_core, file_info.is_shell = (
|
|
85
87
|
classify_result.unwrap() if isinstance(classify_result, Success) else (False, False)
|
|
86
88
|
)
|
|
@@ -88,6 +90,7 @@ def read_and_parse_file(file_path: Path, project_root: Path) -> Result[FileInfo,
|
|
|
88
90
|
return Success(file_info)
|
|
89
91
|
|
|
90
92
|
|
|
93
|
+
# @shell_complexity: Project scanning with exclusions and error handling
|
|
91
94
|
def scan_project(
|
|
92
95
|
project_root: Path,
|
|
93
96
|
only_files: set[Path] | None = None,
|
invar/shell/git.py
CHANGED
|
@@ -28,6 +28,7 @@ def _run_git(args: list[str], cwd: Path) -> Result[str, str]:
|
|
|
28
28
|
return Failure(f"Git error: {e}")
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
# @shell_orchestration: Helper for git output parsing, tightly coupled to Shell
|
|
31
32
|
def _parse_py_files(output: str, project_root: Path) -> set[Path]:
|
|
32
33
|
"""Parse git output and return Python file paths."""
|
|
33
34
|
files: set[Path] = set()
|
|
@@ -37,6 +38,7 @@ def _parse_py_files(output: str, project_root: Path) -> set[Path]:
|
|
|
37
38
|
return files
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
# @shell_complexity: Git operations require multiple subprocess calls with error handling
|
|
40
42
|
def get_changed_files(project_root: Path) -> Result[set[Path], str]:
|
|
41
43
|
"""
|
|
42
44
|
Get Python files modified according to git (staged, unstaged, untracked).
|