invar-tools 1.0.0__py3-none-any.whl → 1.3.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 +1 -0
- invar/core/contracts.py +80 -10
- invar/core/entry_points.py +367 -0
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +195 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +32 -10
- invar/core/hypothesis_strategies.py +50 -10
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -2
- invar/core/models.py +30 -18
- invar/core/must_use.py +2 -1
- invar/core/parser.py +13 -6
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +86 -42
- invar/core/purity.py +13 -7
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +370 -0
- invar/core/rule_meta.py +69 -2
- invar/core/rules.py +91 -28
- invar/core/shell_analysis.py +247 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +92 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +103 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +63 -18
- invar/core/verification_routing.py +155 -0
- invar/mcp/server.py +113 -13
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +152 -44
- invar/shell/{init_cmd.py → commands/init.py} +200 -28
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/mutate.py +184 -0
- invar/shell/{perception.py → commands/perception.py} +2 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/{test_cmd.py → commands/test.py} +3 -1
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +247 -10
- invar/shell/coverage.py +351 -0
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +116 -20
- invar/shell/guard_output.py +106 -24
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutation.py +314 -0
- invar/shell/property_tests.py +75 -24
- invar/shell/prove/__init__.py +9 -0
- invar/shell/prove/accept.py +113 -0
- invar/shell/{prove.py → prove/crosshair.py} +69 -30
- invar/shell/prove/hypothesis.py +293 -0
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +53 -0
- invar/shell/testing.py +77 -37
- invar/templates/CLAUDE.md.template +86 -9
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/audit.md +138 -0
- invar/templates/commands/guard.md +77 -0
- invar/templates/config/CLAUDE.md.jinja +206 -0
- invar/templates/config/context.md.jinja +92 -0
- invar/templates/config/pre-commit.yaml.jinja +44 -0
- invar/templates/context.md.template +33 -0
- invar/templates/cursorrules.template +25 -13
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +4 -2
- invar/templates/examples/core_shell.py +10 -4
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/protocol/INVAR.md +210 -0
- invar/templates/skills/develop/SKILL.md.jinja +318 -0
- invar/templates/skills/investigate/SKILL.md.jinja +106 -0
- invar/templates/skills/propose/SKILL.md.jinja +104 -0
- invar/templates/skills/review/SKILL.md.jinja +125 -0
- invar_tools-1.3.0.dist-info/METADATA +377 -0
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -57
- invar/resource.py +0 -99
- invar/shell/prove_fallback.py +0 -183
- invar/shell/update_cmd.py +0 -191
- invar/templates/INVAR.md +0 -134
- invar_tools-1.0.0.dist-info/METADATA +0 -321
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
invar/core/rules.py
CHANGED
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Callable
|
|
6
6
|
|
|
7
|
-
from deal import post
|
|
7
|
+
from deal import post
|
|
8
8
|
|
|
9
9
|
from invar.core.contracts import (
|
|
10
10
|
check_empty_contracts,
|
|
@@ -12,11 +12,16 @@ from invar.core.contracts import (
|
|
|
12
12
|
check_partial_contract,
|
|
13
13
|
check_redundant_type_contracts,
|
|
14
14
|
check_semantic_tautology,
|
|
15
|
+
check_skip_without_reason,
|
|
15
16
|
)
|
|
17
|
+
from invar.core.entry_points import get_symbol_lines, has_allow_marker, is_entry_point
|
|
16
18
|
from invar.core.extraction import format_extraction_hint
|
|
17
19
|
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
18
20
|
from invar.core.must_use import check_must_use
|
|
21
|
+
from invar.core.postcondition_scope import check_postcondition_scope
|
|
19
22
|
from invar.core.purity import check_impure_calls, check_internal_imports
|
|
23
|
+
from invar.core.review_trigger import check_contract_quality_ratio, check_review_suggested
|
|
24
|
+
from invar.core.shell_architecture import check_shell_pure_logic, check_shell_too_complex
|
|
20
25
|
from invar.core.suggestions import format_suggestion_for_violation
|
|
21
26
|
from invar.core.utils import get_excluded_rules
|
|
22
27
|
|
|
@@ -48,7 +53,6 @@ def _build_size_suggestion(base: str, extraction_hint: str, func_hint: str) -> s
|
|
|
48
53
|
return f"{base}{func_hint}" if func_hint else base
|
|
49
54
|
|
|
50
55
|
|
|
51
|
-
@pre(lambda file_info: isinstance(file_info, FileInfo))
|
|
52
56
|
@post(lambda result: isinstance(result, str))
|
|
53
57
|
def _get_func_hint(file_info: FileInfo) -> str:
|
|
54
58
|
"""Get top 5 largest functions as hint string."""
|
|
@@ -59,7 +63,7 @@ def _get_func_hint(file_info: FileInfo) -> str:
|
|
|
59
63
|
return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
|
|
60
64
|
|
|
61
65
|
|
|
62
|
-
@
|
|
66
|
+
@post(lambda result: all(v.rule in ("file_size", "file_size_warning") for v in result))
|
|
63
67
|
def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
64
68
|
"""
|
|
65
69
|
Check if file exceeds maximum line count or warning threshold.
|
|
@@ -100,13 +104,13 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
100
104
|
return violations
|
|
101
105
|
|
|
102
106
|
|
|
103
|
-
@
|
|
107
|
+
@post(lambda result: all(v.rule == "function_size" for v in result))
|
|
104
108
|
def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
105
109
|
"""
|
|
106
110
|
Check if any function exceeds maximum line count.
|
|
107
111
|
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
DX-22: Always uses code_lines (excluding docstring) and excludes doctest lines.
|
|
113
|
+
These behaviors were previously optional but are now the default.
|
|
110
114
|
|
|
111
115
|
Examples:
|
|
112
116
|
>>> from invar.core.models import FileInfo, Symbol, SymbolKind
|
|
@@ -121,26 +125,19 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
121
125
|
for symbol in file_info.symbols:
|
|
122
126
|
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
123
127
|
total_lines = symbol.end_line - symbol.line + 1
|
|
124
|
-
#
|
|
125
|
-
if
|
|
128
|
+
# DX-22: Always use code_lines when available (excluding docstring)
|
|
129
|
+
if symbol.code_lines is not None:
|
|
126
130
|
func_lines = symbol.code_lines
|
|
127
131
|
line_type = "code lines"
|
|
128
132
|
else:
|
|
129
133
|
func_lines = total_lines
|
|
130
134
|
line_type = "lines"
|
|
131
|
-
#
|
|
132
|
-
if
|
|
135
|
+
# DX-22: Always exclude doctest lines from size calculation
|
|
136
|
+
if symbol.doctest_lines > 0:
|
|
133
137
|
func_lines -= symbol.doctest_lines
|
|
134
138
|
line_type = f"{line_type} (excl. doctest)"
|
|
135
139
|
|
|
136
140
|
if func_lines > config.max_function_lines:
|
|
137
|
-
# P19: Show breakdown if doctest lines exist
|
|
138
|
-
if symbol.doctest_lines > 0 and not config.exclude_doctest_lines:
|
|
139
|
-
code_only = total_lines - symbol.doctest_lines
|
|
140
|
-
breakdown = f" ({code_only} code + {symbol.doctest_lines} doctest)"
|
|
141
|
-
suggestion = f"Extract helper or set exclude_doctest_lines=true{breakdown}"
|
|
142
|
-
else:
|
|
143
|
-
suggestion = "Extract helper functions"
|
|
144
141
|
violations.append(
|
|
145
142
|
Violation(
|
|
146
143
|
rule="function_size",
|
|
@@ -148,14 +145,14 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
148
145
|
file=file_info.path,
|
|
149
146
|
line=symbol.line,
|
|
150
147
|
message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {config.max_function_lines})",
|
|
151
|
-
suggestion=
|
|
148
|
+
suggestion="Extract helper functions",
|
|
152
149
|
)
|
|
153
150
|
)
|
|
154
151
|
|
|
155
152
|
return violations
|
|
156
153
|
|
|
157
154
|
|
|
158
|
-
@
|
|
155
|
+
@post(lambda result: all(v.rule == "forbidden_import" for v in result))
|
|
159
156
|
def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
160
157
|
"""
|
|
161
158
|
Check for forbidden imports in Core files.
|
|
@@ -199,7 +196,7 @@ def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Vio
|
|
|
199
196
|
return violations
|
|
200
197
|
|
|
201
198
|
|
|
202
|
-
@
|
|
199
|
+
@post(lambda result: all(v.rule == "missing_contract" for v in result))
|
|
203
200
|
def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
204
201
|
"""
|
|
205
202
|
Check that public Core functions have contracts.
|
|
@@ -220,9 +217,13 @@ def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
220
217
|
if not file_info.is_core or not config.require_contracts:
|
|
221
218
|
return violations
|
|
222
219
|
|
|
220
|
+
source = file_info.source or ""
|
|
223
221
|
for symbol in file_info.symbols:
|
|
224
222
|
# Check all functions and methods - agent needs contracts everywhere
|
|
225
223
|
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and not symbol.contracts:
|
|
224
|
+
# DX-22: Skip if @invar:allow marker present
|
|
225
|
+
if has_allow_marker(symbol, source, "missing_contract"):
|
|
226
|
+
continue
|
|
226
227
|
kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
227
228
|
suggestion = format_suggestion_for_violation(symbol, "missing_contract")
|
|
228
229
|
violations.append(
|
|
@@ -239,7 +240,7 @@ def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
239
240
|
return violations
|
|
240
241
|
|
|
241
242
|
|
|
242
|
-
@
|
|
243
|
+
@post(lambda result: all(v.rule == "missing_doctest" for v in result))
|
|
243
244
|
def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
244
245
|
"""
|
|
245
246
|
Check that contracted functions have doctest examples.
|
|
@@ -289,12 +290,15 @@ def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
289
290
|
return violations
|
|
290
291
|
|
|
291
292
|
|
|
292
|
-
@
|
|
293
|
+
@post(lambda result: all(v.rule == "shell_result" for v in result))
|
|
293
294
|
def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
294
295
|
"""
|
|
295
296
|
Check that Shell functions with return values use Result[T, E].
|
|
296
297
|
|
|
297
|
-
Skips:
|
|
298
|
+
Skips:
|
|
299
|
+
- Functions returning None (CLI entry points)
|
|
300
|
+
- Generators (Iterator/Generator/AsyncIterator/AsyncGenerator)
|
|
301
|
+
- Entry points (DX-23: framework callbacks like Flask routes, Typer commands)
|
|
298
302
|
|
|
299
303
|
Examples:
|
|
300
304
|
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
@@ -314,23 +318,75 @@ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violatio
|
|
|
314
318
|
# Skip functions with no return type or returning None
|
|
315
319
|
if "-> None" in symbol.signature or "->" not in symbol.signature:
|
|
316
320
|
continue
|
|
317
|
-
# Skip generators (Iterator/Generator) - acceptable
|
|
318
|
-
|
|
321
|
+
# Skip generators (Iterator/Generator/AsyncIterator/AsyncGenerator) - acceptable per protocol
|
|
322
|
+
# MINOR-11: Added async variants
|
|
323
|
+
if any(
|
|
324
|
+
pattern in symbol.signature
|
|
325
|
+
for pattern in ("Iterator[", "Generator[", "AsyncIterator[", "AsyncGenerator[")
|
|
326
|
+
):
|
|
327
|
+
continue
|
|
328
|
+
# DX-23: Skip entry points; DX-22: Skip if @invar:allow marker
|
|
329
|
+
if is_entry_point(symbol, file_info.source) or has_allow_marker(symbol, file_info.source, "shell_result"):
|
|
319
330
|
continue
|
|
320
331
|
if "Result[" not in symbol.signature:
|
|
321
332
|
violations.append(
|
|
322
333
|
Violation(
|
|
323
334
|
rule="shell_result",
|
|
324
|
-
severity=Severity.
|
|
335
|
+
severity=Severity.ERROR, # DX-22: Architecture rule
|
|
325
336
|
file=file_info.path,
|
|
326
337
|
line=symbol.line,
|
|
327
338
|
message=f"Shell function '{symbol.name}' should return Result[T, E]",
|
|
328
|
-
suggestion="Use Result[T, E]
|
|
339
|
+
suggestion="Use Result[T, E], or add: # @invar:allow shell_result: <reason>",
|
|
329
340
|
)
|
|
330
341
|
)
|
|
331
342
|
return violations
|
|
332
343
|
|
|
333
344
|
|
|
345
|
+
@post(lambda result: all(v.rule == "entry_point_too_thick" for v in result))
|
|
346
|
+
def check_entry_point_thin(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
347
|
+
"""
|
|
348
|
+
Check that entry points are thin (DX-23).
|
|
349
|
+
|
|
350
|
+
Entry points should delegate to Shell functions and not contain
|
|
351
|
+
business logic. They serve as "monad runners" at framework boundaries.
|
|
352
|
+
|
|
353
|
+
Examples:
|
|
354
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
355
|
+
>>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
356
|
+
>>> source = '@app.route("/")\\ndef index(): pass'
|
|
357
|
+
>>> info = FileInfo(path="shell/web.py", lines=10, symbols=[sym], is_shell=True, source=source)
|
|
358
|
+
>>> check_entry_point_thin(info, RuleConfig())
|
|
359
|
+
[]
|
|
360
|
+
"""
|
|
361
|
+
violations: list[Violation] = []
|
|
362
|
+
if not file_info.is_shell:
|
|
363
|
+
return violations
|
|
364
|
+
|
|
365
|
+
max_lines = config.entry_max_lines
|
|
366
|
+
|
|
367
|
+
for symbol in file_info.symbols:
|
|
368
|
+
if symbol.kind != SymbolKind.FUNCTION:
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
# Only check entry points; DX-22: Skip if @invar:allow marker
|
|
372
|
+
if not is_entry_point(symbol, file_info.source) or has_allow_marker(symbol, file_info.source, "entry_point_too_thick"):
|
|
373
|
+
continue
|
|
374
|
+
lines = get_symbol_lines(symbol)
|
|
375
|
+
if lines > max_lines:
|
|
376
|
+
violations.append(
|
|
377
|
+
Violation(
|
|
378
|
+
rule="entry_point_too_thick",
|
|
379
|
+
severity=Severity.ERROR, # DX-22: Architecture rule
|
|
380
|
+
file=file_info.path,
|
|
381
|
+
line=symbol.line,
|
|
382
|
+
message=f"Entry point '{symbol.name}' has {lines} lines (max: {max_lines})",
|
|
383
|
+
suggestion="Move logic to Shell function, or add: # @invar:allow entry_point_too_thick: <reason>",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
return violations
|
|
388
|
+
|
|
389
|
+
|
|
334
390
|
@post(lambda result: len(result) > 0)
|
|
335
391
|
def get_all_rules() -> list[RuleFunc]:
|
|
336
392
|
"""
|
|
@@ -347,6 +403,9 @@ def get_all_rules() -> list[RuleFunc]:
|
|
|
347
403
|
check_contracts,
|
|
348
404
|
check_doctests,
|
|
349
405
|
check_shell_result,
|
|
406
|
+
check_entry_point_thin, # DX-23
|
|
407
|
+
check_shell_pure_logic, # DX-22
|
|
408
|
+
check_shell_too_complex, # DX-22
|
|
350
409
|
check_internal_imports,
|
|
351
410
|
check_impure_calls,
|
|
352
411
|
check_empty_contracts,
|
|
@@ -354,7 +413,11 @@ def get_all_rules() -> list[RuleFunc]:
|
|
|
354
413
|
check_redundant_type_contracts,
|
|
355
414
|
check_param_mismatch,
|
|
356
415
|
check_partial_contract,
|
|
416
|
+
check_postcondition_scope,
|
|
357
417
|
check_must_use,
|
|
418
|
+
check_skip_without_reason, # DX-28
|
|
419
|
+
check_contract_quality_ratio, # DX-30
|
|
420
|
+
check_review_suggested, # DX-31
|
|
358
421
|
]
|
|
359
422
|
|
|
360
423
|
|
|
@@ -398,7 +461,7 @@ def _apply_severity_override(v: Violation, overrides: dict[str, str]) -> Violati
|
|
|
398
461
|
)
|
|
399
462
|
|
|
400
463
|
|
|
401
|
-
@
|
|
464
|
+
@post(lambda result: all(v.rule and v.file for v in result) if result else True)
|
|
402
465
|
def check_all_rules(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
403
466
|
"""
|
|
404
467
|
Run all rules against a file and collect violations.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shell source analysis helpers for DX-22.
|
|
3
|
+
|
|
4
|
+
Helper functions for analyzing Shell layer code:
|
|
5
|
+
- I/O operation detection
|
|
6
|
+
- Marker pattern detection
|
|
7
|
+
- Branch counting
|
|
8
|
+
- Symbol source extraction
|
|
9
|
+
|
|
10
|
+
Core module: pure logic, no I/O.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import re
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from deal import post, pre
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from invar.core.models import Symbol
|
|
23
|
+
|
|
24
|
+
# I/O indicators that mark a function as legitimately in Shell
|
|
25
|
+
IO_INDICATORS: frozenset[str] = frozenset([
|
|
26
|
+
# File operations
|
|
27
|
+
".read(",
|
|
28
|
+
".write(",
|
|
29
|
+
".read_text(",
|
|
30
|
+
".write_text(",
|
|
31
|
+
".read_bytes(",
|
|
32
|
+
".write_bytes(",
|
|
33
|
+
"open(",
|
|
34
|
+
"Path(",
|
|
35
|
+
".exists()",
|
|
36
|
+
".is_file()",
|
|
37
|
+
".is_dir()",
|
|
38
|
+
".rglob(",
|
|
39
|
+
".glob(",
|
|
40
|
+
".iterdir(",
|
|
41
|
+
".mkdir(",
|
|
42
|
+
".unlink(",
|
|
43
|
+
"shutil.",
|
|
44
|
+
"tempfile.",
|
|
45
|
+
# Process operations
|
|
46
|
+
"subprocess.",
|
|
47
|
+
"os.system(",
|
|
48
|
+
"os.popen(",
|
|
49
|
+
"os.getenv(",
|
|
50
|
+
"os.environ",
|
|
51
|
+
# Terminal/System
|
|
52
|
+
"sys.stdout",
|
|
53
|
+
"sys.stderr",
|
|
54
|
+
"sys.stdin",
|
|
55
|
+
".isatty()",
|
|
56
|
+
# Module loading
|
|
57
|
+
"importlib.",
|
|
58
|
+
"exec_module(",
|
|
59
|
+
# Network operations
|
|
60
|
+
"requests.",
|
|
61
|
+
"aiohttp.",
|
|
62
|
+
"httpx.",
|
|
63
|
+
"urllib.",
|
|
64
|
+
# Console output
|
|
65
|
+
"print(",
|
|
66
|
+
"console.",
|
|
67
|
+
"Console(",
|
|
68
|
+
"typer.",
|
|
69
|
+
"click.",
|
|
70
|
+
"rich.",
|
|
71
|
+
# Result wrapping (Shell's primary job)
|
|
72
|
+
"Success(",
|
|
73
|
+
"Failure(",
|
|
74
|
+
"Result[",
|
|
75
|
+
# Database
|
|
76
|
+
"cursor.",
|
|
77
|
+
"connection.",
|
|
78
|
+
"session.",
|
|
79
|
+
# Logging
|
|
80
|
+
"logger.",
|
|
81
|
+
"logging.",
|
|
82
|
+
# Serialization (often to files)
|
|
83
|
+
"json.dump(",
|
|
84
|
+
"json.load(",
|
|
85
|
+
"toml.load(",
|
|
86
|
+
"yaml.load(",
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
# Marker pattern to exempt functions from complexity check
|
|
90
|
+
COMPLEXITY_MARKER_PATTERN = re.compile(r"#\s*@shell_complexity\s*:")
|
|
91
|
+
|
|
92
|
+
# Marker pattern to exempt functions from pure logic check (for orchestration functions)
|
|
93
|
+
ORCHESTRATION_MARKER_PATTERN = re.compile(r"#\s*@shell_orchestration\s*:")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# @invar:allow missing_contract: Boolean predicate, empty string is valid input
|
|
97
|
+
def has_io_operations(source: str) -> bool:
|
|
98
|
+
"""
|
|
99
|
+
Check if source code contains I/O operations.
|
|
100
|
+
|
|
101
|
+
Examples:
|
|
102
|
+
>>> has_io_operations("x = Success(value)")
|
|
103
|
+
True
|
|
104
|
+
>>> has_io_operations("return x + y")
|
|
105
|
+
False
|
|
106
|
+
>>> has_io_operations("path.read_text()")
|
|
107
|
+
True
|
|
108
|
+
>>> has_io_operations("print('hello')")
|
|
109
|
+
True
|
|
110
|
+
"""
|
|
111
|
+
return any(indicator in source for indicator in IO_INDICATORS)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pre(lambda symbol, source: symbol is not None) # Symbol must exist
|
|
115
|
+
def has_orchestration_marker(symbol: Symbol, source: str) -> bool:
|
|
116
|
+
"""
|
|
117
|
+
Check if symbol has @shell_orchestration marker comment.
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
>>> from invar.core.models import Symbol, SymbolKind
|
|
121
|
+
>>> sym = Symbol(name="run_phase", kind=SymbolKind.FUNCTION, line=3, end_line=10)
|
|
122
|
+
>>> source = '''
|
|
123
|
+
... # @shell_orchestration: Coordinates shell modules
|
|
124
|
+
... def run_phase():
|
|
125
|
+
... pass
|
|
126
|
+
... '''
|
|
127
|
+
>>> has_orchestration_marker(sym, source)
|
|
128
|
+
True
|
|
129
|
+
|
|
130
|
+
>>> sym2 = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=3)
|
|
131
|
+
>>> has_orchestration_marker(sym2, "def calc(): pass")
|
|
132
|
+
False
|
|
133
|
+
"""
|
|
134
|
+
lines = source.splitlines()
|
|
135
|
+
if not lines:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
start_line = max(0, symbol.line - 4)
|
|
139
|
+
end_line = symbol.line
|
|
140
|
+
|
|
141
|
+
context_lines = lines[start_line:end_line]
|
|
142
|
+
context = "\n".join(context_lines)
|
|
143
|
+
|
|
144
|
+
return bool(ORCHESTRATION_MARKER_PATTERN.search(context))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pre(lambda symbol, source: symbol is not None) # Symbol must exist
|
|
148
|
+
def has_complexity_marker(symbol: Symbol, source: str) -> bool:
|
|
149
|
+
"""
|
|
150
|
+
Check if symbol has @shell_complexity marker comment.
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
>>> from invar.core.models import Symbol, SymbolKind
|
|
154
|
+
>>> sym = Symbol(name="load", kind=SymbolKind.FUNCTION, line=3, end_line=10)
|
|
155
|
+
>>> source = '''
|
|
156
|
+
... # @shell_complexity: Config cascade with fallbacks
|
|
157
|
+
... def load():
|
|
158
|
+
... pass
|
|
159
|
+
... '''
|
|
160
|
+
>>> has_complexity_marker(sym, source)
|
|
161
|
+
True
|
|
162
|
+
|
|
163
|
+
>>> sym2 = Symbol(name="simple", kind=SymbolKind.FUNCTION, line=1, end_line=3)
|
|
164
|
+
>>> has_complexity_marker(sym2, "def simple(): pass")
|
|
165
|
+
False
|
|
166
|
+
"""
|
|
167
|
+
lines = source.splitlines()
|
|
168
|
+
if not lines:
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
# Look at lines before the function definition
|
|
172
|
+
start_line = max(0, symbol.line - 4)
|
|
173
|
+
end_line = symbol.line
|
|
174
|
+
|
|
175
|
+
context_lines = lines[start_line:end_line]
|
|
176
|
+
context = "\n".join(context_lines)
|
|
177
|
+
|
|
178
|
+
return bool(COMPLEXITY_MARKER_PATTERN.search(context))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@post(lambda result: result >= 0) # Branch count is non-negative
|
|
182
|
+
def count_branches(source: str) -> int:
|
|
183
|
+
"""
|
|
184
|
+
Count the number of branches in source code.
|
|
185
|
+
|
|
186
|
+
Counts: if, elif, except, for, while, match case, ternary
|
|
187
|
+
|
|
188
|
+
Examples:
|
|
189
|
+
>>> count_branches("if x: pass")
|
|
190
|
+
1
|
|
191
|
+
>>> count_branches("if x: pass\\nelif y: pass")
|
|
192
|
+
2
|
|
193
|
+
>>> count_branches("for x in y: pass")
|
|
194
|
+
1
|
|
195
|
+
>>> count_branches("x = a if b else c")
|
|
196
|
+
1
|
|
197
|
+
>>> count_branches("pass")
|
|
198
|
+
0
|
|
199
|
+
>>> count_branches("")
|
|
200
|
+
0
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
tree = ast.parse(source)
|
|
204
|
+
except SyntaxError:
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
count = 0
|
|
208
|
+
for node in ast.walk(tree):
|
|
209
|
+
if isinstance(node, ast.If):
|
|
210
|
+
# ast.walk visits all If nodes including elifs
|
|
211
|
+
count += 1
|
|
212
|
+
elif isinstance(node, ast.For | ast.While | ast.ExceptHandler):
|
|
213
|
+
count += 1
|
|
214
|
+
elif isinstance(node, ast.Match):
|
|
215
|
+
# Count match cases
|
|
216
|
+
count += len(node.cases)
|
|
217
|
+
elif isinstance(node, ast.IfExp):
|
|
218
|
+
# Ternary expression
|
|
219
|
+
count += 1
|
|
220
|
+
|
|
221
|
+
return count
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@pre(lambda symbol, file_source: symbol is not None) # Symbol must exist
|
|
225
|
+
def get_symbol_source(symbol: Symbol, file_source: str) -> str:
|
|
226
|
+
"""
|
|
227
|
+
Extract the source code for a specific symbol.
|
|
228
|
+
|
|
229
|
+
Examples:
|
|
230
|
+
>>> from invar.core.models import Symbol, SymbolKind
|
|
231
|
+
>>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=2, end_line=4)
|
|
232
|
+
>>> source = '''# comment
|
|
233
|
+
... def foo():
|
|
234
|
+
... return 1
|
|
235
|
+
... '''
|
|
236
|
+
>>> 'def foo' in get_symbol_source(sym, source)
|
|
237
|
+
True
|
|
238
|
+
"""
|
|
239
|
+
lines = file_source.splitlines()
|
|
240
|
+
if not lines:
|
|
241
|
+
return ""
|
|
242
|
+
|
|
243
|
+
# Line numbers are 1-indexed
|
|
244
|
+
start = max(0, symbol.line - 1)
|
|
245
|
+
end = min(len(lines), symbol.end_line)
|
|
246
|
+
|
|
247
|
+
return "\n".join(lines[start:end])
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shell architecture rules for DX-22.
|
|
3
|
+
|
|
4
|
+
Detects architectural issues in Shell layer:
|
|
5
|
+
- shell_pure_logic: Pure logic that belongs in Core
|
|
6
|
+
- shell_too_complex: Excessive branching complexity
|
|
7
|
+
- shell_complexity_debt: Accumulated complexity warnings
|
|
8
|
+
|
|
9
|
+
Core module: pure logic, no I/O.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from deal import post, pre
|
|
15
|
+
|
|
16
|
+
from invar.core.entry_points import get_symbol_lines, is_entry_point
|
|
17
|
+
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
18
|
+
from invar.core.shell_analysis import (
|
|
19
|
+
count_branches,
|
|
20
|
+
get_symbol_source,
|
|
21
|
+
has_complexity_marker,
|
|
22
|
+
has_io_operations,
|
|
23
|
+
has_orchestration_marker,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@post(lambda result: all(v.rule == "shell_pure_logic" for v in result))
|
|
28
|
+
def check_shell_pure_logic(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
29
|
+
"""
|
|
30
|
+
Check that Shell functions contain I/O operations (DX-22).
|
|
31
|
+
|
|
32
|
+
Pure logic belongs in Core where it can be tested with contracts.
|
|
33
|
+
Functions > 5 lines without I/O indicators are flagged as WARNING.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
37
|
+
>>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=10)
|
|
38
|
+
>>> source = "def calc(x, y):\\n return x + y"
|
|
39
|
+
>>> info = FileInfo(path="shell/util.py", lines=10, symbols=[sym], is_shell=True, source=source)
|
|
40
|
+
>>> violations = check_shell_pure_logic(info, RuleConfig())
|
|
41
|
+
>>> len(violations) >= 0 # May or may not flag based on line count
|
|
42
|
+
True
|
|
43
|
+
"""
|
|
44
|
+
violations: list[Violation] = []
|
|
45
|
+
if not file_info.is_shell:
|
|
46
|
+
return violations
|
|
47
|
+
|
|
48
|
+
for symbol in file_info.symbols:
|
|
49
|
+
if symbol.kind != SymbolKind.FUNCTION:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
# Skip small functions (wrappers are fine)
|
|
53
|
+
lines = get_symbol_lines(symbol)
|
|
54
|
+
if lines <= 5:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
# Skip entry points (they're handled by DX-23)
|
|
58
|
+
if is_entry_point(symbol, file_info.source):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Skip if marked with @shell_orchestration (coordinates other shell modules)
|
|
62
|
+
if has_orchestration_marker(symbol, file_info.source):
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# Get symbol source and check for I/O
|
|
66
|
+
symbol_source = get_symbol_source(symbol, file_info.source)
|
|
67
|
+
if not has_io_operations(symbol_source):
|
|
68
|
+
violations.append(
|
|
69
|
+
Violation(
|
|
70
|
+
rule="shell_pure_logic",
|
|
71
|
+
severity=Severity.WARNING,
|
|
72
|
+
file=file_info.path,
|
|
73
|
+
line=symbol.line,
|
|
74
|
+
message=f"Shell function '{symbol.name}' has no I/O operations - pure logic belongs in Core",
|
|
75
|
+
suggestion="Move to src/*/core/ and add @pre/@post contracts, or add: # @shell_orchestration: <reason>",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return violations
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@post(lambda result: all(v.rule == "shell_too_complex" for v in result))
|
|
83
|
+
def check_shell_too_complex(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
84
|
+
"""
|
|
85
|
+
Check that Shell functions don't have excessive branching (DX-22).
|
|
86
|
+
|
|
87
|
+
Complex logic should be in Core where it can be tested.
|
|
88
|
+
Functions exceeding shell_max_branches are flagged as INFO.
|
|
89
|
+
|
|
90
|
+
Use @shell_complexity marker to exempt justified complexity.
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
94
|
+
>>> sym = Symbol(name="process", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
95
|
+
>>> source = "def process(x):\\n return x"
|
|
96
|
+
>>> info = FileInfo(path="shell/cli.py", lines=5, symbols=[sym], is_shell=True, source=source)
|
|
97
|
+
>>> check_shell_too_complex(info, RuleConfig())
|
|
98
|
+
[]
|
|
99
|
+
"""
|
|
100
|
+
violations: list[Violation] = []
|
|
101
|
+
if not file_info.is_shell:
|
|
102
|
+
return violations
|
|
103
|
+
|
|
104
|
+
max_branches = config.shell_max_branches
|
|
105
|
+
|
|
106
|
+
for symbol in file_info.symbols:
|
|
107
|
+
if symbol.kind != SymbolKind.FUNCTION:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# Skip if marked with @shell_complexity
|
|
111
|
+
if has_complexity_marker(symbol, file_info.source):
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Skip entry points
|
|
115
|
+
if is_entry_point(symbol, file_info.source):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Count branches in symbol source
|
|
119
|
+
symbol_source = get_symbol_source(symbol, file_info.source)
|
|
120
|
+
branches = count_branches(symbol_source)
|
|
121
|
+
|
|
122
|
+
if branches > max_branches:
|
|
123
|
+
violations.append(
|
|
124
|
+
Violation(
|
|
125
|
+
rule="shell_too_complex",
|
|
126
|
+
severity=Severity.INFO,
|
|
127
|
+
file=file_info.path,
|
|
128
|
+
line=symbol.line,
|
|
129
|
+
message=f"Shell function '{symbol.name}' has {branches} branches (max: {max_branches})",
|
|
130
|
+
suggestion="Extract logic to Core, or add: # @shell_complexity: <reason>",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return violations
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pre(lambda violations, limit: isinstance(violations, list) and limit > 0)
|
|
138
|
+
@post(lambda result: isinstance(result, list))
|
|
139
|
+
def check_complexity_debt(violations: list[Violation], limit: int = 5) -> list[Violation]:
|
|
140
|
+
"""
|
|
141
|
+
Check project-level complexity debt (DX-22 Fix-or-Explain).
|
|
142
|
+
|
|
143
|
+
When the project has too many unaddressed shell_too_complex warnings,
|
|
144
|
+
escalate to ERROR to force resolution.
|
|
145
|
+
|
|
146
|
+
Examples:
|
|
147
|
+
>>> from invar.core.models import Violation, Severity
|
|
148
|
+
>>> v1 = Violation(rule="shell_too_complex", severity=Severity.INFO, file="a.py", message="m")
|
|
149
|
+
>>> v2 = Violation(rule="shell_too_complex", severity=Severity.INFO, file="b.py", message="m")
|
|
150
|
+
>>> check_complexity_debt([v1, v2], limit=5)
|
|
151
|
+
[]
|
|
152
|
+
>>> many = [Violation(rule="shell_too_complex", severity=Severity.INFO, file=f"{i}.py", message="m") for i in range(6)]
|
|
153
|
+
>>> result = check_complexity_debt(many, limit=5)
|
|
154
|
+
>>> len(result)
|
|
155
|
+
1
|
|
156
|
+
>>> result[0].severity == Severity.ERROR
|
|
157
|
+
True
|
|
158
|
+
"""
|
|
159
|
+
unaddressed = [v for v in violations if v.rule == "shell_too_complex"]
|
|
160
|
+
if len(unaddressed) >= limit:
|
|
161
|
+
return [
|
|
162
|
+
Violation(
|
|
163
|
+
rule="shell_complexity_debt",
|
|
164
|
+
severity=Severity.ERROR,
|
|
165
|
+
file="<project>",
|
|
166
|
+
line=None,
|
|
167
|
+
message=f"Project has {len(unaddressed)} unaddressed complexity warnings (limit: {limit})",
|
|
168
|
+
suggestion="You must address these before proceeding:\n1. Refactor to reduce branches, OR\n2. Add @shell_complexity: markers with justification",
|
|
169
|
+
)
|
|
170
|
+
]
|
|
171
|
+
return []
|