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
@@ -0,0 +1,902 @@
1
+ """TypeScript Guard Orchestration.
2
+
3
+ Shell module: orchestrates TypeScript verification via subprocess calls.
4
+ Part of LX-06 TypeScript tooling support.
5
+
6
+ This module provides graceful degradation - if TypeScript tools are not
7
+ installed, it reports the missing dependency rather than failing hard.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import json
14
+ import re
15
+ import subprocess
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Literal
19
+
20
+ from returns.result import Failure, Result, Success
21
+
22
+
23
+ @dataclass
24
+ class TypeScriptViolation:
25
+ """A single TypeScript verification issue."""
26
+
27
+ file: str
28
+ line: int | None
29
+ column: int | None
30
+ rule: str
31
+ message: str
32
+ severity: Literal["error", "warning", "info"]
33
+ source: Literal["tsc", "eslint", "vitest"]
34
+
35
+
36
+ @dataclass
37
+ class ContractQuality:
38
+ """Contract quality metrics from ts-analyzer."""
39
+
40
+ strong: int = 0
41
+ medium: int = 0
42
+ weak: int = 0
43
+ useless: int = 0
44
+
45
+
46
+ @dataclass
47
+ class BlindSpot:
48
+ """High-risk code without validation."""
49
+
50
+ function: str
51
+ file: str
52
+ line: int
53
+ risk: Literal["critical", "high", "medium", "low"]
54
+ reason: str
55
+ suggested_schema: str | None = None
56
+
57
+
58
+ @dataclass
59
+ class EnhancedAnalysis:
60
+ """Enhanced analysis from @invar/* Node components."""
61
+
62
+ quick_check_available: bool = False
63
+ ts_analyzer_available: bool = False
64
+ fc_runner_available: bool = False
65
+ contract_coverage: float | None = None
66
+ contract_quality: ContractQuality | None = None
67
+ blind_spots: list[BlindSpot] = field(default_factory=list)
68
+ property_tests_passed: bool | None = None
69
+ property_test_failures: list[dict] = field(default_factory=list)
70
+
71
+
72
+ @dataclass
73
+ class TypeScriptGuardResult:
74
+ """Result of TypeScript verification."""
75
+
76
+ status: Literal["passed", "failed", "skipped"]
77
+ violations: list[TypeScriptViolation] = field(default_factory=list)
78
+ tsc_available: bool = False
79
+ eslint_available: bool = False
80
+ vitest_available: bool = False
81
+ error_count: int = 0
82
+ warning_count: int = 0
83
+ tool_errors: list[str] = field(default_factory=list)
84
+ enhanced: EnhancedAnalysis | None = None
85
+
86
+
87
+ # @shell_orchestration: Transforms TypeScriptGuardResult to JSON for agent consumption
88
+ def format_typescript_guard_v2(result: TypeScriptGuardResult) -> dict:
89
+ """Format TypeScript guard result as v2.0 JSON.
90
+
91
+ LX-06 Phase 3: Agent-optimized JSON output format with:
92
+ - Contract coverage and quality metrics
93
+ - Blind spot detection
94
+ - Property test results with counterexamples
95
+ - Structured fix suggestions
96
+
97
+ Args:
98
+ result: TypeScript guard result to format.
99
+
100
+ Returns:
101
+ Dict in v2.0 JSON format for agent consumption.
102
+ """
103
+ # Count violations by source
104
+ tsc_errors = sum(1 for v in result.violations if v.source == "tsc" and v.severity == "error")
105
+ tsc_warnings = sum(1 for v in result.violations if v.source == "tsc" and v.severity == "warning")
106
+ eslint_errors = sum(1 for v in result.violations if v.source == "eslint" and v.severity == "error")
107
+ eslint_warnings = sum(1 for v in result.violations if v.source == "eslint" and v.severity == "warning")
108
+ vitest_failures = sum(1 for v in result.violations if v.source == "vitest")
109
+
110
+ # Count files checked (unique files in violations + estimate from available tools)
111
+ files_checked = len({v.file for v in result.violations}) if result.violations else 0
112
+
113
+ output: dict = {
114
+ "version": "2.0",
115
+ "language": "typescript",
116
+ "status": result.status,
117
+ "summary": {
118
+ "errors": result.error_count,
119
+ "warnings": result.warning_count,
120
+ "files_checked": files_checked,
121
+ },
122
+ "static": {
123
+ "tsc": {
124
+ "passed": tsc_errors == 0,
125
+ "available": result.tsc_available,
126
+ "errors": tsc_errors,
127
+ "warnings": tsc_warnings,
128
+ },
129
+ "eslint": {
130
+ "passed": eslint_errors == 0,
131
+ "available": result.eslint_available,
132
+ "errors": eslint_errors,
133
+ "warnings": eslint_warnings,
134
+ },
135
+ },
136
+ "tests": {
137
+ "passed": vitest_failures == 0,
138
+ "available": result.vitest_available,
139
+ "failures": vitest_failures,
140
+ },
141
+ "violations": [
142
+ {
143
+ "file": v.file,
144
+ "line": v.line,
145
+ "column": v.column,
146
+ "rule": v.rule,
147
+ "message": v.message,
148
+ "severity": v.severity,
149
+ "source": v.source,
150
+ }
151
+ for v in result.violations
152
+ ],
153
+ }
154
+
155
+ # Add enhanced analysis if available (LX-06 Phase 2)
156
+ if result.enhanced:
157
+ enhanced = result.enhanced
158
+
159
+ # Property tests section
160
+ if enhanced.fc_runner_available:
161
+ # Handle None (not run) vs False (failed) vs True (passed)
162
+ if enhanced.property_tests_passed is None:
163
+ pt_status = "skipped"
164
+ elif enhanced.property_tests_passed:
165
+ pt_status = "passed"
166
+ else:
167
+ pt_status = "failed"
168
+
169
+ property_tests: dict = {
170
+ "status": pt_status,
171
+ "confidence": "statistical",
172
+ "available": True,
173
+ }
174
+ if enhanced.property_test_failures:
175
+ property_tests["failures"] = [
176
+ {
177
+ "name": f.get("name", "unknown"),
178
+ "counterexample": f.get("counterexample"),
179
+ "analysis": f.get("analysis"),
180
+ }
181
+ for f in enhanced.property_test_failures
182
+ ]
183
+ output["property_tests"] = property_tests
184
+
185
+ # Contracts section
186
+ if enhanced.ts_analyzer_available:
187
+ contracts: dict = {"available": True}
188
+ if enhanced.contract_coverage is not None:
189
+ contracts["coverage"] = enhanced.contract_coverage
190
+ if enhanced.contract_quality:
191
+ contracts["quality"] = {
192
+ "strong": enhanced.contract_quality.strong,
193
+ "medium": enhanced.contract_quality.medium,
194
+ "weak": enhanced.contract_quality.weak,
195
+ "useless": enhanced.contract_quality.useless,
196
+ }
197
+ if enhanced.blind_spots:
198
+ contracts["blind_spots"] = [
199
+ {
200
+ "function": bs.function,
201
+ "file": bs.file,
202
+ "line": bs.line,
203
+ "risk": bs.risk,
204
+ "reason": bs.reason,
205
+ "suggested_schema": bs.suggested_schema,
206
+ }
207
+ for bs in enhanced.blind_spots
208
+ ]
209
+ output["contracts"] = contracts
210
+
211
+ # Add tool errors if any
212
+ if result.tool_errors:
213
+ output["tool_errors"] = result.tool_errors
214
+
215
+ # LX-06 Phase 3: Generate fix suggestions from violations
216
+ fixes = _generate_fix_suggestions(result.violations)
217
+ if fixes:
218
+ output["fixes"] = fixes
219
+
220
+ return output
221
+
222
+
223
+ # @shell_orchestration: Helper for format_typescript_guard_v2 output assembly
224
+ def _generate_fix_suggestions(violations: list[TypeScriptViolation]) -> list[dict]:
225
+ """Generate actionable fix suggestions from violations.
226
+
227
+ LX-06 Phase 3: Maps ESLint rule violations to repair code snippets.
228
+
229
+ Args:
230
+ violations: List of TypeScriptViolation from ESLint/tsc.
231
+
232
+ Returns:
233
+ List of fix suggestions with repair code.
234
+ """
235
+ fixes: list[dict] = []
236
+ fix_counter = 1
237
+
238
+ # Rule-specific fix generators mapping rule_id to (priority, action, code_template)
239
+ fix_generators: dict[str, tuple[str, str, str]] = {
240
+ "@invar/require-schema-validation": (
241
+ "high",
242
+ "insert",
243
+ "const validated = Schema.parse({param});",
244
+ ),
245
+ "@invar/shell-result-type": (
246
+ "medium",
247
+ "replace",
248
+ "Result<{return_type}, Error>",
249
+ ),
250
+ "@invar/no-io-in-core": (
251
+ "high",
252
+ "refactor",
253
+ "// Move to shell/ directory and import from there",
254
+ ),
255
+ }
256
+
257
+ for v in violations:
258
+ if v.source != "eslint":
259
+ continue
260
+
261
+ rule = v.rule or ""
262
+ if rule not in fix_generators:
263
+ continue
264
+
265
+ priority, action, code = fix_generators[rule]
266
+
267
+ # Customize code based on rule
268
+ if rule == "@invar/require-schema-validation":
269
+ # Extract param name from message (fragile: depends on ESLint message format)
270
+ # Falls back to "input" if extraction fails - user can adjust in fix suggestion
271
+ param_match = re.search(r'"(\w+)"', v.message)
272
+ param = param_match.group(1) if param_match else "input"
273
+ code = code.replace("{param}", param)
274
+ elif rule == "@invar/shell-result-type":
275
+ # Use 'T' as placeholder since actual type requires source analysis
276
+ code = code.replace("{return_type}", "T")
277
+
278
+ fix = {
279
+ "id": f"FIX-{fix_counter:03d}",
280
+ "priority": priority,
281
+ "issue": {
282
+ "type": rule.replace("@invar/", ""),
283
+ "message": v.message,
284
+ "location": {"file": v.file, "line": v.line, "column": v.column},
285
+ },
286
+ "repair": {
287
+ "action": action,
288
+ "target": {
289
+ "file": v.file,
290
+ "line": (v.line + 1) if v.line is not None else None,
291
+ },
292
+ "code": code,
293
+ "explanation": f"Fix for {rule}",
294
+ },
295
+ }
296
+ fixes.append(fix)
297
+ fix_counter += 1
298
+
299
+ return fixes
300
+
301
+
302
+ def _check_tool_available(tool: str, check_args: list[str]) -> bool:
303
+ """Check if a tool is available in PATH.
304
+
305
+ Args:
306
+ tool: Tool name (e.g., "npx", "tsc") - must be alphanumeric/dash/underscore
307
+ check_args: Arguments for version check
308
+
309
+ Returns:
310
+ True if tool is available and responds to check.
311
+ """
312
+ # Security: validate tool name to prevent command injection
313
+ if not tool or not all(c.isalnum() or c in "-_" for c in tool):
314
+ return False
315
+
316
+ try:
317
+ result = subprocess.run(
318
+ [tool, *check_args],
319
+ capture_output=True,
320
+ timeout=10,
321
+ )
322
+ return result.returncode == 0
323
+ except (FileNotFoundError, subprocess.TimeoutExpired):
324
+ return False
325
+
326
+
327
+ # =============================================================================
328
+ # @invar/* Node Component Integration (LX-06 Phase 2)
329
+ # =============================================================================
330
+
331
+
332
+ # @shell_complexity: Path discovery with fallback logic
333
+ def _get_invar_package_cmd(package_name: str, project_path: Path) -> list[str]:
334
+ """Get command to run an @invar/* package.
335
+
336
+ Priority order:
337
+ 1. Embedded tools (pip install invar-tools includes these)
338
+ 2. Local development (typescript/packages/*/dist/ in Invar repo)
339
+ 3. npx fallback (if published to npm)
340
+
341
+ Args:
342
+ package_name: Package name without @invar/ prefix (e.g., "ts-analyzer")
343
+ project_path: Project path to check for local installation
344
+
345
+ Returns:
346
+ Command list for subprocess.run
347
+ """
348
+ # Priority 1: Embedded tools (from pip install)
349
+ try:
350
+ from invar.node_tools import get_tool_path
351
+
352
+ if embedded := get_tool_path(package_name):
353
+ return ["node", str(embedded)]
354
+ except ImportError:
355
+ pass # node_tools module not available
356
+
357
+ # Priority 2: Local development setup (Invar repo itself)
358
+ local_cli = project_path / f"typescript/packages/{package_name}/dist/cli.js"
359
+ if local_cli.exists():
360
+ return ["node", str(local_cli)]
361
+
362
+ # Priority 2b: Walk up to find the Invar root (monorepo setup)
363
+ check_path = project_path
364
+ for _ in range(5): # Max 5 levels up
365
+ candidate = check_path / f"typescript/packages/{package_name}/dist/cli.js"
366
+ if candidate.exists():
367
+ return ["node", str(candidate)]
368
+ parent = check_path.parent
369
+ if parent == check_path:
370
+ break
371
+ check_path = parent
372
+
373
+ # Priority 3: npx fallback (requires package published to npm)
374
+ return ["npx", f"@invar/{package_name}"]
375
+
376
+
377
+ # @shell_complexity: Error handling branches for subprocess/JSON parsing
378
+ def run_ts_analyzer(project_path: Path) -> Result[dict, str]:
379
+ """Run @invar/ts-analyzer for contract coverage analysis.
380
+
381
+ Calls the Node component via npx with JSON output mode.
382
+ Gracefully degrades if the package is not installed.
383
+
384
+ Args:
385
+ project_path: Path to TypeScript project root.
386
+
387
+ Returns:
388
+ Result containing analysis dict or error message.
389
+ """
390
+ # Validate project path exists before subprocess call
391
+ if not project_path.exists():
392
+ return Failure(f"Project path does not exist: {project_path}")
393
+
394
+ try:
395
+ cmd = _get_invar_package_cmd("ts-analyzer", project_path)
396
+ result = subprocess.run(
397
+ [*cmd, str(project_path), "--json"],
398
+ capture_output=True,
399
+ text=True,
400
+ timeout=60,
401
+ cwd=project_path,
402
+ )
403
+ # ts-analyzer exits non-zero when critical blind spots found, but still outputs valid JSON
404
+ # Try to parse JSON output regardless of exit code
405
+ if result.stdout.strip():
406
+ with contextlib.suppress(json.JSONDecodeError):
407
+ return Success(json.loads(result.stdout))
408
+
409
+ # Only report failure if no valid JSON output
410
+ if "not found" in result.stderr.lower() or "ENOENT" in result.stderr:
411
+ return Failure("@invar/ts-analyzer not installed")
412
+ return Failure(result.stderr or "ts-analyzer failed")
413
+ except FileNotFoundError:
414
+ return Failure("node/npx not available - is Node.js installed?")
415
+ except subprocess.TimeoutExpired:
416
+ return Failure("ts-analyzer timed out")
417
+
418
+
419
+ # @shell_complexity: Error handling branches for subprocess/JSON parsing
420
+ def run_fc_runner(
421
+ project_path: Path, *, seed: int = 42, num_runs: int = 100
422
+ ) -> Result[dict, str]:
423
+ """Run @invar/fc-runner for property-based testing.
424
+
425
+ Calls the Node component via npx with JSON output mode.
426
+ Gracefully degrades if the package is not installed.
427
+
428
+ Args:
429
+ project_path: Path to TypeScript project root.
430
+ seed: Random seed for reproducibility.
431
+ num_runs: Number of test iterations.
432
+
433
+ Returns:
434
+ Result containing test results dict or error message.
435
+ """
436
+ # Validate project path exists before subprocess call
437
+ if not project_path.exists():
438
+ return Failure(f"Project path does not exist: {project_path}")
439
+
440
+ try:
441
+ cmd = _get_invar_package_cmd("fc-runner", project_path)
442
+ result = subprocess.run(
443
+ [*cmd, "--json", "--seed", str(seed), "--num-runs", str(num_runs)],
444
+ capture_output=True,
445
+ text=True,
446
+ timeout=120, # Property tests can take longer
447
+ cwd=project_path,
448
+ )
449
+ if result.returncode == 0:
450
+ with contextlib.suppress(json.JSONDecodeError):
451
+ return Success(json.loads(result.stdout))
452
+ return Failure("Invalid JSON output from fc-runner")
453
+ if "not found" in result.stderr.lower() or "ENOENT" in result.stderr:
454
+ return Failure("@invar/fc-runner not installed")
455
+ # Return code 1 might mean test failures - try to parse output
456
+ with contextlib.suppress(json.JSONDecodeError):
457
+ data = json.loads(result.stdout)
458
+ if "properties" in data:
459
+ return Success(data) # Has results even if tests failed
460
+ return Failure(result.stderr or "fc-runner failed")
461
+ except FileNotFoundError:
462
+ return Failure("node/npx not available - is Node.js installed?")
463
+ except subprocess.TimeoutExpired:
464
+ return Failure("fc-runner timed out")
465
+
466
+
467
+ # @shell_complexity: Error handling branches for subprocess/JSON parsing
468
+ def run_quick_check(project_path: Path) -> Result[dict, str]:
469
+ """Run @invar/quick-check for fast smoke testing.
470
+
471
+ Calls the Node component via npx with JSON output mode.
472
+ Gracefully degrades if the package is not installed.
473
+
474
+ Args:
475
+ project_path: Path to TypeScript project root.
476
+
477
+ Returns:
478
+ Result containing check results dict or error message.
479
+ """
480
+ # Validate project path exists before subprocess call
481
+ if not project_path.exists():
482
+ return Failure(f"Project path does not exist: {project_path}")
483
+
484
+ try:
485
+ cmd = _get_invar_package_cmd("quick-check", project_path)
486
+ result = subprocess.run(
487
+ [*cmd, str(project_path), "--json"],
488
+ capture_output=True,
489
+ text=True,
490
+ timeout=30, # Quick check should be fast
491
+ cwd=project_path,
492
+ )
493
+ # quick-check exits non-zero when checks fail, but still outputs valid JSON
494
+ if result.stdout.strip():
495
+ with contextlib.suppress(json.JSONDecodeError):
496
+ return Success(json.loads(result.stdout))
497
+
498
+ if "not found" in result.stderr.lower() or "ENOENT" in result.stderr:
499
+ return Failure("@invar/quick-check not installed")
500
+ return Failure(result.stderr or "quick-check failed")
501
+ except FileNotFoundError:
502
+ return Failure("node/npx not available - is Node.js installed?")
503
+ except subprocess.TimeoutExpired:
504
+ return Failure("quick-check timed out")
505
+
506
+
507
+ # @shell_orchestration: Helper for run_ts_analyzer - processes subprocess output
508
+ def _parse_ts_analyzer_result(
509
+ data: dict,
510
+ ) -> tuple[float | None, ContractQuality | None, list[BlindSpot]]:
511
+ """Parse ts-analyzer JSON output into typed structures.
512
+
513
+ Args:
514
+ data: Raw JSON dict from ts-analyzer.
515
+
516
+ Returns:
517
+ Tuple of (coverage, quality, blind_spots).
518
+ """
519
+ coverage = data.get("coverage")
520
+
521
+ quality = None
522
+ if q := data.get("quality"):
523
+ quality = ContractQuality(
524
+ strong=q.get("strong", 0),
525
+ medium=q.get("medium", 0),
526
+ weak=q.get("weak", 0),
527
+ useless=q.get("useless", 0),
528
+ )
529
+
530
+ blind_spots = []
531
+ for bs in data.get("blindSpots", []):
532
+ blind_spots.append(
533
+ BlindSpot(
534
+ function=bs.get("function", "unknown"),
535
+ file=bs.get("file", "unknown"),
536
+ line=bs.get("line", 0),
537
+ risk=bs.get("risk", "medium"),
538
+ reason=bs.get("reason", ""),
539
+ suggested_schema=bs.get("suggestedSchema"),
540
+ )
541
+ )
542
+
543
+ return coverage, quality, blind_spots
544
+
545
+
546
+ # @shell_orchestration: Helper for run_fc_runner - processes subprocess output
547
+ def _parse_fc_runner_result(data: dict) -> tuple[bool | None, list[dict]]:
548
+ """Parse fc-runner JSON output into typed structures.
549
+
550
+ Args:
551
+ data: Raw JSON dict from fc-runner.
552
+
553
+ Returns:
554
+ Tuple of (all_passed, failures).
555
+ """
556
+ if "properties" not in data:
557
+ return None, []
558
+
559
+ failures = []
560
+ all_passed = True
561
+
562
+ for prop in data.get("properties", []):
563
+ if prop.get("status") == "failed":
564
+ all_passed = False
565
+ failures.append(
566
+ {
567
+ "name": prop.get("name", "unknown"),
568
+ "counterexample": prop.get("counterexample"),
569
+ "seed": prop.get("seed"),
570
+ "shrunk": prop.get("shrunk", False),
571
+ }
572
+ )
573
+
574
+ return all_passed, failures
575
+
576
+
577
+ # =============================================================================
578
+ # Standard Tools (tsc, eslint, vitest)
579
+ # =============================================================================
580
+
581
+
582
+ # @shell_complexity: CLI tool integration with error handling and output parsing
583
+ def run_tsc(project_path: Path) -> Result[list[TypeScriptViolation], str]:
584
+ """Run TypeScript compiler for type checking.
585
+
586
+ Args:
587
+ project_path: Path to TypeScript project root.
588
+
589
+ Returns:
590
+ Result containing list of violations or error message.
591
+ """
592
+ # Validate project path exists before subprocess call
593
+ if not project_path.exists():
594
+ return Failure(f"Project path does not exist: {project_path}")
595
+
596
+ tsconfig = project_path / "tsconfig.json"
597
+ if not tsconfig.exists():
598
+ return Failure("No tsconfig.json found")
599
+
600
+ try:
601
+ result = subprocess.run(
602
+ ["npx", "tsc", "--noEmit", "--pretty", "false"],
603
+ cwd=project_path,
604
+ capture_output=True,
605
+ text=True,
606
+ timeout=120,
607
+ )
608
+
609
+ violations: list[TypeScriptViolation] = []
610
+
611
+ # Parse tsc output (format: file(line,col): error TSxxxx: message)
612
+ for line in result.stdout.splitlines():
613
+ if ": error TS" in line or ": warning TS" in line:
614
+ violation = _parse_tsc_line(line)
615
+ if violation:
616
+ violations.append(violation)
617
+
618
+ return Success(violations)
619
+
620
+ except FileNotFoundError:
621
+ return Failure("npx not found - is Node.js installed?")
622
+ except subprocess.TimeoutExpired:
623
+ return Failure("tsc timed out after 120 seconds")
624
+
625
+
626
+ # @shell_orchestration: Parser helper for run_tsc subprocess output
627
+ def _parse_tsc_line(line: str) -> TypeScriptViolation | None:
628
+ """Parse a single tsc output line into a violation.
629
+
630
+ Args:
631
+ line: Raw tsc output line.
632
+
633
+ Returns:
634
+ Parsed violation or None if parsing fails.
635
+
636
+ Examples:
637
+ >>> v = _parse_tsc_line("src/foo.ts(10,5): error TS2322: Type mismatch")
638
+ >>> v.file if v else None
639
+ 'src/foo.ts'
640
+ >>> v.line if v else None
641
+ 10
642
+ >>> v.rule if v else None
643
+ 'TS2322'
644
+ """
645
+ # Pattern: file(line,col): severity TSxxxx: message
646
+ pattern = r"^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$"
647
+ match = re.match(pattern, line)
648
+
649
+ if not match:
650
+ return None
651
+
652
+ file_path, line_num, col, severity, code, message = match.groups()
653
+
654
+ return TypeScriptViolation(
655
+ file=file_path,
656
+ line=int(line_num),
657
+ column=int(col),
658
+ rule=code,
659
+ message=message,
660
+ severity="error" if severity == "error" else "warning",
661
+ source="tsc",
662
+ )
663
+
664
+
665
+ # @shell_complexity: CLI tool integration with JSON parsing and error handling
666
+ def run_eslint(project_path: Path) -> Result[list[TypeScriptViolation], str]:
667
+ """Run ESLint for code quality checks.
668
+
669
+ Args:
670
+ project_path: Path to project root.
671
+
672
+ Returns:
673
+ Result containing list of violations or error message.
674
+ """
675
+ # Validate project path exists before subprocess call
676
+ if not project_path.exists():
677
+ return Failure(f"Project path does not exist: {project_path}")
678
+
679
+ try:
680
+ result = subprocess.run(
681
+ ["npx", "eslint", ".", "--format", "json", "--ext", ".ts,.tsx"],
682
+ cwd=project_path,
683
+ capture_output=True,
684
+ text=True,
685
+ timeout=120,
686
+ )
687
+
688
+ violations: list[TypeScriptViolation] = []
689
+
690
+ try:
691
+ eslint_output = json.loads(result.stdout)
692
+ for file_result in eslint_output:
693
+ file_path = file_result.get("filePath", "")
694
+ # Make path relative
695
+ with contextlib.suppress(ValueError):
696
+ file_path = str(Path(file_path).relative_to(project_path))
697
+
698
+ for msg in file_result.get("messages", []):
699
+ severity_num = msg.get("severity", 1)
700
+ violations.append(
701
+ TypeScriptViolation(
702
+ file=file_path,
703
+ line=msg.get("line"),
704
+ column=msg.get("column"),
705
+ rule=msg.get("ruleId", "unknown"),
706
+ message=msg.get("message", ""),
707
+ severity="error" if severity_num == 2 else "warning",
708
+ source="eslint",
709
+ )
710
+ )
711
+ except json.JSONDecodeError:
712
+ # ESLint may output non-JSON on certain errors
713
+ if result.returncode != 0 and result.stderr:
714
+ return Failure(f"ESLint error: {result.stderr[:200]}")
715
+
716
+ return Success(violations)
717
+
718
+ except FileNotFoundError:
719
+ return Failure("npx not found - is Node.js installed?")
720
+ except subprocess.TimeoutExpired:
721
+ return Failure("eslint timed out after 120 seconds")
722
+
723
+
724
+ # @shell_complexity: Test runner with JSON result parsing and failure extraction
725
+ def run_vitest(project_path: Path) -> Result[list[TypeScriptViolation], str]:
726
+ """Run Vitest for test execution.
727
+
728
+ Args:
729
+ project_path: Path to project root.
730
+
731
+ Returns:
732
+ Result containing list of violations (test failures) or error message.
733
+ """
734
+ # Validate project path exists before subprocess call
735
+ if not project_path.exists():
736
+ return Failure(f"Project path does not exist: {project_path}")
737
+
738
+ vitest_config = project_path / "vitest.config.ts"
739
+ if not vitest_config.exists() and not (project_path / "vitest.config.js").exists():
740
+ # Check if vitest is in package.json
741
+ pkg_json = project_path / "package.json"
742
+ if pkg_json.exists():
743
+ try:
744
+ pkg = json.loads(pkg_json.read_text())
745
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
746
+ if "vitest" not in deps:
747
+ return Success([]) # No vitest configured, skip
748
+ except json.JSONDecodeError:
749
+ pass
750
+
751
+ try:
752
+ result = subprocess.run(
753
+ ["npx", "vitest", "run", "--reporter=json"],
754
+ cwd=project_path,
755
+ capture_output=True,
756
+ text=True,
757
+ timeout=300, # Tests may take longer
758
+ )
759
+
760
+ violations: list[TypeScriptViolation] = []
761
+
762
+ try:
763
+ vitest_output = json.loads(result.stdout)
764
+ for test_file in vitest_output.get("testResults", []):
765
+ file_path = test_file.get("name", "")
766
+ with contextlib.suppress(ValueError):
767
+ file_path = str(Path(file_path).relative_to(project_path))
768
+
769
+ for assertion in test_file.get("assertionResults", []):
770
+ if assertion.get("status") == "failed":
771
+ violations.append(
772
+ TypeScriptViolation(
773
+ file=file_path,
774
+ line=None,
775
+ column=None,
776
+ rule="test_failure",
777
+ message=assertion.get("title", "Test failed"),
778
+ severity="error",
779
+ source="vitest",
780
+ )
781
+ )
782
+ except json.JSONDecodeError:
783
+ # Non-JSON output usually means vitest itself failed
784
+ if result.returncode != 0:
785
+ return Failure(f"Vitest error: {result.stderr[:200]}")
786
+
787
+ return Success(violations)
788
+
789
+ except FileNotFoundError:
790
+ return Failure("npx not found - is Node.js installed?")
791
+ except subprocess.TimeoutExpired:
792
+ return Failure("vitest timed out after 300 seconds")
793
+
794
+
795
+ # @shell_complexity: Orchestrates multiple tools with graceful degradation
796
+ def run_typescript_guard(
797
+ project_path: Path,
798
+ *,
799
+ skip_tests: bool = False,
800
+ skip_enhanced: bool = False,
801
+ ) -> Result[TypeScriptGuardResult, str]:
802
+ """Run full TypeScript verification pipeline.
803
+
804
+ Orchestrates tsc, eslint, vitest, and @invar/* Node components
805
+ with graceful degradation if tools are unavailable.
806
+
807
+ Args:
808
+ project_path: Path to TypeScript project root.
809
+ skip_tests: If True, skip vitest execution.
810
+ skip_enhanced: If True, skip @invar/* Node component analysis.
811
+
812
+ Returns:
813
+ Result containing guard result or error message.
814
+ """
815
+ result = TypeScriptGuardResult(status="passed")
816
+
817
+ # Check tool availability
818
+ result.tsc_available = _check_tool_available("npx", ["tsc", "--version"])
819
+ result.eslint_available = _check_tool_available("npx", ["eslint", "--version"])
820
+ result.vitest_available = _check_tool_available("npx", ["vitest", "--version"])
821
+
822
+ all_violations: list[TypeScriptViolation] = []
823
+
824
+ # Run tsc
825
+ if result.tsc_available:
826
+ tsc_result = run_tsc(project_path)
827
+ match tsc_result:
828
+ case Success(violations):
829
+ all_violations.extend(violations)
830
+ case Failure(err):
831
+ # tsc failure is not fatal if it's just "no tsconfig"
832
+ if "No tsconfig.json" not in err:
833
+ pass # Log but continue
834
+
835
+ # Run eslint
836
+ if result.eslint_available:
837
+ eslint_result = run_eslint(project_path)
838
+ match eslint_result:
839
+ case Success(violations):
840
+ all_violations.extend(violations)
841
+ case Failure(_):
842
+ pass # ESLint errors are non-fatal
843
+
844
+ # Run vitest
845
+ if result.vitest_available and not skip_tests:
846
+ vitest_result = run_vitest(project_path)
847
+ match vitest_result:
848
+ case Success(violations):
849
+ all_violations.extend(violations)
850
+ case Failure(_):
851
+ pass # Test errors are non-fatal
852
+
853
+ # =========================================================================
854
+ # Enhanced analysis via @invar/* Node components (LX-06 Phase 2)
855
+ # =========================================================================
856
+ if not skip_enhanced:
857
+ enhanced = EnhancedAnalysis()
858
+
859
+ # Run ts-analyzer for contract coverage and blind spots
860
+ ts_analyzer_result = run_ts_analyzer(project_path)
861
+ match ts_analyzer_result:
862
+ case Success(data):
863
+ enhanced.ts_analyzer_available = True
864
+ coverage, quality, blind_spots = _parse_ts_analyzer_result(data)
865
+ enhanced.contract_coverage = coverage
866
+ enhanced.contract_quality = quality
867
+ enhanced.blind_spots = blind_spots
868
+ case Failure(_):
869
+ enhanced.ts_analyzer_available = False
870
+
871
+ # Run fc-runner for property-based testing
872
+ fc_runner_result = run_fc_runner(project_path)
873
+ match fc_runner_result:
874
+ case Success(data):
875
+ enhanced.fc_runner_available = True
876
+ passed, failures = _parse_fc_runner_result(data)
877
+ enhanced.property_tests_passed = passed
878
+ enhanced.property_test_failures = failures
879
+ case Failure(_):
880
+ enhanced.fc_runner_available = False
881
+
882
+ # Run quick-check for fast smoke testing
883
+ quick_check_result = run_quick_check(project_path)
884
+ match quick_check_result:
885
+ case Success(_):
886
+ enhanced.quick_check_available = True
887
+ case Failure(_):
888
+ enhanced.quick_check_available = False
889
+
890
+ result.enhanced = enhanced
891
+
892
+ # Aggregate results
893
+ result.violations = all_violations
894
+ result.error_count = sum(1 for v in all_violations if v.severity == "error")
895
+ result.warning_count = sum(1 for v in all_violations if v.severity == "warning")
896
+
897
+ if result.error_count > 0:
898
+ result.status = "failed"
899
+ elif not any([result.tsc_available, result.eslint_available]):
900
+ result.status = "skipped"
901
+
902
+ return Success(result)