invar-tools 1.5.0__py3-none-any.whl → 1.7.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 CHANGED
@@ -8,7 +8,13 @@ This package provides development tools (guard, map, sig).
8
8
  For runtime contracts only, use invar-runtime instead.
9
9
  """
10
10
 
11
- __version__ = "1.0.0"
11
+ import importlib.metadata
12
+
13
+ try:
14
+ __version__ = importlib.metadata.version("invar-tools")
15
+ except importlib.metadata.PackageNotFoundError:
16
+ __version__ = "0.0.0.dev" # Development mode fallback
17
+
12
18
  __protocol_version__ = "5.0" # Protocol/spec version (separate from package version)
13
19
 
14
20
  # Re-export from invar-runtime for backwards compatibility
@@ -100,30 +100,30 @@ def count_escape_hatches(source: str) -> int:
100
100
  return len(extract_escape_hatches(source))
101
101
 
102
102
 
103
- @post(lambda result: all(len(t) == 2 for t in result)) # Returns (rule, reason) tuples
104
- def extract_escape_hatches(source: str) -> list[tuple[str, str]]:
103
+ @post(lambda result: all(len(t) == 3 for t in result)) # Returns (rule, reason, line) tuples
104
+ def extract_escape_hatches(source: str) -> list[tuple[str, str, int]]:
105
105
  """
106
- Extract @invar:allow markers with their reasons (DX-33 Option E).
106
+ Extract @invar:allow markers with their reasons and line numbers (DX-33, DX-66).
107
107
 
108
108
  Uses tokenize to only match real comments, not strings/docstrings.
109
- Returns list of (rule, reason) tuples for cross-file analysis.
109
+ Returns list of (rule, reason, line) tuples for cross-file analysis.
110
110
 
111
111
  Examples:
112
112
  >>> extract_escape_hatches("")
113
113
  []
114
114
  >>> extract_escape_hatches("# @invar:allow shell_result: API boundary")
115
- [('shell_result', 'API boundary')]
115
+ [('shell_result', 'API boundary', 1)]
116
116
  >>> source = '''
117
117
  ... # @invar:allow rule1: same reason
118
118
  ... # @invar:allow rule2: different reason
119
119
  ... '''
120
120
  >>> extract_escape_hatches(source)
121
- [('rule1', 'same reason'), ('rule2', 'different reason')]
121
+ [('rule1', 'same reason', 2), ('rule2', 'different reason', 3)]
122
122
  >>> # DX-33 Option C: Strings containing the pattern should NOT match
123
123
  >>> extract_escape_hatches('suggestion = "# @invar:allow rule: reason"')
124
124
  []
125
125
  """
126
- results: list[tuple[str, str]] = []
126
+ results: list[tuple[str, str, int]] = []
127
127
  try:
128
128
  # Use iterator-based readline to avoid io.StringIO (forbidden in Core)
129
129
  lines = iter(source.splitlines(keepends=True))
@@ -132,10 +132,12 @@ def extract_escape_hatches(source: str) -> list[tuple[str, str]]:
132
132
  if tok.type == tokenize.COMMENT:
133
133
  match = INVAR_ALLOW_PATTERN.search(tok.string)
134
134
  if match:
135
- results.append((match.group(1), match.group(2)))
135
+ # DX-66: tok.start[0] is the 1-based line number
136
+ results.append((match.group(1), match.group(2), tok.start[0]))
136
137
  except Exception:
137
- # Fall back to regex if tokenization fails (invalid syntax, non-printable chars, etc.)
138
- return INVAR_ALLOW_PATTERN.findall(source)
138
+ # Fall back to regex if tokenization fails - can't get line numbers
139
+ # Return line 0 to indicate unknown position
140
+ return [(r, reason, 0) for r, reason in INVAR_ALLOW_PATTERN.findall(source)]
139
141
  return results
140
142
 
141
143
 
invar/core/formatter.py CHANGED
@@ -256,6 +256,21 @@ def format_guard_agent(report: GuardReport, combined_status: str | None = None)
256
256
  if report.suggests > 0:
257
257
  result["static"]["suggests"] = report.suggests
258
258
  result["summary"]["suggests"] = report.suggests
259
+ # DX-66: Add escape hatch summary if any exist
260
+ if report.escape_hatches.count > 0:
261
+ result["escape_hatches"] = {
262
+ "count": report.escape_hatches.count,
263
+ "by_rule": report.escape_hatches.by_rule,
264
+ "details": [
265
+ {
266
+ "file": d.file,
267
+ "line": d.line,
268
+ "rule": d.rule,
269
+ "reason": d.reason,
270
+ }
271
+ for d in report.escape_hatches.details
272
+ ],
273
+ }
259
274
  return result
260
275
 
261
276
 
invar/core/models.py CHANGED
@@ -83,6 +83,89 @@ class Violation(BaseModel):
83
83
  suggestion: str | None = None
84
84
 
85
85
 
86
+ class EscapeHatchDetail(BaseModel):
87
+ """
88
+ Detail of a single escape hatch (@invar:allow) marker (DX-66).
89
+
90
+ Examples:
91
+ >>> d = EscapeHatchDetail(file="test.py", line=10, rule="shell_result", reason="API")
92
+ >>> d.line
93
+ 10
94
+ >>> # line=0 is valid (fallback when line number unknown)
95
+ >>> d0 = EscapeHatchDetail(file="test.py", line=0, rule="test", reason="fallback")
96
+ >>> d0.line
97
+ 0
98
+ """
99
+
100
+ file: str
101
+ line: int = Field(ge=0) # 0 = fallback when line number unknown
102
+ rule: str
103
+ reason: str
104
+
105
+
106
+ class EscapeHatchSummary(BaseModel):
107
+ """
108
+ Summary of escape hatches in the codebase (DX-66).
109
+
110
+ Provides visibility into @invar:allow usage for tracking technical debt.
111
+
112
+ Examples:
113
+ >>> summary = EscapeHatchSummary()
114
+ >>> summary.count
115
+ 0
116
+ >>> summary.by_rule
117
+ {}
118
+ >>> detail = EscapeHatchDetail(file="test.py", line=10, rule="shell_result", reason="API boundary")
119
+ >>> summary.add(detail)
120
+ >>> summary.count
121
+ 1
122
+ >>> summary.by_rule
123
+ {'shell_result': 1}
124
+ """
125
+
126
+ details: list[EscapeHatchDetail] = Field(default_factory=list)
127
+
128
+ @property
129
+ @post(lambda result: result >= 0)
130
+ def count(self) -> int:
131
+ """
132
+ Total number of escape hatches.
133
+
134
+ Examples:
135
+ >>> EscapeHatchSummary().count
136
+ 0
137
+ """
138
+ return len(self.details)
139
+
140
+ @property
141
+ @post(lambda result: all(v >= 0 for v in result.values()))
142
+ def by_rule(self) -> dict[str, int]:
143
+ """
144
+ Count of escape hatches grouped by rule.
145
+
146
+ Examples:
147
+ >>> EscapeHatchSummary().by_rule
148
+ {}
149
+ """
150
+ counts: dict[str, int] = {}
151
+ for detail in self.details:
152
+ counts[detail.rule] = counts.get(detail.rule, 0) + 1
153
+ return counts
154
+
155
+ @pre(lambda self, detail: bool(detail.rule) and bool(detail.file))
156
+ def add(self, detail: EscapeHatchDetail) -> None:
157
+ """
158
+ Add an escape hatch detail to the summary.
159
+
160
+ Examples:
161
+ >>> s = EscapeHatchSummary()
162
+ >>> s.add(EscapeHatchDetail(file="t.py", line=1, rule="r", reason="x"))
163
+ >>> s.count
164
+ 1
165
+ """
166
+ self.details.append(detail)
167
+
168
+
86
169
  class GuardReport(BaseModel):
87
170
  """Complete Guard report for a project."""
88
171
 
@@ -95,6 +178,8 @@ class GuardReport(BaseModel):
95
178
  # P24: Contract coverage statistics (Core files only)
96
179
  core_functions_total: int = 0
97
180
  core_functions_with_contracts: int = 0
181
+ # DX-66: Escape hatch visibility
182
+ escape_hatches: EscapeHatchSummary = Field(default_factory=EscapeHatchSummary)
98
183
 
99
184
  @pre(lambda self, violation: violation.rule and violation.severity) # Valid violation
100
185
  def add_violation(self, violation: Violation) -> None:
@@ -25,7 +25,7 @@ from invar import __version__
25
25
  from invar.core.models import GuardReport, RuleConfig
26
26
  from invar.core.rules import check_all_rules
27
27
  from invar.core.utils import get_exit_code
28
- from invar.shell.config import load_config
28
+ from invar.shell.config import find_project_root, load_config
29
29
  from invar.shell.fs import scan_project
30
30
  from invar.shell.guard_output import output_agent, output_rich
31
31
 
@@ -56,13 +56,13 @@ def _count_core_functions(file_info) -> tuple[int, int]:
56
56
  return (total, with_contracts)
57
57
 
58
58
 
59
- # @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
60
59
  # @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
61
60
  def _scan_and_check(
62
61
  path: Path, config: RuleConfig, only_files: set[Path] | None = None
63
62
  ) -> Result[GuardReport, str]:
64
63
  """Scan project files and check against rules."""
65
64
  from invar.core.entry_points import extract_escape_hatches
65
+ from invar.core.models import EscapeHatchDetail
66
66
  from invar.core.review_trigger import check_duplicate_escape_reasons
67
67
  from invar.core.shell_architecture import check_complexity_debt
68
68
 
@@ -80,10 +80,17 @@ def _scan_and_check(
80
80
  report.update_coverage(total, with_contracts)
81
81
  for violation in check_all_rules(file_info, config):
82
82
  report.add_violation(violation)
83
- # DX-33: Collect escape hatches for cross-file analysis
83
+ # DX-33 + DX-66: Collect escape hatches for cross-file analysis and visibility
84
84
  if file_info.source:
85
- for rule, reason in extract_escape_hatches(file_info.source):
85
+ for rule, reason, line in extract_escape_hatches(file_info.source):
86
86
  all_escapes.append((file_info.path, rule, reason))
87
+ # DX-66: Add to escape hatch summary
88
+ report.escape_hatches.add(EscapeHatchDetail(
89
+ file=file_info.path,
90
+ line=line,
91
+ rule=rule,
92
+ reason=reason,
93
+ ))
87
94
 
88
95
  # DX-22: Check project-level complexity debt (Fix-or-Explain enforcement)
89
96
  for debt_violation in check_complexity_debt(
@@ -102,7 +109,11 @@ def _scan_and_check(
102
109
  @app.command()
103
110
  def guard(
104
111
  path: Path = typer.Argument(
105
- Path(), help="Project root directory", exists=True, file_okay=False, dir_okay=True
112
+ Path(),
113
+ help="Project directory or single Python file",
114
+ exists=True,
115
+ file_okay=True,
116
+ dir_okay=True,
106
117
  ),
107
118
  strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
108
119
  changed: bool = typer.Option(
@@ -157,6 +168,16 @@ def guard(
157
168
  )
158
169
  from invar.shell.testing import VerificationLevel
159
170
 
171
+ # DX-65: Handle single file mode
172
+ single_file_mode = path.is_file()
173
+ single_file: Path | None = None
174
+ if single_file_mode:
175
+ if path.suffix != ".py":
176
+ console.print(f"[red]Error:[/red] {path} is not a Python file")
177
+ raise typer.Exit(1)
178
+ single_file = path.resolve()
179
+ path = find_project_root(path)
180
+
160
181
  # Load and configure
161
182
  config_result = load_config(path)
162
183
  if isinstance(config_result, Failure):
@@ -179,7 +200,9 @@ def guard(
179
200
  format_contract_coverage_report,
180
201
  )
181
202
 
182
- coverage_result = calculate_contract_coverage(path, changed_only=changed)
203
+ # DX-65: Use single file path if in single file mode
204
+ coverage_path = single_file if single_file else path
205
+ coverage_result = calculate_contract_coverage(coverage_path, changed_only=changed)
183
206
  if isinstance(coverage_result, Failure):
184
207
  console.print(f"[red]Error:[/red] {coverage_result.failure()}")
185
208
  raise typer.Exit(1)
@@ -194,10 +217,14 @@ def guard(
194
217
 
195
218
  raise typer.Exit(0 if report_data.ready_for_build else 1)
196
219
 
197
- # Handle --changed mode
220
+ # Handle --changed mode or single file mode (DX-65)
198
221
  only_files: set[Path] | None = None
199
222
  checked_files: list[Path] = []
200
- if changed:
223
+ if single_file:
224
+ # DX-65: Single file mode - only check the specified file
225
+ only_files = {single_file}
226
+ checked_files = [single_file]
227
+ elif changed:
201
228
  changed_result = handle_changed_mode(path)
202
229
  if isinstance(changed_result, Failure):
203
230
  if changed_result.failure() == "NO_CHANGES":
@@ -379,7 +406,7 @@ def _show_verification_level(verification_level) -> None:
379
406
  @app.command()
380
407
  def version() -> None:
381
408
  """Show Invar version."""
382
- console.print(f"invar {__version__}")
409
+ console.print(f"invar-tools {__version__}")
383
410
 
384
411
 
385
412
  @app.command("map")
@@ -494,9 +521,11 @@ from invar.shell.commands.init import init
494
521
  from invar.shell.commands.mutate import mutate # DX-28
495
522
  from invar.shell.commands.sync_self import sync_self # DX-49
496
523
  from invar.shell.commands.test import test, verify
524
+ from invar.shell.commands.uninstall import uninstall # DX-69
497
525
  from invar.shell.commands.update import update
498
526
 
499
527
  app.command()(init)
528
+ app.command()(uninstall) # DX-69: Remove Invar from project
500
529
  app.command()(update)
501
530
  app.command()(test)
502
531
  app.command()(verify)
@@ -38,8 +38,6 @@ from invar.shell.mcp_config import (
38
38
  from invar.shell.template_engine import generate_from_manifest
39
39
  from invar.shell.templates import (
40
40
  add_config,
41
- add_invar_reference,
42
- create_agent_config,
43
41
  create_directories,
44
42
  detect_agent_configs,
45
43
  install_hooks,
@@ -65,18 +63,17 @@ def run_claude_init(path: Path) -> bool:
65
63
 
66
64
  console.print("\n[bold]Running claude /init...[/bold]")
67
65
  try:
66
+ # Don't capture output - claude /init is interactive and needs user input
68
67
  result = subprocess.run(
69
68
  ["claude", "/init"],
70
69
  cwd=path,
71
- capture_output=True,
72
- text=True,
73
70
  timeout=120,
74
71
  )
75
72
  if result.returncode == 0:
76
73
  console.print("[green]claude /init completed successfully[/green]")
77
74
  return True
78
75
  else:
79
- console.print(f"[yellow]Warning:[/yellow] claude /init failed: {result.stderr}")
76
+ console.print("[yellow]Warning:[/yellow] claude /init failed")
80
77
  return False
81
78
  except subprocess.TimeoutExpired:
82
79
  console.print("[yellow]Warning:[/yellow] claude /init timed out")
@@ -86,57 +83,6 @@ def run_claude_init(path: Path) -> bool:
86
83
  return False
87
84
 
88
85
 
89
- def append_invar_reference_to_claude_md(path: Path) -> bool:
90
- """
91
- Append Invar reference to existing CLAUDE.md.
92
-
93
- Preserves content generated by 'claude /init'.
94
- Returns True if modified, False otherwise.
95
- """
96
- claude_md = path / "CLAUDE.md"
97
- if not claude_md.exists():
98
- return False
99
-
100
- content = claude_md.read_text()
101
- if "INVAR.md" in content:
102
- console.print("[dim]CLAUDE.md already references INVAR.md[/dim]")
103
- return False
104
-
105
- # Append reference at the end
106
- invar_reference = """
107
-
108
- ---
109
-
110
- ## Invar Protocol
111
-
112
- > **Protocol:** Follow [INVAR.md](./INVAR.md) — includes Check-In, USBV workflow, and Task Completion.
113
-
114
- ### Check-In
115
-
116
- Your first message MUST display:
117
-
118
- ```
119
- ✓ Check-In: [project] | [branch] | [clean/dirty]
120
- ```
121
-
122
- Read `.invar/context.md` first. Do NOT run guard/map at Check-In.
123
-
124
- ### Final
125
-
126
- Your last message MUST display:
127
-
128
- ```
129
- ✓ Final: guard PASS | 0 errors, 2 warnings
130
- ```
131
-
132
- Execute `invar guard` and show this one-line summary.
133
- """
134
-
135
- claude_md.write_text(content + invar_reference)
136
- console.print("[green]Updated[/green] CLAUDE.md (added Invar reference)")
137
- return True
138
-
139
-
140
86
  # @shell_complexity: MCP config with method selection and validation
141
87
  def configure_mcp_with_method(
142
88
  path: Path, mcp_method: str | None
@@ -312,11 +258,9 @@ def init(
312
258
  return
313
259
 
314
260
  # DX-21B: Run claude /init if requested (before sync)
261
+ # DX-69: sync_templates() will merge claude's CLAUDE.md with invar template
315
262
  if claude:
316
- claude_success = run_claude_init(path)
317
- if claude_success:
318
- # Append Invar reference to generated CLAUDE.md
319
- append_invar_reference_to_claude_md(path)
263
+ run_claude_init(path)
320
264
 
321
265
  config_result = add_config(path, console)
322
266
  if isinstance(config_result, Failure):
@@ -371,28 +315,13 @@ def init(
371
315
  if isinstance(result, Success) and result.unwrap():
372
316
  console.print("[green]Created[/green] .invar/proposals/TEMPLATE.md")
373
317
 
374
- # Agent detection and configuration (DX-11)
318
+ # Agent detection (DX-69: simplified, only Claude Code supported)
375
319
  console.print("\n[bold]Checking for agent configurations...[/bold]")
376
320
  agent_result = detect_agent_configs(path)
377
- if isinstance(agent_result, Failure):
378
- console.print(f"[yellow]Warning:[/yellow] {agent_result.failure()}")
379
- agent_status: dict[str, str] = {}
380
- else:
321
+ if isinstance(agent_result, Success):
381
322
  agent_status = agent_result.unwrap()
382
-
383
- # Handle agent configs (DX-11, DX-17)
384
- for agent, status in agent_status.items():
385
- if status == "configured":
386
- console.print(f" [green]✓[/green] {agent}: already configured")
387
- elif status == "found":
388
- # Existing file without Invar reference - ask before modifying
389
- if yes or typer.confirm(f" Add Invar reference to {agent} config?", default=True):
390
- add_invar_reference(path, agent, console)
391
- else:
392
- console.print(f" [yellow]○[/yellow] {agent}: skipped")
393
- elif status == "not_found":
394
- # Create full template with workflow enforcement (DX-17)
395
- create_agent_config(path, agent, console)
323
+ if agent_status.get("claude") == "configured":
324
+ console.print(" [green]✓[/green] claude: already configured")
396
325
 
397
326
  # Configure MCP server (DX-16, DX-21B)
398
327
  configure_mcp_with_method(path, mcp_method)