invar-tools 1.8.0__py3-none-any.whl → 1.11.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.
Files changed (117) hide show
  1. invar/__init__.py +8 -0
  2. invar/core/doc_edit.py +187 -0
  3. invar/core/doc_parser.py +563 -0
  4. invar/core/language.py +88 -0
  5. invar/core/models.py +106 -0
  6. invar/core/patterns/detector.py +6 -1
  7. invar/core/patterns/p0_exhaustive.py +15 -3
  8. invar/core/patterns/p0_literal.py +15 -3
  9. invar/core/patterns/p0_newtype.py +15 -3
  10. invar/core/patterns/p0_nonempty.py +15 -3
  11. invar/core/patterns/p0_validation.py +15 -3
  12. invar/core/patterns/registry.py +5 -1
  13. invar/core/patterns/types.py +5 -1
  14. invar/core/property_gen.py +4 -0
  15. invar/core/rules.py +84 -18
  16. invar/core/sync_helpers.py +27 -1
  17. invar/core/ts_parsers.py +286 -0
  18. invar/core/ts_sig_parser.py +310 -0
  19. invar/mcp/handlers.py +408 -0
  20. invar/mcp/server.py +288 -143
  21. invar/node_tools/MANIFEST +7 -0
  22. invar/node_tools/__init__.py +51 -0
  23. invar/node_tools/fc-runner/cli.js +77 -0
  24. invar/node_tools/quick-check/cli.js +28 -0
  25. invar/node_tools/ts-analyzer/cli.js +480 -0
  26. invar/shell/claude_hooks.py +35 -12
  27. invar/shell/commands/doc.py +409 -0
  28. invar/shell/commands/guard.py +41 -1
  29. invar/shell/commands/init.py +154 -16
  30. invar/shell/commands/perception.py +157 -33
  31. invar/shell/commands/skill.py +187 -0
  32. invar/shell/commands/template_sync.py +65 -13
  33. invar/shell/commands/uninstall.py +60 -12
  34. invar/shell/commands/update.py +6 -14
  35. invar/shell/contract_coverage.py +1 -0
  36. invar/shell/doc_tools.py +459 -0
  37. invar/shell/fs.py +67 -13
  38. invar/shell/pi_hooks.py +6 -0
  39. invar/shell/prove/crosshair.py +3 -0
  40. invar/shell/prove/guard_ts.py +902 -0
  41. invar/shell/skill_manager.py +355 -0
  42. invar/shell/template_engine.py +28 -4
  43. invar/shell/templates.py +4 -4
  44. invar/templates/claude-md/python/critical-rules.md +33 -0
  45. invar/templates/claude-md/python/quick-reference.md +24 -0
  46. invar/templates/claude-md/typescript/critical-rules.md +40 -0
  47. invar/templates/claude-md/typescript/quick-reference.md +24 -0
  48. invar/templates/claude-md/universal/check-in.md +25 -0
  49. invar/templates/claude-md/universal/skills.md +73 -0
  50. invar/templates/claude-md/universal/workflow.md +55 -0
  51. invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
  52. invar/templates/config/AGENT.md.jinja +58 -0
  53. invar/templates/config/CLAUDE.md.jinja +16 -209
  54. invar/templates/config/context.md.jinja +19 -0
  55. invar/templates/examples/{README.md → python/README.md} +2 -0
  56. invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
  57. invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
  58. invar/templates/examples/python/core_shell.py +227 -0
  59. invar/templates/examples/python/functional.py +613 -0
  60. invar/templates/examples/typescript/README.md +31 -0
  61. invar/templates/examples/typescript/contracts.ts +163 -0
  62. invar/templates/examples/typescript/core_shell.ts +374 -0
  63. invar/templates/examples/typescript/functional.ts +601 -0
  64. invar/templates/examples/typescript/workflow.md +95 -0
  65. invar/templates/hooks/PostToolUse.sh.jinja +10 -1
  66. invar/templates/hooks/PreToolUse.sh.jinja +38 -0
  67. invar/templates/hooks/Stop.sh.jinja +1 -1
  68. invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
  69. invar/templates/hooks/pi/invar.ts.jinja +9 -0
  70. invar/templates/manifest.toml +7 -6
  71. invar/templates/onboard/assessment.md.jinja +214 -0
  72. invar/templates/onboard/patterns/python.md +347 -0
  73. invar/templates/onboard/patterns/typescript.md +452 -0
  74. invar/templates/onboard/roadmap.md.jinja +168 -0
  75. invar/templates/protocol/INVAR.md.jinja +51 -0
  76. invar/templates/protocol/python/architecture-examples.md +41 -0
  77. invar/templates/protocol/python/contracts-syntax.md +56 -0
  78. invar/templates/protocol/python/markers.md +44 -0
  79. invar/templates/protocol/python/tools.md +24 -0
  80. invar/templates/protocol/python/troubleshooting.md +38 -0
  81. invar/templates/protocol/typescript/architecture-examples.md +52 -0
  82. invar/templates/protocol/typescript/contracts-syntax.md +73 -0
  83. invar/templates/protocol/typescript/markers.md +48 -0
  84. invar/templates/protocol/typescript/tools.md +65 -0
  85. invar/templates/protocol/typescript/troubleshooting.md +104 -0
  86. invar/templates/protocol/universal/architecture.md +36 -0
  87. invar/templates/protocol/universal/completion.md +14 -0
  88. invar/templates/protocol/universal/contracts-concept.md +37 -0
  89. invar/templates/protocol/universal/header.md +17 -0
  90. invar/templates/protocol/universal/session.md +17 -0
  91. invar/templates/protocol/universal/six-laws.md +10 -0
  92. invar/templates/protocol/universal/usbv.md +14 -0
  93. invar/templates/protocol/universal/visible-workflow.md +25 -0
  94. invar/templates/skills/develop/SKILL.md.jinja +85 -3
  95. invar/templates/skills/extensions/_registry.yaml +93 -0
  96. invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
  97. invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
  98. invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
  99. invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
  100. invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
  101. invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
  102. invar/templates/skills/extensions/security/SKILL.md +382 -0
  103. invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
  104. invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
  105. invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
  106. invar/templates/skills/review/SKILL.md.jinja +220 -248
  107. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +336 -12
  108. invar_tools-1.11.0.dist-info/RECORD +178 -0
  109. invar/templates/examples/core_shell.py +0 -127
  110. invar/templates/protocol/INVAR.md +0 -310
  111. invar_tools-1.8.0.dist-info/RECORD +0 -116
  112. /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
  113. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/WHEEL +0 -0
  114. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
  115. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
  116. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
  117. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/NOTICE +0 -0
invar/core/models.py CHANGED
@@ -1,3 +1,4 @@
1
+ # @invar:allow file_size: LX-10 added layer types and functions, extraction planned
1
2
  """
2
3
  Pydantic models for Invar.
3
4
 
@@ -7,6 +8,7 @@ No I/O operations allowed.
7
8
 
8
9
  from __future__ import annotations
9
10
 
11
+ from dataclasses import dataclass
10
12
  from enum import Enum
11
13
  from typing import Literal
12
14
 
@@ -31,6 +33,47 @@ class Severity(str, Enum):
31
33
  SUGGEST = "suggest" # DX-61: Functional pattern suggestions
32
34
 
33
35
 
36
+ class CodeLayer(str, Enum):
37
+ """Code layer for differentiated size limits (LX-10)."""
38
+
39
+ CORE = "core"
40
+ SHELL = "shell"
41
+ TESTS = "tests"
42
+ DEFAULT = "default"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class LayerLimits:
47
+ """Size limits for a specific code layer (LX-10).
48
+
49
+ Examples:
50
+ >>> limits = LayerLimits(max_file_lines=500, max_function_lines=50)
51
+ >>> limits.max_file_lines
52
+ 500
53
+ >>> limits.max_function_lines
54
+ 50
55
+ """
56
+
57
+ max_file_lines: int
58
+ max_function_lines: int
59
+
60
+
61
+ # LX-10: Hardcoded layer limits (no config needed)
62
+ PYTHON_LAYER_LIMITS: dict[CodeLayer, LayerLimits] = {
63
+ CodeLayer.CORE: LayerLimits(500, 50),
64
+ CodeLayer.SHELL: LayerLimits(700, 100),
65
+ CodeLayer.TESTS: LayerLimits(1000, 200),
66
+ CodeLayer.DEFAULT: LayerLimits(600, 80),
67
+ }
68
+
69
+ TYPESCRIPT_LAYER_LIMITS: dict[CodeLayer, LayerLimits] = {
70
+ CodeLayer.CORE: LayerLimits(650, 65),
71
+ CodeLayer.SHELL: LayerLimits(910, 130),
72
+ CodeLayer.TESTS: LayerLimits(1300, 260),
73
+ CodeLayer.DEFAULT: LayerLimits(780, 104),
74
+ }
75
+
76
+
34
77
  class Contract(BaseModel):
35
78
  """A contract (precondition or postcondition) on a function."""
36
79
 
@@ -72,6 +115,69 @@ class FileInfo(BaseModel):
72
115
  source: str = "" # Original source code for advanced analysis
73
116
 
74
117
 
118
+ # LX-10: Layer detection functions
119
+ @pre(lambda file_info: file_info is not None and hasattr(file_info, "path"))
120
+ @post(lambda result: result in CodeLayer)
121
+ def get_layer(file_info: FileInfo) -> CodeLayer:
122
+ """
123
+ Determine code layer from FileInfo classification.
124
+
125
+ Uses existing is_core/is_shell fields. Tests detection via path.
126
+
127
+ Examples:
128
+ >>> get_layer(FileInfo(path="src/core/logic.py", lines=10, is_core=True))
129
+ <CodeLayer.CORE: 'core'>
130
+ >>> get_layer(FileInfo(path="src/shell/cli.py", lines=10, is_shell=True))
131
+ <CodeLayer.SHELL: 'shell'>
132
+ >>> get_layer(FileInfo(path="tests/test_foo.py", lines=10))
133
+ <CodeLayer.TESTS: 'tests'>
134
+ >>> get_layer(FileInfo(path="src/utils.py", lines=10))
135
+ <CodeLayer.DEFAULT: 'default'>
136
+ >>> # Edge: "test_" must be at filename start, not anywhere in path
137
+ >>> get_layer(FileInfo(path="src/contest_utils.py", lines=10))
138
+ <CodeLayer.DEFAULT: 'default'>
139
+ >>> get_layer(FileInfo(path="src/foo_test.py", lines=10))
140
+ <CodeLayer.TESTS: 'tests'>
141
+ """
142
+ # Tests: path-based (no is_tests field exists)
143
+ path_lower = file_info.path.replace("\\", "/").lower()
144
+ filename = path_lower.rsplit("/", 1)[-1] # Extract filename
145
+ # Match: /tests/ dir, /test/ dir, test_*.py files, *_test.py files
146
+ if (
147
+ "/tests/" in path_lower
148
+ or "/test/" in path_lower
149
+ or filename.startswith("test_")
150
+ or filename.endswith("_test.py")
151
+ ):
152
+ return CodeLayer.TESTS
153
+
154
+ # Core/Shell: use existing classification
155
+ if file_info.is_core:
156
+ return CodeLayer.CORE
157
+ if file_info.is_shell:
158
+ return CodeLayer.SHELL
159
+
160
+ return CodeLayer.DEFAULT
161
+
162
+
163
+ @pre(lambda layer, language="python": isinstance(layer, CodeLayer) and language in ("python", "typescript"))
164
+ @post(lambda result: result.max_file_lines > 0 and result.max_function_lines > 0)
165
+ def get_limits(layer: CodeLayer, language: str = "python") -> LayerLimits:
166
+ """
167
+ Get size limits for layer and language.
168
+
169
+ Examples:
170
+ >>> get_limits(CodeLayer.CORE).max_function_lines
171
+ 50
172
+ >>> get_limits(CodeLayer.SHELL).max_function_lines
173
+ 100
174
+ >>> get_limits(CodeLayer.CORE, "typescript").max_function_lines
175
+ 65
176
+ """
177
+ limits = TYPESCRIPT_LAYER_LIMITS if language == "typescript" else PYTHON_LAYER_LIMITS
178
+ return limits[layer]
179
+
180
+
75
181
  class Violation(BaseModel):
76
182
  """A rule violation found by Guard."""
77
183
 
@@ -27,6 +27,7 @@ class PatternDetector(Protocol):
27
27
  Detectors analyze AST nodes and return suggestions with confidence levels.
28
28
  """
29
29
 
30
+ # @invar:allow missing_doctest: Abstract property - no executable implementation
30
31
  @property
31
32
  @abstractmethod
32
33
  @post(lambda result: result in PatternID)
@@ -34,6 +35,7 @@ class PatternDetector(Protocol):
34
35
  """Unique identifier for this pattern."""
35
36
  ...
36
37
 
38
+ # @invar:allow missing_doctest: Abstract property - no executable implementation
37
39
  @property
38
40
  @abstractmethod
39
41
  @post(lambda result: result in Priority)
@@ -41,6 +43,7 @@ class PatternDetector(Protocol):
41
43
  """Priority tier (P0 or P1)."""
42
44
  ...
43
45
 
46
+ # @invar:allow missing_doctest: Abstract property - no executable implementation
44
47
  @property
45
48
  @abstractmethod
46
49
  @post(lambda result: len(result) > 0)
@@ -48,6 +51,7 @@ class PatternDetector(Protocol):
48
51
  """Human-readable description of the pattern."""
49
52
  ...
50
53
 
54
+ # @invar:allow missing_doctest: Abstract method - no executable implementation
51
55
  @abstractmethod
52
56
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
53
57
  def detect(self, tree: ast.AST, file_path: str) -> list[PatternSuggestion]:
@@ -140,7 +144,8 @@ class BaseDetector:
140
144
  return f"{left} | {right}"
141
145
  else:
142
146
  # Python 3.9+ always has ast.unparse (project requires 3.11+)
143
- return ast.unparse(annotation)
147
+ result = ast.unparse(annotation)
148
+ return result if result else "<unknown>"
144
149
 
145
150
  @pre(lambda self, params, type_name: len(type_name) > 0)
146
151
  @post(lambda result: result >= 0)
@@ -51,19 +51,31 @@ class ExhaustiveMatchDetector(BaseDetector):
51
51
  @property
52
52
  @post(lambda result: result == PatternID.EXHAUSTIVE)
53
53
  def pattern_id(self) -> PatternID:
54
- """Unique identifier for this pattern."""
54
+ """Unique identifier for this pattern.
55
+
56
+ >>> ExhaustiveMatchDetector().pattern_id
57
+ <PatternID.EXHAUSTIVE: 'exhaustive'>
58
+ """
55
59
  return PatternID.EXHAUSTIVE
56
60
 
57
61
  @property
58
62
  @post(lambda result: result == Priority.P0)
59
63
  def priority(self) -> Priority:
60
- """Priority tier."""
64
+ """Priority tier.
65
+
66
+ >>> ExhaustiveMatchDetector().priority
67
+ <Priority.P0: 'P0'>
68
+ """
61
69
  return Priority.P0
62
70
 
63
71
  @property
64
72
  @post(lambda result: len(result) > 0)
65
73
  def description(self) -> str:
66
- """Human-readable description."""
74
+ """Human-readable description.
75
+
76
+ >>> len(ExhaustiveMatchDetector().description) > 0
77
+ True
78
+ """
67
79
  return "Use assert_never for exhaustive enum matching"
68
80
 
69
81
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -48,19 +48,31 @@ class LiteralDetector(BaseDetector):
48
48
  @property
49
49
  @post(lambda result: result == PatternID.LITERAL)
50
50
  def pattern_id(self) -> PatternID:
51
- """Unique identifier for this pattern."""
51
+ """Unique identifier for this pattern.
52
+
53
+ >>> LiteralDetector().pattern_id
54
+ <PatternID.LITERAL: 'literal'>
55
+ """
52
56
  return PatternID.LITERAL
53
57
 
54
58
  @property
55
59
  @post(lambda result: result == Priority.P0)
56
60
  def priority(self) -> Priority:
57
- """Priority tier."""
61
+ """Priority tier.
62
+
63
+ >>> LiteralDetector().priority
64
+ <Priority.P0: 'P0'>
65
+ """
58
66
  return Priority.P0
59
67
 
60
68
  @property
61
69
  @post(lambda result: len(result) > 0)
62
70
  def description(self) -> str:
63
- """Human-readable description."""
71
+ """Human-readable description.
72
+
73
+ >>> len(LiteralDetector().description) > 0
74
+ True
75
+ """
64
76
  return "Use Literal type for finite value sets"
65
77
 
66
78
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -50,19 +50,31 @@ class NewTypeDetector(BaseDetector):
50
50
  @property
51
51
  @post(lambda result: result == PatternID.NEWTYPE)
52
52
  def pattern_id(self) -> PatternID:
53
- """Unique identifier for this pattern."""
53
+ """Unique identifier for this pattern.
54
+
55
+ >>> NewTypeDetector().pattern_id
56
+ <PatternID.NEWTYPE: 'newtype'>
57
+ """
54
58
  return PatternID.NEWTYPE
55
59
 
56
60
  @property
57
61
  @post(lambda result: result == Priority.P0)
58
62
  def priority(self) -> Priority:
59
- """Priority tier."""
63
+ """Priority tier.
64
+
65
+ >>> NewTypeDetector().priority
66
+ <Priority.P0: 'P0'>
67
+ """
60
68
  return Priority.P0
61
69
 
62
70
  @property
63
71
  @post(lambda result: len(result) > 0)
64
72
  def description(self) -> str:
65
- """Human-readable description."""
73
+ """Human-readable description.
74
+
75
+ >>> len(NewTypeDetector().description) > 0
76
+ True
77
+ """
66
78
  return "Use NewType for semantic clarity with multiple same-type parameters"
67
79
 
68
80
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -47,19 +47,31 @@ class NonEmptyDetector(BaseDetector):
47
47
  @property
48
48
  @post(lambda result: result == PatternID.NONEMPTY)
49
49
  def pattern_id(self) -> PatternID:
50
- """Unique identifier for this pattern."""
50
+ """Unique identifier for this pattern.
51
+
52
+ >>> NonEmptyDetector().pattern_id
53
+ <PatternID.NONEMPTY: 'nonempty'>
54
+ """
51
55
  return PatternID.NONEMPTY
52
56
 
53
57
  @property
54
58
  @post(lambda result: result == Priority.P0)
55
59
  def priority(self) -> Priority:
56
- """Priority tier."""
60
+ """Priority tier.
61
+
62
+ >>> NonEmptyDetector().priority
63
+ <Priority.P0: 'P0'>
64
+ """
57
65
  return Priority.P0
58
66
 
59
67
  @property
60
68
  @post(lambda result: len(result) > 0)
61
69
  def description(self) -> str:
62
- """Human-readable description."""
70
+ """Human-readable description.
71
+
72
+ >>> len(NonEmptyDetector().description) > 0
73
+ True
74
+ """
63
75
  return "Use NonEmpty type for compile-time non-empty guarantees"
64
76
 
65
77
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -55,19 +55,31 @@ class ValidationDetector(BaseDetector):
55
55
  @property
56
56
  @post(lambda result: result == PatternID.VALIDATION)
57
57
  def pattern_id(self) -> PatternID:
58
- """Unique identifier for this pattern."""
58
+ """Unique identifier for this pattern.
59
+
60
+ >>> ValidationDetector().pattern_id
61
+ <PatternID.VALIDATION: 'validation'>
62
+ """
59
63
  return PatternID.VALIDATION
60
64
 
61
65
  @property
62
66
  @post(lambda result: result == Priority.P0)
63
67
  def priority(self) -> Priority:
64
- """Priority tier."""
68
+ """Priority tier.
69
+
70
+ >>> ValidationDetector().priority
71
+ <Priority.P0: 'P0'>
72
+ """
65
73
  return Priority.P0
66
74
 
67
75
  @property
68
76
  @post(lambda result: len(result) > 0)
69
77
  def description(self) -> str:
70
- """Human-readable description."""
78
+ """Human-readable description.
79
+
80
+ >>> len(ValidationDetector().description) > 0
81
+ True
82
+ """
71
83
  return "Use error accumulation instead of fail-fast validation"
72
84
 
73
85
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -53,7 +53,11 @@ class PatternRegistry:
53
53
  @property
54
54
  @post(lambda result: len(result) > 0)
55
55
  def detectors(self) -> list[PatternDetector]:
56
- """Get all registered detectors."""
56
+ """Get all registered detectors.
57
+
58
+ >>> len(PatternRegistry().detectors) >= 5
59
+ True
60
+ """
57
61
  return self._detectors
58
62
 
59
63
  @post(lambda result: result is not None)
@@ -149,7 +149,11 @@ class DetectionResult:
149
149
  @property
150
150
  @post(lambda result: isinstance(result, bool))
151
151
  def has_suggestions(self) -> bool:
152
- """Check if any suggestions were found."""
152
+ """Check if any suggestions were found.
153
+
154
+ >>> DetectionResult(file="test.py", suggestions=[], patterns_checked=[]).has_suggestions
155
+ False
156
+ """
153
157
  return len(self.suggestions) > 0
154
158
 
155
159
  @pre(lambda self, min_confidence: min_confidence in Confidence)
@@ -368,6 +368,7 @@ def _skip_result(name: str, reason: str) -> PropertyTestResult:
368
368
  _SKIP_PATTERNS = (
369
369
  "Nothing", "NoSuchExample", "filter_too_much", "Could not resolve",
370
370
  "validation error", "missing", "positional argument", "Unable to satisfy",
371
+ "has no attribute 'check'", # invar_runtime contracts, not deal contracts
371
372
  )
372
373
 
373
374
 
@@ -377,6 +378,9 @@ def _handle_test_exception(
377
378
  err_str: str, func_name: str, max_examples: int
378
379
  ) -> PropertyTestResult:
379
380
  """Handle exception from property test, returning skip or failure result."""
381
+ # Check for invar_runtime contracts (deal.cases requires deal contracts)
382
+ if "has no attribute 'check'" in err_str:
383
+ return _skip_result(func_name, "Skipped: uses invar_runtime (deal.cases requires deal contracts)")
380
384
  if any(p in err_str for p in _SKIP_PATTERNS):
381
385
  return _skip_result(func_name, "Skipped: untestable types")
382
386
  seed = _extract_hypothesis_seed(err_str)
invar/core/rules.py CHANGED
@@ -1,10 +1,11 @@
1
+ # @invar:allow file_size: LX-10 added doctests, consider extraction later
1
2
  """Rule engine for Guard. Rules check FileInfo and produce Violations. No I/O."""
2
3
 
3
4
  from __future__ import annotations
4
5
 
5
6
  from collections.abc import Callable
6
7
 
7
- from deal import post
8
+ from deal import post, pre
8
9
 
9
10
  from invar.core.contracts import (
10
11
  check_empty_contracts,
@@ -14,9 +15,22 @@ from invar.core.contracts import (
14
15
  check_semantic_tautology,
15
16
  check_skip_without_reason,
16
17
  )
17
- from invar.core.entry_points import get_symbol_lines, has_allow_marker, is_entry_point
18
+ from invar.core.entry_points import (
19
+ extract_escape_hatches,
20
+ get_symbol_lines,
21
+ has_allow_marker,
22
+ is_entry_point,
23
+ )
18
24
  from invar.core.extraction import format_extraction_hint
19
- from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
25
+ from invar.core.models import (
26
+ FileInfo,
27
+ RuleConfig,
28
+ Severity,
29
+ SymbolKind,
30
+ Violation,
31
+ get_layer,
32
+ get_limits,
33
+ )
20
34
  from invar.core.must_use import check_must_use
21
35
  from invar.core.postcondition_scope import check_postcondition_scope
22
36
  from invar.core.purity import check_impure_calls, check_internal_imports
@@ -63,11 +77,33 @@ def _get_func_hint(file_info: FileInfo) -> str:
63
77
  return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
64
78
 
65
79
 
80
+ @pre(lambda file_info, rule: file_info is not None and len(rule) > 0)
81
+ @post(lambda result: isinstance(result, bool))
82
+ def _has_file_escape(file_info: FileInfo, rule: str) -> bool:
83
+ """Check if file has escape hatch for given rule.
84
+
85
+ Examples:
86
+ >>> info = FileInfo(path="test.py", lines=10, source="# @invar:allow file_size: reason")
87
+ >>> _has_file_escape(info, "file_size")
88
+ True
89
+ >>> _has_file_escape(info, "other_rule")
90
+ False
91
+ >>> # Edge: empty source returns False
92
+ >>> _has_file_escape(FileInfo(path="x.py", lines=1), "any")
93
+ False
94
+ """
95
+ if not file_info.source:
96
+ return False
97
+ escapes = extract_escape_hatches(file_info.source)
98
+ return any(r == rule for r, _, _ in escapes)
99
+
100
+
66
101
  @post(lambda result: all(v.rule in ("file_size", "file_size_warning") for v in result))
67
102
  def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
68
103
  """
69
104
  Check if file exceeds maximum line count or warning threshold.
70
105
 
106
+ LX-10: Uses layer-based limits (Core/Shell/Tests/Default).
71
107
  P18: Shows function groups in size warnings to help agents decide what to extract.
72
108
  P25: Shows extractable groups with dependencies for warnings.
73
109
 
@@ -75,30 +111,43 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
75
111
  >>> from invar.core.models import FileInfo, RuleConfig
76
112
  >>> check_file_size(FileInfo(path="ok.py", lines=100), RuleConfig())
77
113
  []
78
- >>> len(check_file_size(FileInfo(path="big.py", lines=600), RuleConfig()))
114
+ >>> # Default layer: 600 lines max, error at 650
115
+ >>> len(check_file_size(FileInfo(path="big.py", lines=650), RuleConfig()))
116
+ 1
117
+ >>> # Shell layer: 700 lines max, no error at 650
118
+ >>> vs = check_file_size(FileInfo(path="shell/cli.py", lines=550, is_shell=True), RuleConfig())
119
+ >>> any(v.rule == "file_size" for v in vs)
120
+ False
121
+ >>> # Core layer: 500 lines max (strict)
122
+ >>> len(check_file_size(FileInfo(path="core/calc.py", lines=550, is_core=True), RuleConfig()))
79
123
  1
80
- >>> # P8: Warning at 80% threshold (400 lines when max is 500)
81
- >>> vs = check_file_size(FileInfo(path="growing.py", lines=420), RuleConfig())
82
- >>> len(vs) == 1 and vs[0].rule == "file_size_warning"
83
- True
84
124
  """
125
+ # Check for escape hatch
126
+ if _has_file_escape(file_info, "file_size"):
127
+ return []
128
+
85
129
  violations: list[Violation] = []
86
130
  func_hint = _get_func_hint(file_info)
87
131
  extraction_hint = format_extraction_hint(file_info)
88
132
 
89
- if file_info.lines > config.max_file_lines:
133
+ # LX-10: Get layer-based limits
134
+ layer = get_layer(file_info)
135
+ limits = get_limits(layer)
136
+ max_lines = limits.max_file_lines
137
+
138
+ if file_info.lines > max_lines:
90
139
  violations.append(Violation(
91
140
  rule="file_size", severity=Severity.ERROR, file=file_info.path, line=None,
92
- message=f"File has {file_info.lines} lines (max: {config.max_file_lines})",
141
+ message=f"File has {file_info.lines} lines (max: {max_lines} for {layer.value})",
93
142
  suggestion=_build_size_suggestion("Split into smaller modules.", extraction_hint, func_hint),
94
143
  ))
95
144
  elif config.size_warning_threshold > 0:
96
- threshold = int(config.max_file_lines * config.size_warning_threshold)
145
+ threshold = int(max_lines * config.size_warning_threshold)
97
146
  if file_info.lines >= threshold:
98
- pct = int(file_info.lines / config.max_file_lines * 100)
147
+ pct = int(file_info.lines / max_lines * 100)
99
148
  violations.append(Violation(
100
149
  rule="file_size_warning", severity=Severity.WARNING, file=file_info.path, line=None,
101
- message=f"File has {file_info.lines} lines ({pct}% of {config.max_file_lines} limit)",
150
+ message=f"File has {file_info.lines} lines ({pct}% of {max_lines} limit)",
102
151
  suggestion=_build_size_suggestion("Consider splitting before reaching limit.", extraction_hint, func_hint),
103
152
  ))
104
153
  return violations
@@ -109,21 +158,38 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
109
158
  """
110
159
  Check if any function exceeds maximum line count.
111
160
 
161
+ LX-10: Uses layer-based limits (Core/Shell/Tests/Default).
112
162
  DX-22: Always uses code_lines (excluding docstring) and excludes doctest lines.
113
- These behaviors were previously optional but are now the default.
114
163
 
115
164
  Examples:
116
165
  >>> from invar.core.models import FileInfo, Symbol, SymbolKind
117
166
  >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=10)
118
167
  >>> info = FileInfo(path="test.py", lines=20, symbols=[sym])
119
- >>> cfg = RuleConfig(max_function_lines=50)
120
- >>> check_function_size(info, cfg)
168
+ >>> check_function_size(info, RuleConfig())
169
+ []
170
+ >>> # Shell layer: 100 lines max (more lenient)
171
+ >>> sym2 = Symbol(name="cli", kind=SymbolKind.FUNCTION, line=1, end_line=80)
172
+ >>> info2 = FileInfo(path="shell/cli.py", lines=100, symbols=[sym2], is_shell=True)
173
+ >>> check_function_size(info2, RuleConfig())
121
174
  []
175
+ >>> # Core layer: 50 lines max (strict)
176
+ >>> sym3 = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=60)
177
+ >>> info3 = FileInfo(path="core/calc.py", lines=100, symbols=[sym3], is_core=True)
178
+ >>> len(check_function_size(info3, RuleConfig()))
179
+ 1
122
180
  """
123
181
  violations: list[Violation] = []
124
182
 
183
+ # LX-10: Get layer-based limits
184
+ layer = get_layer(file_info)
185
+ limits = get_limits(layer)
186
+ max_func_lines = limits.max_function_lines
187
+
125
188
  for symbol in file_info.symbols:
126
189
  if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
190
+ # LX-10: Check for escape hatch on individual functions
191
+ if has_allow_marker(symbol, file_info.source, "function_size"):
192
+ continue
127
193
  total_lines = symbol.end_line - symbol.line + 1
128
194
  # DX-22: Always use code_lines when available (excluding docstring)
129
195
  if symbol.code_lines is not None:
@@ -137,14 +203,14 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
137
203
  func_lines -= symbol.doctest_lines
138
204
  line_type = f"{line_type} (excl. doctest)"
139
205
 
140
- if func_lines > config.max_function_lines:
206
+ if func_lines > max_func_lines:
141
207
  violations.append(
142
208
  Violation(
143
209
  rule="function_size",
144
210
  severity=Severity.WARNING,
145
211
  file=file_info.path,
146
212
  line=symbol.line,
147
- message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {config.max_function_lines})",
213
+ message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {max_func_lines} for {layer.value})",
148
214
  suggestion="Extract helper functions",
149
215
  )
150
216
  )
@@ -21,6 +21,10 @@ if TYPE_CHECKING:
21
21
  # =============================================================================
22
22
 
23
23
 
24
+ # LX-05: Valid language values for template rendering
25
+ VALID_LANGUAGES = frozenset({"python", "typescript"})
26
+
27
+
24
28
  @dataclass
25
29
  class SyncConfig:
26
30
  """Configuration for template sync operation.
@@ -29,12 +33,16 @@ class SyncConfig:
29
33
  >>> config = SyncConfig()
30
34
  >>> config.syntax
31
35
  'cli'
36
+ >>> config.language
37
+ 'python'
32
38
  >>> config.inject_project_additions
33
39
  False
34
40
 
35
- >>> config = SyncConfig(syntax="mcp", force=True)
41
+ >>> config = SyncConfig(syntax="mcp", language="typescript", force=True)
36
42
  >>> config.syntax
37
43
  'mcp'
44
+ >>> config.language
45
+ 'typescript'
38
46
  >>> config.force
39
47
  True
40
48
 
@@ -44,12 +52,30 @@ class SyncConfig:
44
52
  """
45
53
 
46
54
  syntax: str = "cli" # "cli" or "mcp"
55
+ language: str = "python" # "python" or "typescript" (LX-05)
47
56
  inject_project_additions: bool = False
48
57
  force: bool = False
49
58
  check: bool = False # Preview only
50
59
  reset: bool = False # Discard user content
51
60
  skip_patterns: list[str] = field(default_factory=list) # Glob patterns to skip
52
61
 
62
+ @post(lambda result: result is None) # Void method, validates or raises
63
+ def __post_init__(self) -> None:
64
+ """Validate configuration values.
65
+
66
+ Examples:
67
+ >>> SyncConfig(language="python") # Valid
68
+ SyncConfig(syntax='cli', language='python', inject_project_additions=False, force=False, check=False, reset=False, skip_patterns=[])
69
+
70
+ >>> SyncConfig(language="rust") # doctest: +IGNORE_EXCEPTION_DETAIL
71
+ Traceback (most recent call last):
72
+ ValueError: Invalid language 'rust'. Must be one of: python, typescript
73
+ """
74
+ if self.language not in VALID_LANGUAGES:
75
+ valid = ", ".join(sorted(VALID_LANGUAGES))
76
+ msg = f"Invalid language '{self.language}'. Must be one of: {valid}"
77
+ raise ValueError(msg)
78
+
53
79
 
54
80
  @dataclass
55
81
  class SyncReport: