invar-tools 1.3.3__py3-none-any.whl → 1.5.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.
@@ -0,0 +1,358 @@
1
+ """Contract coverage analysis for DX-63 - Shell layer.
2
+
3
+ I/O operations for checking contract coverage, detecting batch creation,
4
+ and formatting reports. Different from coverage.py which handles branch coverage.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path # noqa: TC003 - used at runtime
12
+
13
+ from returns.result import Failure, Result, Success
14
+
15
+ from invar.core.trivial_detection import (
16
+ TrivialContract,
17
+ analyze_contracts_in_source,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class BatchWarning:
23
+ """Warning for batch file creation without contracts.
24
+
25
+ Examples:
26
+ >>> bw = BatchWarning(
27
+ ... file_count=5,
28
+ ... files=[("src/a.py", 3, 0), ("src/b.py", 4, 1)],
29
+ ... message="Multiple new files with low coverage"
30
+ ... )
31
+ >>> bw.file_count
32
+ 5
33
+ """
34
+
35
+ file_count: int
36
+ files: list[tuple[str, int, int]] # (file, total_funcs, with_contracts)
37
+ message: str
38
+
39
+
40
+ @dataclass
41
+ class ContractCoverageReport:
42
+ """Contract coverage check result.
43
+
44
+ Examples:
45
+ >>> report = ContractCoverageReport(
46
+ ... files_checked=3,
47
+ ... total_functions=10,
48
+ ... functions_with_contracts=8,
49
+ ... trivial_contracts=[],
50
+ ... batch_warning=None
51
+ ... )
52
+ >>> report.coverage_pct
53
+ 80
54
+ >>> report.ready_for_build
55
+ True
56
+ """
57
+
58
+ files_checked: int = 0
59
+ total_functions: int = 0
60
+ functions_with_contracts: int = 0
61
+ trivial_contracts: list[TrivialContract] = field(default_factory=list)
62
+ batch_warning: BatchWarning | None = None
63
+
64
+ @property
65
+ def coverage_pct(self) -> int:
66
+ """Get coverage percentage.
67
+
68
+ Examples:
69
+ >>> ContractCoverageReport(total_functions=10, functions_with_contracts=7).coverage_pct
70
+ 70
71
+ >>> ContractCoverageReport(total_functions=0, functions_with_contracts=0).coverage_pct
72
+ 100
73
+ """
74
+ if self.total_functions == 0:
75
+ return 100
76
+ return int(100 * self.functions_with_contracts / self.total_functions)
77
+
78
+ @property
79
+ def trivial_count(self) -> int:
80
+ """Count of trivial contracts."""
81
+ return len(self.trivial_contracts)
82
+
83
+ @property
84
+ def ready_for_build(self) -> bool:
85
+ """Check if ready for BUILD phase.
86
+
87
+ Ready when:
88
+ - Coverage >= 80%
89
+ - No trivial contracts
90
+ - No batch warning
91
+
92
+ Examples:
93
+ >>> ContractCoverageReport(total_functions=10, functions_with_contracts=10).ready_for_build
94
+ True
95
+ >>> ContractCoverageReport(total_functions=10, functions_with_contracts=5).ready_for_build
96
+ False
97
+ """
98
+ if self.trivial_contracts:
99
+ return False
100
+ if self.batch_warning:
101
+ return False
102
+ return self.coverage_pct >= 80
103
+
104
+
105
+ def count_contracts_in_file(
106
+ file_path: Path,
107
+ ) -> Result[tuple[int, int, list[TrivialContract]], str]:
108
+ """Count functions and contracts in a file.
109
+
110
+ Returns: Result containing (total_functions, functions_with_contracts, trivial_contracts)
111
+ """
112
+ if not file_path.exists():
113
+ return Failure(f"File not found: {file_path}")
114
+
115
+ try:
116
+ source = file_path.read_text(encoding="utf-8")
117
+ except UnicodeDecodeError as e:
118
+ return Failure(f"Encoding error: {e}")
119
+
120
+ result = analyze_contracts_in_source(source, str(file_path))
121
+ return Success(result)
122
+
123
+
124
+ def get_changed_python_files(path: Path) -> Result[list[Path], str]:
125
+ """Get Python files changed in git."""
126
+ try:
127
+ result = subprocess.run(
128
+ ["git", "status", "--porcelain"],
129
+ capture_output=True,
130
+ text=True,
131
+ cwd=path,
132
+ check=False,
133
+ )
134
+ if result.returncode != 0:
135
+ return Failure(f"Git error: {result.stderr}")
136
+
137
+ files = []
138
+ for line in result.stdout.strip().split("\n"):
139
+ if not line:
140
+ continue
141
+ # Status is first 2 chars, then space, then filename
142
+ status = line[:2]
143
+ filename = line[3:].strip()
144
+
145
+ # Include new, modified, or untracked Python files
146
+ if filename.endswith(".py") and status.strip():
147
+ file_path = path / filename
148
+ if file_path.exists():
149
+ files.append(file_path)
150
+
151
+ return Success(files)
152
+ except FileNotFoundError:
153
+ return Failure("Git not found")
154
+
155
+
156
+ def calculate_contract_coverage(
157
+ path: Path, changed_only: bool = False
158
+ ) -> Result[ContractCoverageReport, str]:
159
+ """Calculate contract coverage for a path (file or directory)."""
160
+ report = ContractCoverageReport()
161
+
162
+ if path.is_file():
163
+ if path.suffix == ".py":
164
+ result = count_contracts_in_file(path)
165
+ if isinstance(result, Failure):
166
+ return result
167
+ total, with_contracts, trivials = result.unwrap()
168
+ report.files_checked = 1
169
+ report.total_functions = total
170
+ report.functions_with_contracts = with_contracts
171
+ report.trivial_contracts = trivials
172
+ else:
173
+ # Get files to check
174
+ if changed_only:
175
+ files_result = get_changed_python_files(path)
176
+ if isinstance(files_result, Failure):
177
+ return files_result
178
+ files = files_result.unwrap()
179
+ else:
180
+ files = list(path.rglob("*.py"))
181
+
182
+ # Filter out test files, __pycache__, etc.
183
+ files = [
184
+ f
185
+ for f in files
186
+ if "__pycache__" not in str(f)
187
+ and "test_" not in f.name
188
+ and "_test.py" not in f.name
189
+ and ".venv" not in str(f)
190
+ ]
191
+
192
+ for file_path in files:
193
+ result = count_contracts_in_file(file_path)
194
+ if isinstance(result, Failure):
195
+ continue # Skip files with errors
196
+ total, with_contracts, trivials = result.unwrap()
197
+ report.files_checked += 1
198
+ report.total_functions += total
199
+ report.functions_with_contracts += with_contracts
200
+ report.trivial_contracts.extend(trivials)
201
+
202
+ # Check for batch creation
203
+ batch_result = detect_batch_creation(path)
204
+ if isinstance(batch_result, Success):
205
+ report.batch_warning = batch_result.unwrap()
206
+
207
+ return Success(report)
208
+
209
+
210
+ def detect_batch_creation(
211
+ path: Path, threshold: int = 3
212
+ ) -> Result[BatchWarning | None, str]:
213
+ """Detect batch file creation without contracts.
214
+
215
+ Returns BatchWarning if >= threshold new/untracked files have < 50% coverage.
216
+ """
217
+ try:
218
+ result = subprocess.run(
219
+ ["git", "status", "--porcelain"],
220
+ capture_output=True,
221
+ text=True,
222
+ cwd=path,
223
+ check=False,
224
+ )
225
+ if result.returncode != 0:
226
+ return Success(None)
227
+
228
+ uncovered_files: list[tuple[str, int, int]] = []
229
+
230
+ for line in result.stdout.strip().split("\n"):
231
+ if not line:
232
+ continue
233
+
234
+ status = line[:2]
235
+ filename = line[3:].strip()
236
+
237
+ # Check new/untracked Python files (status starts with ? or A)
238
+ if filename.endswith(".py") and status[0] in ("?", "A"):
239
+ file_path = path / filename
240
+ if file_path.exists():
241
+ count_result = count_contracts_in_file(file_path)
242
+ if isinstance(count_result, Success):
243
+ total, with_contracts, _ = count_result.unwrap()
244
+ if total > 0:
245
+ coverage_pct = int(100 * with_contracts / total)
246
+ if coverage_pct < 50:
247
+ uncovered_files.append(
248
+ (filename, total, with_contracts)
249
+ )
250
+
251
+ if len(uncovered_files) >= threshold:
252
+ return Success(
253
+ BatchWarning(
254
+ file_count=len(uncovered_files),
255
+ files=uncovered_files,
256
+ message="Multiple new files with low contract coverage",
257
+ )
258
+ )
259
+
260
+ return Success(None)
261
+
262
+ except FileNotFoundError:
263
+ return Success(None)
264
+
265
+
266
+ # @shell_orchestration: Report formatting tightly coupled with CLI output
267
+ def format_contract_coverage_report(report: ContractCoverageReport) -> str:
268
+ """Format coverage report for human-readable output."""
269
+ lines = [
270
+ "Contract Coverage Check",
271
+ "=" * 40,
272
+ f"Files: {report.files_checked} | Functions: {report.total_functions}",
273
+ "",
274
+ ]
275
+
276
+ # Coverage line
277
+ coverage_pct = report.coverage_pct
278
+ if coverage_pct >= 80:
279
+ lines.append(
280
+ f"Coverage: {report.functions_with_contracts}/{report.total_functions} "
281
+ f"({coverage_pct}%) \u2713"
282
+ )
283
+ else:
284
+ lines.append(
285
+ f"Coverage: {report.functions_with_contracts}/{report.total_functions} "
286
+ f"({coverage_pct}%) \u2717"
287
+ )
288
+
289
+ # Trivial contracts
290
+ total = max(1, report.total_functions)
291
+ if report.trivial_contracts:
292
+ trivial_pct = int(100 * report.trivial_count / total)
293
+ lines.append(f"Trivial: {report.trivial_count}/{report.total_functions} ({trivial_pct}%) \u2717")
294
+ lines.append("")
295
+ lines.append("\u2717 Trivial contracts detected:")
296
+ for tc in report.trivial_contracts:
297
+ lines.append(
298
+ f" - {tc.file}:{tc.line} {tc.function_name} "
299
+ f"@{tc.contract_type}({tc.expression})"
300
+ )
301
+ else:
302
+ lines.append(f"Trivial: 0/{report.total_functions} (0%) \u2713")
303
+
304
+ # Batch warning
305
+ if report.batch_warning:
306
+ lines.append("")
307
+ lines.append(
308
+ f"\u26a0 BATCH WARNING: {report.batch_warning.file_count} "
309
+ "new files with low coverage"
310
+ )
311
+ for filename, total_f, with_c in report.batch_warning.files:
312
+ lines.append(f" - {filename} ({with_c}/{total_f})")
313
+ lines.append("")
314
+ lines.append("Recommendation: Add contracts incrementally, one file at a time.")
315
+
316
+ # Final status
317
+ lines.append("")
318
+ if report.ready_for_build:
319
+ lines.append("Ready for BUILD phase.")
320
+ else:
321
+ lines.append("Not ready for BUILD phase.")
322
+
323
+ return "\n".join(lines)
324
+
325
+
326
+ # @shell_orchestration: Output formatting for CLI integration
327
+ def format_contract_coverage_agent(report: ContractCoverageReport) -> dict:
328
+ """Format coverage report for agent JSON output."""
329
+ return {
330
+ "status": "passed" if report.ready_for_build else "failed",
331
+ "contract_coverage": {
332
+ "files_checked": report.files_checked,
333
+ "total_functions": report.total_functions,
334
+ "functions_with_contracts": report.functions_with_contracts,
335
+ "coverage_pct": report.coverage_pct,
336
+ "trivial_count": report.trivial_count,
337
+ "trivial_contracts": [
338
+ {
339
+ "file": tc.file,
340
+ "line": tc.line,
341
+ "function": tc.function_name,
342
+ "type": tc.contract_type,
343
+ "expression": tc.expression,
344
+ }
345
+ for tc in report.trivial_contracts
346
+ ],
347
+ "batch_warning": (
348
+ {
349
+ "file_count": report.batch_warning.file_count,
350
+ "files": report.batch_warning.files,
351
+ "message": report.batch_warning.message,
352
+ }
353
+ if report.batch_warning
354
+ else None
355
+ ),
356
+ "ready_for_build": report.ready_for_build,
357
+ },
358
+ }
@@ -151,6 +151,8 @@ def output_rich(
151
151
  icon = "[red]ERROR[/red]"
152
152
  elif v.severity == Severity.WARNING:
153
153
  icon = "[yellow]WARN[/yellow]"
154
+ elif v.severity == Severity.SUGGEST:
155
+ icon = "[magenta]SUGGEST[/magenta]" # DX-61
154
156
  else:
155
157
  icon = "[blue]INFO[/blue]"
156
158
  ln = f":{v.line}" if v.line else ""
@@ -182,6 +184,9 @@ def output_rich(
182
184
  )
183
185
  if report.infos > 0:
184
186
  summary += f"\nInfos: {report.infos}"
187
+ # DX-61: Show suggestions count if any
188
+ if report.suggests > 0:
189
+ summary += f"\n[magenta]Suggestions: {report.suggests}[/magenta]"
185
190
  console.print(summary)
186
191
 
187
192
  # P24: Contract coverage statistics (only show if core files exist)
@@ -0,0 +1,234 @@
1
+ """
2
+ Pattern Detection Integration for Guard (DX-61).
3
+
4
+ Shell module: handles file I/O for pattern detection.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path # noqa: TC003 - used at runtime (path.exists, rglob, etc)
10
+
11
+ from deal import post, pre
12
+ from returns.result import Failure, Result, Success
13
+
14
+ from invar.core.models import RuleConfig, Severity, Violation
15
+ from invar.core.patterns import PatternSuggestion, detect_patterns
16
+ from invar.core.patterns.types import Confidence, Priority
17
+
18
+
19
+ # @shell_complexity: File collection and filtering requires multiple conditions
20
+ @pre(lambda path, files: path.exists())
21
+ @post(lambda result: isinstance(result, (Success, Failure)))
22
+ def run_pattern_detection(
23
+ path: Path,
24
+ files: list[Path] | None = None,
25
+ ) -> Result[list[PatternSuggestion], str]:
26
+ """
27
+ Run pattern detection on source files.
28
+
29
+ DX-61: Detects opportunities for functional patterns like
30
+ NewType, Validation, NonEmpty, Literal, and ExhaustiveMatch.
31
+
32
+ Args:
33
+ path: Project root directory
34
+ files: Optional list of specific files to check (None = all Python files)
35
+
36
+ Returns:
37
+ Success with list of suggestions, or Failure with error message
38
+
39
+ @pre: path exists
40
+ @post: returns Result type
41
+ """
42
+ suggestions: list[PatternSuggestion] = []
43
+
44
+ try:
45
+ # Collect Python files
46
+ if files:
47
+ python_files = [f for f in files if f.suffix == ".py"]
48
+ else:
49
+ python_files = list(path.rglob("*.py"))
50
+
51
+ # Filter out test files and hidden directories
52
+ python_files = [
53
+ f for f in python_files
54
+ if not any(part.startswith(".") for part in f.parts)
55
+ and "test" not in f.name.lower()
56
+ and "__pycache__" not in str(f)
57
+ ]
58
+
59
+ for file_path in python_files:
60
+ try:
61
+ source = file_path.read_text(encoding="utf-8")
62
+ result = detect_patterns(str(file_path), source)
63
+ suggestions.extend(result.suggestions)
64
+ except (OSError, UnicodeDecodeError):
65
+ # Skip files that can't be read
66
+ continue
67
+
68
+ return Success(suggestions)
69
+
70
+ except Exception as e:
71
+ return Failure(f"Pattern detection failed: {e}")
72
+
73
+
74
+ # @shell_orchestration: Converts pattern suggestions to violations for Guard output integration
75
+ @pre(lambda suggestion: suggestion is not None)
76
+ @post(lambda result: result.severity == Severity.SUGGEST)
77
+ def suggestion_to_violation(suggestion: PatternSuggestion) -> Violation:
78
+ """
79
+ Convert pattern suggestion to Guard violation format.
80
+
81
+ DX-61: Enables pattern suggestions to appear in Guard output
82
+ alongside other violations.
83
+
84
+ Args:
85
+ suggestion: Pattern suggestion from detector
86
+
87
+ Returns:
88
+ Violation with SUGGEST severity
89
+
90
+ @pre: suggestion is not None
91
+ @post: result has SUGGEST severity
92
+
93
+ >>> from invar.core.patterns.types import (
94
+ ... PatternSuggestion, PatternID, Confidence, Priority, Location
95
+ ... )
96
+ >>> suggestion = PatternSuggestion(
97
+ ... pattern_id=PatternID.NEWTYPE,
98
+ ... location=Location(file="test.py", line=10),
99
+ ... message="3 str params",
100
+ ... confidence=Confidence.HIGH,
101
+ ... priority=Priority.P0,
102
+ ... current_code="def f(a, b, c): pass",
103
+ ... suggested_pattern="NewType",
104
+ ... reference_file=".invar/examples/functional.py",
105
+ ... reference_pattern="Pattern 1",
106
+ ... )
107
+ >>> violation = suggestion_to_violation(suggestion)
108
+ >>> violation.severity == Severity.SUGGEST
109
+ True
110
+ >>> "Pattern 1" in violation.suggestion
111
+ True
112
+ """
113
+ # Build suggestion text with reference
114
+ suggestion_text = (
115
+ f"{suggestion.suggested_pattern}\n"
116
+ f"See: {suggestion.reference_file} - {suggestion.reference_pattern}"
117
+ )
118
+
119
+ return Violation(
120
+ file=suggestion.location.file,
121
+ line=suggestion.location.line,
122
+ rule=f"pattern_{suggestion.pattern_id.value}",
123
+ severity=Severity.SUGGEST,
124
+ message=f"[{suggestion.confidence.value.upper()}] {suggestion.message}",
125
+ suggestion=suggestion_text,
126
+ )
127
+
128
+
129
+ # @shell_orchestration: Batch converts pattern suggestions to violations for Guard report
130
+ @pre(lambda suggestions: suggestions is not None)
131
+ @post(lambda result: all(v.severity == Severity.SUGGEST for v in result))
132
+ def suggestions_to_violations(
133
+ suggestions: list[PatternSuggestion],
134
+ ) -> list[Violation]:
135
+ """
136
+ Convert all pattern suggestions to violations.
137
+
138
+ Args:
139
+ suggestions: List of pattern suggestions
140
+
141
+ Returns:
142
+ List of violations with SUGGEST severity
143
+
144
+ @pre: suggestions is not None
145
+ @post: all results have SUGGEST severity
146
+ """
147
+ return [suggestion_to_violation(s) for s in suggestions]
148
+
149
+
150
+ # DX-61: Confidence level ordering for filtering
151
+ _CONFIDENCE_ORDER = {
152
+ Confidence.LOW: 0,
153
+ Confidence.MEDIUM: 1,
154
+ Confidence.HIGH: 2,
155
+ }
156
+
157
+
158
+ # @shell_orchestration: Filters suggestions based on RuleConfig settings
159
+ # @shell_complexity: Multiple config filters (confidence, priority, exclusion) require branching
160
+ @pre(lambda suggestions, config: suggestions is not None and config is not None)
161
+ @post(lambda result: isinstance(result, list))
162
+ def filter_suggestions(
163
+ suggestions: list[PatternSuggestion],
164
+ config: RuleConfig,
165
+ ) -> list[PatternSuggestion]:
166
+ """
167
+ Filter suggestions based on configuration.
168
+
169
+ DX-61: Applies confidence, priority, and exclusion filters.
170
+
171
+ Args:
172
+ suggestions: List of pattern suggestions
173
+ config: Rule configuration with pattern settings
174
+
175
+ Returns:
176
+ Filtered list of suggestions
177
+
178
+ @pre: suggestions and config are not None
179
+ @post: returns a list
180
+
181
+ >>> from invar.core.patterns.types import (
182
+ ... PatternSuggestion, PatternID, Confidence, Priority, Location
183
+ ... )
184
+ >>> from invar.core.models import RuleConfig
185
+ >>> config = RuleConfig(pattern_min_confidence="high")
186
+ >>> low = PatternSuggestion(
187
+ ... pattern_id=PatternID.NEWTYPE,
188
+ ... location=Location(file="t.py", line=1),
189
+ ... message="msg", confidence=Confidence.LOW, priority=Priority.P0,
190
+ ... current_code="x", suggested_pattern="X", reference_file="f.py",
191
+ ... reference_pattern="P1"
192
+ ... )
193
+ >>> high = PatternSuggestion(
194
+ ... pattern_id=PatternID.VALIDATION,
195
+ ... location=Location(file="t.py", line=2),
196
+ ... message="msg", confidence=Confidence.HIGH, priority=Priority.P0,
197
+ ... current_code="x", suggested_pattern="X", reference_file="f.py",
198
+ ... reference_pattern="P1"
199
+ ... )
200
+ >>> result = filter_suggestions([low, high], config)
201
+ >>> len(result)
202
+ 1
203
+ >>> result[0].confidence == Confidence.HIGH
204
+ True
205
+ """
206
+ # Parse minimum confidence level
207
+ min_conf_str = config.pattern_min_confidence.lower()
208
+ min_conf_map = {"low": Confidence.LOW, "medium": Confidence.MEDIUM, "high": Confidence.HIGH}
209
+ min_confidence = min_conf_map.get(min_conf_str, Confidence.MEDIUM)
210
+ min_conf_order = _CONFIDENCE_ORDER[min_confidence]
211
+
212
+ # Parse allowed priorities (only P0 and P1 exist, skip invalid values)
213
+ priority_map = {"P0": Priority.P0, "P1": Priority.P1}
214
+ allowed_priorities = {
215
+ priority_map[p] for p in config.pattern_priorities if p in priority_map
216
+ }
217
+
218
+ # Parse excluded patterns
219
+ excluded_patterns = set(config.pattern_exclude)
220
+
221
+ filtered = []
222
+ for s in suggestions:
223
+ # Check confidence threshold
224
+ if _CONFIDENCE_ORDER[s.confidence] < min_conf_order:
225
+ continue
226
+ # Check priority filter
227
+ if s.priority not in allowed_priorities:
228
+ continue
229
+ # Check exclusion list
230
+ if s.pattern_id.value in excluded_patterns:
231
+ continue
232
+ filtered.append(s)
233
+
234
+ return filtered
invar/shell/templates.py CHANGED
@@ -434,7 +434,7 @@ Find your Python path: `python -c "import sys; print(sys.executable)"`
434
434
 
435
435
  ```bash
436
436
  # Recommended: use uvx (no installation needed)
437
- uvx --from invar-tools invar guard
437
+ uvx invar-tools guard
438
438
 
439
439
  # Or install globally
440
440
  pip install invar-tools
@@ -449,7 +449,7 @@ Run the MCP server directly:
449
449
 
450
450
  ```bash
451
451
  # Using uvx
452
- uvx --from invar-tools invar mcp
452
+ uvx invar-tools mcp
453
453
 
454
454
  # Or if installed
455
455
  invar mcp
invar/shell/testing.py CHANGED
@@ -136,13 +136,24 @@ def run_doctests_on_files(
136
136
  return Success({"status": "skipped", "reason": "no files", "files": []})
137
137
 
138
138
  # Filter to Python files only
139
- # Exclude: conftest.py (pytest config), templates/examples/ (source templates, not user examples)
139
+ # Exclude: conftest.py (pytest config), templates/examples/ (source templates),
140
+ # .invar/examples/ (documentation examples with intentionally "bad" patterns)
141
+ def is_excluded(f: Path) -> bool:
142
+ """Check if path matches excluded patterns using path parts (not substring)."""
143
+ parts = f.parts
144
+ # Check for consecutive "templates/examples" or ".invar/examples"
145
+ for i in range(len(parts) - 1):
146
+ if (parts[i] == "templates" and parts[i + 1] == "examples") or \
147
+ (parts[i] == ".invar" and parts[i + 1] == "examples"):
148
+ return True
149
+ return False
150
+
140
151
  py_files = [
141
152
  f for f in files
142
153
  if f.suffix == ".py"
143
154
  and f.exists()
144
155
  and f.name != "conftest.py"
145
- and "templates/examples" not in str(f)
156
+ and not is_excluded(f)
146
157
  ]
147
158
  if not py_files:
148
159
  return Success({"status": "skipped", "reason": "no Python files", "files": []})
@@ -86,6 +86,7 @@ src/{project}/
86
86
  | INVAR.md | Invar | No | Protocol (`invar update` to sync) |
87
87
  | CLAUDE.md | User | Yes | Project customization (this file) |
88
88
  | .invar/context.md | User | Yes | Project state, lessons learned |
89
+ | .invar/project-additions.md | User | Yes | Project rules → injected into CLAUDE.md |
89
90
  | .invar/examples/ | Invar | No | **Must read:** Core/Shell patterns, workflow |
90
91
 
91
92
  ## Visible Workflow (DX-30)