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