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
|
@@ -27,7 +27,7 @@ from invar.core.rules import check_all_rules
|
|
|
27
27
|
from invar.core.utils import get_exit_code
|
|
28
28
|
from invar.shell.config import load_config
|
|
29
29
|
from invar.shell.fs import scan_project
|
|
30
|
-
from invar.shell.guard_output import output_agent,
|
|
30
|
+
from invar.shell.guard_output import output_agent, output_rich
|
|
31
31
|
|
|
32
32
|
app = typer.Typer(
|
|
33
33
|
name="invar",
|
|
@@ -37,6 +37,8 @@ app = typer.Typer(
|
|
|
37
37
|
console = Console()
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
# @shell_orchestration: Statistics helper for CLI guard output
|
|
41
|
+
# @shell_complexity: Iterates symbols checking kind and contracts (4 branches minimal)
|
|
40
42
|
def _count_core_functions(file_info) -> tuple[int, int]:
|
|
41
43
|
"""Count functions and functions with contracts in a Core file (P24)."""
|
|
42
44
|
from invar.core.models import SymbolKind
|
|
@@ -54,11 +56,19 @@ def _count_core_functions(file_info) -> tuple[int, int]:
|
|
|
54
56
|
return (total, with_contracts)
|
|
55
57
|
|
|
56
58
|
|
|
59
|
+
# @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
|
|
60
|
+
# @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
|
|
57
61
|
def _scan_and_check(
|
|
58
62
|
path: Path, config: RuleConfig, only_files: set[Path] | None = None
|
|
59
63
|
) -> Result[GuardReport, str]:
|
|
60
64
|
"""Scan project files and check against rules."""
|
|
65
|
+
from invar.core.entry_points import extract_escape_hatches
|
|
66
|
+
from invar.core.review_trigger import check_duplicate_escape_reasons
|
|
67
|
+
from invar.core.shell_architecture import check_complexity_debt
|
|
68
|
+
|
|
61
69
|
report = GuardReport(files_checked=0)
|
|
70
|
+
all_escapes: list[tuple[str, str, str]] = [] # DX-33: (file, rule, reason)
|
|
71
|
+
|
|
62
72
|
for file_result in scan_project(path, only_files):
|
|
63
73
|
if isinstance(file_result, Failure):
|
|
64
74
|
console.print(f"[yellow]Warning:[/yellow] {file_result.failure()}")
|
|
@@ -70,43 +80,64 @@ def _scan_and_check(
|
|
|
70
80
|
report.update_coverage(total, with_contracts)
|
|
71
81
|
for violation in check_all_rules(file_info, config):
|
|
72
82
|
report.add_violation(violation)
|
|
83
|
+
# DX-33: Collect escape hatches for cross-file analysis
|
|
84
|
+
if file_info.source:
|
|
85
|
+
for rule, reason in extract_escape_hatches(file_info.source):
|
|
86
|
+
all_escapes.append((file_info.path, rule, reason))
|
|
87
|
+
|
|
88
|
+
# DX-22: Check project-level complexity debt (Fix-or-Explain enforcement)
|
|
89
|
+
for debt_violation in check_complexity_debt(
|
|
90
|
+
report.violations, config.shell_complexity_debt_limit
|
|
91
|
+
):
|
|
92
|
+
report.add_violation(debt_violation)
|
|
93
|
+
|
|
94
|
+
# DX-33: Check for duplicate escape reasons across files
|
|
95
|
+
for escape_violation in check_duplicate_escape_reasons(all_escapes):
|
|
96
|
+
report.add_violation(escape_violation)
|
|
97
|
+
|
|
73
98
|
return Success(report)
|
|
74
99
|
|
|
75
100
|
|
|
101
|
+
# @invar:allow entry_point_too_thick: Main CLI entry point, orchestrates all verification phases
|
|
76
102
|
@app.command()
|
|
77
103
|
def guard(
|
|
78
104
|
path: Path = typer.Argument(
|
|
79
105
|
Path(), help="Project root directory", exists=True, file_okay=False, dir_okay=True
|
|
80
106
|
),
|
|
81
107
|
strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
|
|
108
|
+
changed: bool = typer.Option(
|
|
109
|
+
False, "--changed", help="Only check git-modified files"
|
|
110
|
+
),
|
|
111
|
+
static: bool = typer.Option(
|
|
112
|
+
False, "--static", help="Static analysis only, skip all runtime tests"
|
|
113
|
+
),
|
|
114
|
+
human: bool = typer.Option(
|
|
115
|
+
False, "--human", help="Force human-readable output (for testing/debugging)"
|
|
116
|
+
),
|
|
117
|
+
# DX-26: Deprecated flags kept for backward compatibility
|
|
82
118
|
no_strict_pure: bool = typer.Option(
|
|
83
|
-
False, "--no-strict-pure", help="Disable purity checks
|
|
119
|
+
False, "--no-strict-pure", hidden=True, help="[Deprecated] Disable purity checks"
|
|
84
120
|
),
|
|
85
121
|
pedantic: bool = typer.Option(
|
|
86
|
-
False, "--pedantic", help="Show
|
|
122
|
+
False, "--pedantic", hidden=True, help="[Deprecated] Show off-by-default rules"
|
|
87
123
|
),
|
|
88
124
|
explain: bool = typer.Option(
|
|
89
|
-
False, "--explain", help="Show detailed explanations
|
|
90
|
-
),
|
|
91
|
-
changed: bool = typer.Option(
|
|
92
|
-
False, "--changed", help="Only check git-modified files"
|
|
125
|
+
False, "--explain", hidden=True, help="[Deprecated] Show detailed explanations"
|
|
93
126
|
),
|
|
94
127
|
agent: bool = typer.Option(
|
|
95
|
-
False, "--agent", help="
|
|
128
|
+
False, "--agent", help="Force JSON output (for inspecting agent format)"
|
|
96
129
|
),
|
|
97
130
|
json_output: bool = typer.Option(
|
|
98
|
-
False, "--json", help="
|
|
131
|
+
False, "--json", hidden=True, help="[Deprecated] Use TTY auto-detection instead"
|
|
99
132
|
),
|
|
100
|
-
|
|
101
|
-
False, "--
|
|
133
|
+
coverage: bool = typer.Option(
|
|
134
|
+
False, "--coverage", help="DX-37: Collect branch coverage from doctest + hypothesis"
|
|
102
135
|
),
|
|
103
136
|
) -> None:
|
|
104
137
|
"""Check project against Invar architecture rules.
|
|
105
138
|
|
|
106
139
|
Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
|
|
107
140
|
Use --static for quick static-only checks (~0.5s vs ~5s full).
|
|
108
|
-
|
|
109
|
-
DX-19: Simplified to 2 levels (Zero decisions).
|
|
110
141
|
"""
|
|
111
142
|
from invar.shell.guard_helpers import (
|
|
112
143
|
collect_files_to_check,
|
|
@@ -150,58 +181,106 @@ def guard(
|
|
|
150
181
|
raise typer.Exit(1)
|
|
151
182
|
report = scan_result.unwrap()
|
|
152
183
|
|
|
153
|
-
#
|
|
154
|
-
use_agent_output
|
|
155
|
-
json_output, agent
|
|
156
|
-
)
|
|
184
|
+
# DX-26: Simplified output mode (TTY auto-detect + --human override)
|
|
185
|
+
use_agent_output = _determine_output_mode(human, agent, json_output)
|
|
157
186
|
|
|
158
187
|
# DX-19: Simplified to 2 levels (STATIC or STANDARD)
|
|
159
188
|
verification_level = VerificationLevel.STATIC if static else VerificationLevel.STANDARD
|
|
160
189
|
level_name = "STATIC" if static else "STANDARD"
|
|
161
190
|
|
|
162
191
|
# Show verification level (human mode)
|
|
163
|
-
if not use_agent_output
|
|
192
|
+
if not use_agent_output:
|
|
164
193
|
_show_verification_level(verification_level)
|
|
165
194
|
|
|
166
195
|
# Run verification phases
|
|
167
196
|
static_exit_code = get_exit_code(report, strict)
|
|
168
197
|
doctest_passed, doctest_output = True, ""
|
|
169
|
-
crosshair_passed
|
|
170
|
-
|
|
198
|
+
crosshair_passed: bool = True
|
|
199
|
+
crosshair_output: dict = {}
|
|
200
|
+
property_passed: bool = True
|
|
201
|
+
property_output: dict = {}
|
|
202
|
+
# DX-37: Coverage data from doctest + hypothesis phases
|
|
203
|
+
doctest_coverage: dict | None = None
|
|
204
|
+
property_coverage: dict | None = None
|
|
205
|
+
|
|
206
|
+
# DX-37: Check coverage availability if requested
|
|
207
|
+
if coverage:
|
|
208
|
+
from invar.shell.coverage import check_coverage_available
|
|
209
|
+
cov_check = check_coverage_available()
|
|
210
|
+
if isinstance(cov_check, Failure):
|
|
211
|
+
console.print(f"[yellow]Warning:[/yellow] {cov_check.failure()}")
|
|
212
|
+
coverage = False # Disable coverage if not available
|
|
171
213
|
|
|
172
214
|
# DX-19: STANDARD runs all verification phases
|
|
173
215
|
if verification_level == VerificationLevel.STANDARD and static_exit_code == 0:
|
|
174
216
|
checked_files = collect_files_to_check(path, checked_files)
|
|
175
217
|
|
|
176
|
-
# Phase 1: Doctests
|
|
177
|
-
doctest_passed, doctest_output = run_doctests_phase(
|
|
218
|
+
# Phase 1: Doctests (DX-37: with optional coverage)
|
|
219
|
+
doctest_passed, doctest_output, doctest_coverage = run_doctests_phase(
|
|
220
|
+
checked_files, explain, timeout=config.timeout_doctest,
|
|
221
|
+
collect_coverage=coverage,
|
|
222
|
+
)
|
|
178
223
|
|
|
179
224
|
# Phase 2: CrossHair symbolic verification
|
|
225
|
+
# Note: CrossHair uses subprocess + symbolic execution, coverage not applicable
|
|
180
226
|
crosshair_passed, crosshair_output = run_crosshair_phase(
|
|
181
227
|
path, checked_files, doctest_passed, static_exit_code,
|
|
182
228
|
changed_mode=changed,
|
|
229
|
+
timeout=config.timeout_crosshair,
|
|
230
|
+
per_condition_timeout=config.timeout_crosshair_per_condition,
|
|
183
231
|
)
|
|
184
232
|
|
|
185
|
-
# Phase 3: Hypothesis property tests
|
|
186
|
-
property_passed, property_output = run_property_tests_phase(
|
|
187
|
-
checked_files, doctest_passed, static_exit_code
|
|
233
|
+
# Phase 3: Hypothesis property tests (DX-37: with optional coverage)
|
|
234
|
+
property_passed, property_output, property_coverage = run_property_tests_phase(
|
|
235
|
+
checked_files, doctest_passed, static_exit_code,
|
|
236
|
+
collect_coverage=coverage,
|
|
188
237
|
)
|
|
189
|
-
|
|
190
|
-
|
|
238
|
+
elif verification_level == VerificationLevel.STATIC:
|
|
239
|
+
# Static-only mode: explicitly mark verification as skipped
|
|
240
|
+
crosshair_output = {"status": "skipped", "reason": "static mode"}
|
|
241
|
+
property_output = {"status": "skipped", "reason": "static mode"}
|
|
242
|
+
elif static_exit_code != 0:
|
|
243
|
+
# Static failures: explicitly mark verification as skipped
|
|
244
|
+
crosshair_output = {"status": "skipped", "reason": "prior failures"}
|
|
245
|
+
property_output = {"status": "skipped", "reason": "prior failures"}
|
|
246
|
+
|
|
247
|
+
# DX-37: Merge coverage data from doctest + hypothesis
|
|
248
|
+
coverage_output: dict | None = None
|
|
249
|
+
if coverage and (doctest_coverage or property_coverage):
|
|
250
|
+
coverage_output = {
|
|
251
|
+
"enabled": True,
|
|
252
|
+
"phases_tracked": [],
|
|
253
|
+
"phases_excluded": ["crosshair"], # CrossHair uses symbolic execution
|
|
254
|
+
}
|
|
255
|
+
if doctest_coverage and doctest_coverage.get("collected"):
|
|
256
|
+
coverage_output["phases_tracked"].append("doctest")
|
|
257
|
+
if property_coverage and property_coverage.get("collected"):
|
|
258
|
+
coverage_output["phases_tracked"].append("hypothesis")
|
|
259
|
+
if "overall_branch_coverage" in property_coverage:
|
|
260
|
+
coverage_output["overall_branch_coverage"] = property_coverage["overall_branch_coverage"]
|
|
261
|
+
|
|
262
|
+
# DX-26: Unified output (agent JSON or human Rich)
|
|
191
263
|
if use_agent_output:
|
|
192
264
|
output_agent(
|
|
193
|
-
report, doctest_passed, doctest_output, crosshair_output, level_name,
|
|
265
|
+
report, strict, doctest_passed, doctest_output, crosshair_output, level_name,
|
|
194
266
|
property_output=property_output,
|
|
267
|
+
coverage_data=coverage_output, # DX-37
|
|
195
268
|
)
|
|
196
|
-
elif use_json_output:
|
|
197
|
-
output_json(report)
|
|
198
269
|
else:
|
|
199
|
-
output_rich(report, config.strict_pure, changed, pedantic, explain)
|
|
270
|
+
output_rich(report, config.strict_pure, changed, pedantic, explain, static)
|
|
200
271
|
output_verification_status(
|
|
201
272
|
verification_level, static_exit_code, doctest_passed,
|
|
202
273
|
doctest_output, crosshair_output, explain,
|
|
203
274
|
property_output=property_output,
|
|
275
|
+
strict=strict,
|
|
204
276
|
)
|
|
277
|
+
# DX-37: Show coverage info in human output
|
|
278
|
+
if coverage_output and coverage_output.get("phases_tracked"):
|
|
279
|
+
phases = coverage_output.get("phases_tracked", [])
|
|
280
|
+
overall = coverage_output.get("overall_branch_coverage", 0.0)
|
|
281
|
+
console.print(f"\n[bold]Coverage Analysis[/bold] ({' + '.join(phases)})")
|
|
282
|
+
console.print(f" Overall branch coverage: {overall}%")
|
|
283
|
+
console.print(" [dim]Note: CrossHair uses symbolic execution; coverage not applicable.[/dim]")
|
|
205
284
|
|
|
206
285
|
# Exit with combined status
|
|
207
286
|
all_passed = doctest_passed and crosshair_passed and property_passed
|
|
@@ -209,13 +288,26 @@ def guard(
|
|
|
209
288
|
raise typer.Exit(final_exit)
|
|
210
289
|
|
|
211
290
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
291
|
+
# @shell_orchestration: Output mode decision helper for CLI
|
|
292
|
+
def _determine_output_mode(human: bool, agent: bool = False, json_output: bool = False) -> bool:
|
|
293
|
+
"""Determine if agent JSON output should be used (DX-26).
|
|
294
|
+
|
|
295
|
+
DX-26: TTY auto-detection with --human override.
|
|
296
|
+
- --human flag → human output (for testing/debugging)
|
|
297
|
+
- TTY (terminal) → human output
|
|
298
|
+
- Non-TTY (pipe/redirect) → agent JSON output
|
|
299
|
+
- Deprecated --agent/--json flags → still work for backward compat
|
|
300
|
+
"""
|
|
301
|
+
# --human flag always forces human output
|
|
302
|
+
if human:
|
|
303
|
+
return False # use_agent = False
|
|
304
|
+
|
|
305
|
+
# Deprecated flags (backward compat)
|
|
306
|
+
if json_output or agent:
|
|
307
|
+
return True # use_agent = True
|
|
308
|
+
|
|
309
|
+
# TTY auto-detection
|
|
310
|
+
return _detect_agent_mode() # Returns True if non-TTY
|
|
219
311
|
|
|
220
312
|
|
|
221
313
|
def _show_verification_level(verification_level) -> None:
|
|
@@ -245,7 +337,7 @@ def map_command(
|
|
|
245
337
|
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
246
338
|
) -> None:
|
|
247
339
|
"""Generate symbol map with reference counts."""
|
|
248
|
-
from invar.shell.perception import run_map
|
|
340
|
+
from invar.shell.commands.perception import run_map
|
|
249
341
|
|
|
250
342
|
# Phase 9 P11: Auto-detect agent mode
|
|
251
343
|
use_json = json_output or _detect_agent_mode()
|
|
@@ -261,7 +353,7 @@ def sig_command(
|
|
|
261
353
|
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
262
354
|
) -> None:
|
|
263
355
|
"""Extract signatures from a file or symbol."""
|
|
264
|
-
from invar.shell.perception import run_sig
|
|
356
|
+
from invar.shell.commands.perception import run_sig
|
|
265
357
|
|
|
266
358
|
# Phase 9 P11: Auto-detect agent mode
|
|
267
359
|
use_json = json_output or _detect_agent_mode()
|
|
@@ -271,6 +363,7 @@ def sig_command(
|
|
|
271
363
|
raise typer.Exit(1)
|
|
272
364
|
|
|
273
365
|
|
|
366
|
+
# @invar:allow entry_point_too_thick: Rules display with filtering and dual output modes
|
|
274
367
|
@app.command()
|
|
275
368
|
def rules(
|
|
276
369
|
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
@@ -343,15 +436,30 @@ def rules(
|
|
|
343
436
|
console.print(f"\n[dim]{len(rules_list)} rules total. Use --json for full details.[/dim]")
|
|
344
437
|
|
|
345
438
|
|
|
346
|
-
# Import commands from
|
|
347
|
-
from invar.shell.
|
|
348
|
-
from invar.shell.
|
|
349
|
-
from invar.shell.
|
|
439
|
+
# DX-48b: Import commands from shell/commands/
|
|
440
|
+
from invar.shell.commands.init import init
|
|
441
|
+
from invar.shell.commands.mutate import mutate # DX-28
|
|
442
|
+
from invar.shell.commands.sync_self import sync_self # DX-49
|
|
443
|
+
from invar.shell.commands.test import test, verify
|
|
444
|
+
from invar.shell.commands.update import update
|
|
350
445
|
|
|
351
446
|
app.command()(init)
|
|
352
447
|
app.command()(update)
|
|
353
448
|
app.command()(test)
|
|
354
449
|
app.command()(verify)
|
|
450
|
+
app.command()(mutate) # DX-28: Mutation testing
|
|
451
|
+
|
|
452
|
+
# DX-56: Create dev subcommand group for developer commands
|
|
453
|
+
dev_app = typer.Typer(
|
|
454
|
+
name="dev",
|
|
455
|
+
help="Developer commands for Invar project development",
|
|
456
|
+
add_completion=False,
|
|
457
|
+
)
|
|
458
|
+
dev_app.command("sync")(sync_self) # DX-56: renamed from sync-self
|
|
459
|
+
app.add_typer(dev_app)
|
|
460
|
+
|
|
461
|
+
# DX-56: Keep sync-self as alias for backward compatibility (deprecated)
|
|
462
|
+
app.command("sync-self", hidden=True)(sync_self)
|
|
355
463
|
|
|
356
464
|
|
|
357
465
|
if __name__ == "__main__":
|
|
@@ -3,6 +3,8 @@ Init command for Invar.
|
|
|
3
3
|
|
|
4
4
|
Shell module: handles project initialization.
|
|
5
5
|
DX-21B: Added --claude flag for Claude Code integration.
|
|
6
|
+
DX-55: Unified idempotent init command with smart merge.
|
|
7
|
+
DX-56: Uses unified template sync engine for file generation.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from __future__ import annotations
|
|
@@ -15,17 +17,23 @@ import typer
|
|
|
15
17
|
from returns.result import Failure, Success
|
|
16
18
|
from rich.console import Console
|
|
17
19
|
|
|
20
|
+
from invar.core.sync_helpers import SyncConfig
|
|
21
|
+
from invar.core.template_parser import ClaudeMdState
|
|
22
|
+
from invar.shell.commands.merge import (
|
|
23
|
+
ProjectState,
|
|
24
|
+
detect_project_state,
|
|
25
|
+
)
|
|
26
|
+
from invar.shell.commands.template_sync import sync_templates
|
|
18
27
|
from invar.shell.mcp_config import (
|
|
19
28
|
detect_available_methods,
|
|
20
29
|
generate_mcp_json,
|
|
21
30
|
get_method_by_name,
|
|
22
31
|
get_recommended_method,
|
|
23
32
|
)
|
|
33
|
+
from invar.shell.template_engine import generate_from_manifest
|
|
24
34
|
from invar.shell.templates import (
|
|
25
35
|
add_config,
|
|
26
36
|
add_invar_reference,
|
|
27
|
-
copy_examples_directory,
|
|
28
|
-
copy_template,
|
|
29
37
|
create_agent_config,
|
|
30
38
|
create_directories,
|
|
31
39
|
detect_agent_configs,
|
|
@@ -35,6 +43,7 @@ from invar.shell.templates import (
|
|
|
35
43
|
console = Console()
|
|
36
44
|
|
|
37
45
|
|
|
46
|
+
# @shell_complexity: Claude init with config file detection
|
|
38
47
|
def run_claude_init(path: Path) -> bool:
|
|
39
48
|
"""
|
|
40
49
|
Run 'claude /init' to generate intelligent CLAUDE.md.
|
|
@@ -95,16 +104,27 @@ def append_invar_reference_to_claude_md(path: Path) -> bool:
|
|
|
95
104
|
|
|
96
105
|
## Invar Protocol
|
|
97
106
|
|
|
98
|
-
> **Protocol:** Follow [INVAR.md](./INVAR.md)
|
|
107
|
+
> **Protocol:** Follow [INVAR.md](./INVAR.md) — includes Check-In, USBV workflow, and Task Completion.
|
|
108
|
+
|
|
109
|
+
### Check-In
|
|
110
|
+
|
|
111
|
+
Your first message MUST display:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
✓ Check-In: [project] | [branch] | [clean/dirty]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Read `.invar/context.md` first. Do NOT run guard/map at Check-In.
|
|
99
118
|
|
|
100
|
-
|
|
119
|
+
### Final
|
|
101
120
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
Your last message MUST display:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
✓ Final: guard PASS | 0 errors, 2 warnings
|
|
105
125
|
```
|
|
106
126
|
|
|
107
|
-
|
|
127
|
+
Execute `invar guard` and show this one-line summary.
|
|
108
128
|
"""
|
|
109
129
|
|
|
110
130
|
claude_md.write_text(content + invar_reference)
|
|
@@ -112,6 +132,7 @@ invar map --top 10 # or: invar_map(top=10)
|
|
|
112
132
|
return True
|
|
113
133
|
|
|
114
134
|
|
|
135
|
+
# @shell_complexity: MCP config with method selection and validation
|
|
115
136
|
def configure_mcp_with_method(
|
|
116
137
|
path: Path, mcp_method: str | None
|
|
117
138
|
) -> None:
|
|
@@ -163,6 +184,7 @@ def show_available_mcp_methods() -> None:
|
|
|
163
184
|
console.print(f" {marker} {method.method.value}: {method.description}")
|
|
164
185
|
|
|
165
186
|
|
|
187
|
+
# @shell_complexity: Project init with config detection and template setup
|
|
166
188
|
def init(
|
|
167
189
|
path: Path = typer.Argument(Path(), help="Project root directory"),
|
|
168
190
|
claude: bool = typer.Option(
|
|
@@ -179,24 +201,107 @@ def init(
|
|
|
179
201
|
hooks: bool = typer.Option(
|
|
180
202
|
True, "--hooks/--no-hooks", help="Install pre-commit hooks (default: ON)"
|
|
181
203
|
),
|
|
204
|
+
skills: bool = typer.Option(
|
|
205
|
+
True, "--skills/--no-skills", help="Create .claude/skills/ (default: ON, use --no-skills for Cursor)"
|
|
206
|
+
),
|
|
182
207
|
yes: bool = typer.Option(
|
|
183
208
|
False, "--yes", "-y", help="Accept defaults without prompting"
|
|
184
209
|
),
|
|
210
|
+
check: bool = typer.Option(
|
|
211
|
+
False, "--check", help="Preview changes without applying (DX-55)"
|
|
212
|
+
),
|
|
213
|
+
force: bool = typer.Option(
|
|
214
|
+
False, "--force", help="Update even if already current (DX-55)"
|
|
215
|
+
),
|
|
216
|
+
reset: bool = typer.Option(
|
|
217
|
+
False, "--reset", help="Dangerous: discard all user content (DX-55)"
|
|
218
|
+
),
|
|
185
219
|
) -> None:
|
|
186
220
|
"""
|
|
187
|
-
Initialize Invar configuration
|
|
221
|
+
Initialize or update Invar configuration (idempotent).
|
|
222
|
+
|
|
223
|
+
DX-55: This command is idempotent - safe to run multiple times.
|
|
224
|
+
It detects current state and does the right thing:
|
|
225
|
+
|
|
226
|
+
\b
|
|
227
|
+
- New project: Full setup
|
|
228
|
+
- Existing project: Update managed regions, preserve user content
|
|
229
|
+
- Corrupted/overwritten: Smart recovery with content preservation
|
|
188
230
|
|
|
189
231
|
Works with or without pyproject.toml:
|
|
190
|
-
|
|
232
|
+
|
|
233
|
+
\b
|
|
234
|
+
- If pyproject.toml exists: adds tool.invar section
|
|
191
235
|
- Otherwise: creates invar.toml
|
|
192
236
|
|
|
193
|
-
Use --
|
|
237
|
+
Use --check to preview changes without applying.
|
|
238
|
+
Use --force to update even if already current.
|
|
239
|
+
Use --reset to discard all user content (dangerous).
|
|
240
|
+
Use --claude to run 'claude /init' first.
|
|
194
241
|
Use --mcp-method to specify MCP execution method (uvx, command, python).
|
|
195
242
|
Use --dirs to always create directories, --no-dirs to skip.
|
|
196
243
|
Use --no-hooks to skip pre-commit hooks installation.
|
|
244
|
+
Use --no-skills to skip .claude/skills/ creation (for Cursor users).
|
|
197
245
|
Use --yes to accept defaults without prompting.
|
|
198
246
|
"""
|
|
199
|
-
|
|
247
|
+
from invar import __version__
|
|
248
|
+
|
|
249
|
+
# DX-55: Detect project state first
|
|
250
|
+
state = detect_project_state(path)
|
|
251
|
+
|
|
252
|
+
# --check mode: preview only
|
|
253
|
+
if check:
|
|
254
|
+
_show_check_preview(state, path, __version__)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# --reset mode: dangerous full reset
|
|
258
|
+
if reset:
|
|
259
|
+
if not yes and not typer.confirm(
|
|
260
|
+
"[red]This will DELETE all user customizations. Continue?[/red]",
|
|
261
|
+
default=False,
|
|
262
|
+
):
|
|
263
|
+
console.print("[yellow]Cancelled[/yellow]")
|
|
264
|
+
return
|
|
265
|
+
# Fall through to full init with reset flag
|
|
266
|
+
state = ProjectState(
|
|
267
|
+
initialized=False,
|
|
268
|
+
claude_md_state=ClaudeMdState(state="absent"),
|
|
269
|
+
version="",
|
|
270
|
+
needs_update=True,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# DX-55: Handle based on detected state
|
|
274
|
+
action = state.action if not force else "update"
|
|
275
|
+
|
|
276
|
+
if action == "none" and not force:
|
|
277
|
+
# DX-55: Check for missing required files before declaring "no changes needed"
|
|
278
|
+
missing_files = []
|
|
279
|
+
if skills:
|
|
280
|
+
skill_files = [
|
|
281
|
+
".claude/skills/develop/SKILL.md",
|
|
282
|
+
".claude/skills/investigate/SKILL.md",
|
|
283
|
+
".claude/skills/propose/SKILL.md",
|
|
284
|
+
".claude/skills/review/SKILL.md",
|
|
285
|
+
]
|
|
286
|
+
for skill_file in skill_files:
|
|
287
|
+
if not (path / skill_file).exists():
|
|
288
|
+
missing_files.append(skill_file)
|
|
289
|
+
|
|
290
|
+
if not missing_files:
|
|
291
|
+
console.print(f"[green]✓[/green] Invar v{__version__} configured (no changes needed)")
|
|
292
|
+
console.print("[dim]Use --force to refresh managed regions[/dim]")
|
|
293
|
+
return
|
|
294
|
+
else:
|
|
295
|
+
# Recreate missing files
|
|
296
|
+
console.print(f"[yellow]Detected:[/yellow] {len(missing_files)} missing file(s)")
|
|
297
|
+
result = generate_from_manifest(path, syntax="cli", files_to_generate=missing_files)
|
|
298
|
+
if isinstance(result, Success):
|
|
299
|
+
for generated_file in result.unwrap():
|
|
300
|
+
console.print(f"[green]Restored[/green] {generated_file}")
|
|
301
|
+
console.print(f"[green]✓[/green] Invar v{__version__} configured")
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
# DX-21B: Run claude /init if requested (before sync)
|
|
200
305
|
if claude:
|
|
201
306
|
claude_success = run_claude_init(path)
|
|
202
307
|
if claude_success:
|
|
@@ -209,26 +314,49 @@ def init(
|
|
|
209
314
|
raise typer.Exit(1)
|
|
210
315
|
config_added = config_result.unwrap()
|
|
211
316
|
|
|
212
|
-
#
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
317
|
+
# DX-56: Use unified sync engine for file generation
|
|
318
|
+
console.print("\n[bold]Creating Invar files...[/bold]")
|
|
319
|
+
|
|
320
|
+
# Check for project-additions.md
|
|
321
|
+
has_project_additions = (path / ".invar" / "project-additions.md").exists()
|
|
322
|
+
|
|
323
|
+
# Build skip patterns for --no-skills
|
|
324
|
+
skip_patterns: list[str] = []
|
|
325
|
+
if not skills:
|
|
326
|
+
skip_patterns.append(".claude/skills/*")
|
|
327
|
+
|
|
328
|
+
sync_config = SyncConfig(
|
|
329
|
+
syntax="cli",
|
|
330
|
+
inject_project_additions=has_project_additions,
|
|
331
|
+
force=force,
|
|
332
|
+
check=False, # Already handled above
|
|
333
|
+
reset=reset,
|
|
334
|
+
skip_patterns=skip_patterns,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# DX-56: Run unified sync engine (handles DX-55 state detection internally)
|
|
338
|
+
result = sync_templates(path, sync_config)
|
|
339
|
+
if isinstance(result, Failure):
|
|
340
|
+
console.print(f"[yellow]Warning:[/yellow] {result.failure()}")
|
|
341
|
+
else:
|
|
342
|
+
report = result.unwrap()
|
|
343
|
+
for file in report.created:
|
|
344
|
+
console.print(f"[green]Created[/green] {file}")
|
|
345
|
+
for file in report.updated:
|
|
346
|
+
console.print(f"[cyan]Updated[/cyan] {file}")
|
|
347
|
+
for error in report.errors:
|
|
348
|
+
console.print(f"[yellow]Warning:[/yellow] {error}")
|
|
349
|
+
|
|
350
|
+
# Create .invar directory structure (for proposals template - not in manifest)
|
|
221
351
|
invar_dir = path / ".invar"
|
|
222
352
|
if not invar_dir.exists():
|
|
223
353
|
invar_dir.mkdir()
|
|
224
|
-
result = copy_template("context.md.template", invar_dir, "context.md")
|
|
225
|
-
if isinstance(result, Success) and result.unwrap():
|
|
226
|
-
console.print("[green]Created[/green] .invar/context.md (context management)")
|
|
227
354
|
|
|
228
355
|
# Create proposals directory for protocol governance
|
|
229
356
|
proposals_dir = invar_dir / "proposals"
|
|
230
357
|
if not proposals_dir.exists():
|
|
231
358
|
proposals_dir.mkdir()
|
|
359
|
+
from invar.shell.templates import copy_template
|
|
232
360
|
result = copy_template("proposal.md.template", proposals_dir, "TEMPLATE.md")
|
|
233
361
|
if isinstance(result, Success) and result.unwrap():
|
|
234
362
|
console.print("[green]Created[/green] .invar/proposals/TEMPLATE.md")
|
|
@@ -281,9 +409,53 @@ def init(
|
|
|
281
409
|
if not config_added and not (path / "INVAR.md").exists():
|
|
282
410
|
console.print("[yellow]Invar already configured.[/yellow]")
|
|
283
411
|
|
|
284
|
-
# Summary
|
|
285
|
-
|
|
412
|
+
# DX-55: Summary based on action taken
|
|
413
|
+
if action == "full_init":
|
|
414
|
+
console.print(f"\n[bold green]✓ Initialized Invar v{__version__}[/bold green]")
|
|
415
|
+
console.print("[dim]Note: If you run 'claude /init' later, just run 'invar init' again.[/dim]")
|
|
416
|
+
elif action == "recover":
|
|
417
|
+
console.print(f"\n[bold green]✓ Recovered Invar v{__version__}[/bold green]")
|
|
418
|
+
console.print("[dim]Review the merged content in CLAUDE.md[/dim]")
|
|
419
|
+
elif action == "update" or force:
|
|
420
|
+
console.print(f"\n[bold green]✓ Updated Invar v{__version__}[/bold green]")
|
|
421
|
+
console.print("[dim]Refreshed managed regions, preserved user content[/dim]")
|
|
422
|
+
else:
|
|
423
|
+
console.print("\n[bold green]Invar initialized successfully![/bold green]")
|
|
424
|
+
|
|
286
425
|
if claude:
|
|
287
426
|
console.print("[dim]Next: Review CLAUDE.md and start coding with Claude Code[/dim]")
|
|
288
|
-
|
|
289
|
-
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# @shell_complexity: Preview display requires multiple state-specific branches
|
|
430
|
+
def _show_check_preview(state: ProjectState, path: Path, version: str) -> None:
|
|
431
|
+
"""Show preview of what would change (--check mode)."""
|
|
432
|
+
console.print(f"\n[bold]Invar v{version} - Preview Mode[/bold]\n")
|
|
433
|
+
|
|
434
|
+
console.print(f"Project state: [cyan]{state.claude_md_state.state}[/cyan]")
|
|
435
|
+
console.print(f"Initialized: [cyan]{state.initialized}[/cyan]")
|
|
436
|
+
console.print(f"Current version: [cyan]{state.version or 'N/A'}[/cyan]")
|
|
437
|
+
console.print(f"Needs update: [cyan]{state.needs_update}[/cyan]")
|
|
438
|
+
console.print(f"Action: [cyan]{state.action}[/cyan]\n")
|
|
439
|
+
|
|
440
|
+
match state.action:
|
|
441
|
+
case "none":
|
|
442
|
+
console.print("[green]No changes needed[/green]")
|
|
443
|
+
case "full_init":
|
|
444
|
+
console.print("Would create:")
|
|
445
|
+
console.print(" - INVAR.md")
|
|
446
|
+
console.print(" - CLAUDE.md")
|
|
447
|
+
console.print(" - .invar/context.md")
|
|
448
|
+
console.print(" - .claude/skills/")
|
|
449
|
+
console.print(" - .pre-commit-config.yaml")
|
|
450
|
+
case "update":
|
|
451
|
+
console.print("Would update:")
|
|
452
|
+
console.print(f" - CLAUDE.md (managed section v{state.version} → v{version})")
|
|
453
|
+
console.print(" - .claude/skills/* (refresh)")
|
|
454
|
+
case "recover":
|
|
455
|
+
console.print("[yellow]Would recover:[/yellow]")
|
|
456
|
+
console.print(" - CLAUDE.md (restore regions, preserve content)")
|
|
457
|
+
case "create":
|
|
458
|
+
console.print("Would create:")
|
|
459
|
+
console.print(" - CLAUDE.md")
|
|
460
|
+
|
|
461
|
+
console.print("\n[dim]Run 'invar init' to apply.[/dim]")
|