invar-tools 1.7.1__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 (113) 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/template_helpers.py +32 -0
  16. invar/core/ts_parsers.py +286 -0
  17. invar/core/ts_sig_parser.py +307 -0
  18. invar/node_tools/MANIFEST +7 -0
  19. invar/node_tools/__init__.py +51 -0
  20. invar/node_tools/fc-runner/cli.js +77 -0
  21. invar/node_tools/quick-check/cli.js +28 -0
  22. invar/node_tools/ts-analyzer/cli.js +480 -0
  23. invar/shell/claude_hooks.py +35 -12
  24. invar/shell/commands/guard.py +36 -1
  25. invar/shell/commands/init.py +133 -7
  26. invar/shell/commands/perception.py +157 -33
  27. invar/shell/commands/skill.py +187 -0
  28. invar/shell/commands/template_sync.py +65 -13
  29. invar/shell/commands/uninstall.py +77 -12
  30. invar/shell/commands/update.py +6 -14
  31. invar/shell/contract_coverage.py +1 -0
  32. invar/shell/fs.py +66 -13
  33. invar/shell/pi_hooks.py +213 -0
  34. invar/shell/prove/guard_ts.py +899 -0
  35. invar/shell/skill_manager.py +353 -0
  36. invar/shell/template_engine.py +28 -4
  37. invar/shell/templates.py +4 -4
  38. invar/templates/claude-md/python/critical-rules.md +33 -0
  39. invar/templates/claude-md/python/quick-reference.md +24 -0
  40. invar/templates/claude-md/typescript/critical-rules.md +40 -0
  41. invar/templates/claude-md/typescript/quick-reference.md +24 -0
  42. invar/templates/claude-md/universal/check-in.md +25 -0
  43. invar/templates/claude-md/universal/skills.md +73 -0
  44. invar/templates/claude-md/universal/workflow.md +55 -0
  45. invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
  46. invar/templates/config/AGENT.md.jinja +256 -0
  47. invar/templates/config/CLAUDE.md.jinja +16 -209
  48. invar/templates/config/context.md.jinja +19 -0
  49. invar/templates/examples/{README.md → python/README.md} +2 -0
  50. invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
  51. invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
  52. invar/templates/examples/python/core_shell.py +227 -0
  53. invar/templates/examples/python/functional.py +613 -0
  54. invar/templates/examples/typescript/README.md +31 -0
  55. invar/templates/examples/typescript/contracts.ts +163 -0
  56. invar/templates/examples/typescript/core_shell.ts +374 -0
  57. invar/templates/examples/typescript/functional.ts +601 -0
  58. invar/templates/examples/typescript/workflow.md +95 -0
  59. invar/templates/hooks/PostToolUse.sh.jinja +10 -1
  60. invar/templates/hooks/PreToolUse.sh.jinja +38 -0
  61. invar/templates/hooks/Stop.sh.jinja +1 -1
  62. invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
  63. invar/templates/hooks/pi/invar.ts.jinja +82 -0
  64. invar/templates/manifest.toml +8 -6
  65. invar/templates/onboard/assessment.md.jinja +214 -0
  66. invar/templates/onboard/patterns/python.md +347 -0
  67. invar/templates/onboard/patterns/typescript.md +452 -0
  68. invar/templates/onboard/roadmap.md.jinja +168 -0
  69. invar/templates/protocol/INVAR.md.jinja +51 -0
  70. invar/templates/protocol/python/architecture-examples.md +41 -0
  71. invar/templates/protocol/python/contracts-syntax.md +56 -0
  72. invar/templates/protocol/python/markers.md +44 -0
  73. invar/templates/protocol/python/tools.md +24 -0
  74. invar/templates/protocol/python/troubleshooting.md +38 -0
  75. invar/templates/protocol/typescript/architecture-examples.md +52 -0
  76. invar/templates/protocol/typescript/contracts-syntax.md +73 -0
  77. invar/templates/protocol/typescript/markers.md +48 -0
  78. invar/templates/protocol/typescript/tools.md +65 -0
  79. invar/templates/protocol/typescript/troubleshooting.md +104 -0
  80. invar/templates/protocol/universal/architecture.md +36 -0
  81. invar/templates/protocol/universal/completion.md +14 -0
  82. invar/templates/protocol/universal/contracts-concept.md +37 -0
  83. invar/templates/protocol/universal/header.md +17 -0
  84. invar/templates/protocol/universal/session.md +17 -0
  85. invar/templates/protocol/universal/six-laws.md +10 -0
  86. invar/templates/protocol/universal/usbv.md +14 -0
  87. invar/templates/protocol/universal/visible-workflow.md +25 -0
  88. invar/templates/skills/develop/SKILL.md.jinja +98 -3
  89. invar/templates/skills/extensions/_registry.yaml +93 -0
  90. invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
  91. invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
  92. invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
  93. invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
  94. invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
  95. invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
  96. invar/templates/skills/extensions/security/SKILL.md +382 -0
  97. invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
  98. invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
  99. invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
  100. invar/templates/skills/investigate/SKILL.md.jinja +15 -0
  101. invar/templates/skills/propose/SKILL.md.jinja +33 -0
  102. invar/templates/skills/review/SKILL.md.jinja +346 -71
  103. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/METADATA +326 -19
  104. invar_tools-1.10.0.dist-info/RECORD +173 -0
  105. invar/templates/examples/core_shell.py +0 -127
  106. invar/templates/protocol/INVAR.md +0 -310
  107. invar_tools-1.7.1.dist-info/RECORD +0 -112
  108. /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
  109. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/WHEEL +0 -0
  110. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/entry_points.txt +0 -0
  111. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE +0 -0
  112. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE-GPL +0 -0
  113. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/licenses/NOTICE +0 -0
invar/core/rules.py CHANGED
@@ -1,10 +1,11 @@
1
+ # @invar:allow file_size: LX-10 added doctests, consider extraction later
1
2
  """Rule engine for Guard. Rules check FileInfo and produce Violations. No I/O."""
2
3
 
3
4
  from __future__ import annotations
4
5
 
5
6
  from collections.abc import Callable
6
7
 
7
- from deal import post
8
+ from deal import post, pre
8
9
 
9
10
  from invar.core.contracts import (
10
11
  check_empty_contracts,
@@ -14,9 +15,22 @@ from invar.core.contracts import (
14
15
  check_semantic_tautology,
15
16
  check_skip_without_reason,
16
17
  )
17
- from invar.core.entry_points import get_symbol_lines, has_allow_marker, is_entry_point
18
+ from invar.core.entry_points import (
19
+ extract_escape_hatches,
20
+ get_symbol_lines,
21
+ has_allow_marker,
22
+ is_entry_point,
23
+ )
18
24
  from invar.core.extraction import format_extraction_hint
19
- from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
25
+ from invar.core.models import (
26
+ FileInfo,
27
+ RuleConfig,
28
+ Severity,
29
+ SymbolKind,
30
+ Violation,
31
+ get_layer,
32
+ get_limits,
33
+ )
20
34
  from invar.core.must_use import check_must_use
21
35
  from invar.core.postcondition_scope import check_postcondition_scope
22
36
  from invar.core.purity import check_impure_calls, check_internal_imports
@@ -63,11 +77,33 @@ def _get_func_hint(file_info: FileInfo) -> str:
63
77
  return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
64
78
 
65
79
 
80
+ @pre(lambda file_info, rule: file_info is not None and len(rule) > 0)
81
+ @post(lambda result: isinstance(result, bool))
82
+ def _has_file_escape(file_info: FileInfo, rule: str) -> bool:
83
+ """Check if file has escape hatch for given rule.
84
+
85
+ Examples:
86
+ >>> info = FileInfo(path="test.py", lines=10, source="# @invar:allow file_size: reason")
87
+ >>> _has_file_escape(info, "file_size")
88
+ True
89
+ >>> _has_file_escape(info, "other_rule")
90
+ False
91
+ >>> # Edge: empty source returns False
92
+ >>> _has_file_escape(FileInfo(path="x.py", lines=1), "any")
93
+ False
94
+ """
95
+ if not file_info.source:
96
+ return False
97
+ escapes = extract_escape_hatches(file_info.source)
98
+ return any(r == rule for r, _, _ in escapes)
99
+
100
+
66
101
  @post(lambda result: all(v.rule in ("file_size", "file_size_warning") for v in result))
67
102
  def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
68
103
  """
69
104
  Check if file exceeds maximum line count or warning threshold.
70
105
 
106
+ LX-10: Uses layer-based limits (Core/Shell/Tests/Default).
71
107
  P18: Shows function groups in size warnings to help agents decide what to extract.
72
108
  P25: Shows extractable groups with dependencies for warnings.
73
109
 
@@ -75,30 +111,43 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
75
111
  >>> from invar.core.models import FileInfo, RuleConfig
76
112
  >>> check_file_size(FileInfo(path="ok.py", lines=100), RuleConfig())
77
113
  []
78
- >>> len(check_file_size(FileInfo(path="big.py", lines=600), RuleConfig()))
114
+ >>> # Default layer: 600 lines max, error at 650
115
+ >>> len(check_file_size(FileInfo(path="big.py", lines=650), RuleConfig()))
116
+ 1
117
+ >>> # Shell layer: 700 lines max, no error at 650
118
+ >>> vs = check_file_size(FileInfo(path="shell/cli.py", lines=550, is_shell=True), RuleConfig())
119
+ >>> any(v.rule == "file_size" for v in vs)
120
+ False
121
+ >>> # Core layer: 500 lines max (strict)
122
+ >>> len(check_file_size(FileInfo(path="core/calc.py", lines=550, is_core=True), RuleConfig()))
79
123
  1
80
- >>> # P8: Warning at 80% threshold (400 lines when max is 500)
81
- >>> vs = check_file_size(FileInfo(path="growing.py", lines=420), RuleConfig())
82
- >>> len(vs) == 1 and vs[0].rule == "file_size_warning"
83
- True
84
124
  """
125
+ # Check for escape hatch
126
+ if _has_file_escape(file_info, "file_size"):
127
+ return []
128
+
85
129
  violations: list[Violation] = []
86
130
  func_hint = _get_func_hint(file_info)
87
131
  extraction_hint = format_extraction_hint(file_info)
88
132
 
89
- if file_info.lines > config.max_file_lines:
133
+ # LX-10: Get layer-based limits
134
+ layer = get_layer(file_info)
135
+ limits = get_limits(layer)
136
+ max_lines = limits.max_file_lines
137
+
138
+ if file_info.lines > max_lines:
90
139
  violations.append(Violation(
91
140
  rule="file_size", severity=Severity.ERROR, file=file_info.path, line=None,
92
- message=f"File has {file_info.lines} lines (max: {config.max_file_lines})",
141
+ message=f"File has {file_info.lines} lines (max: {max_lines} for {layer.value})",
93
142
  suggestion=_build_size_suggestion("Split into smaller modules.", extraction_hint, func_hint),
94
143
  ))
95
144
  elif config.size_warning_threshold > 0:
96
- threshold = int(config.max_file_lines * config.size_warning_threshold)
145
+ threshold = int(max_lines * config.size_warning_threshold)
97
146
  if file_info.lines >= threshold:
98
- pct = int(file_info.lines / config.max_file_lines * 100)
147
+ pct = int(file_info.lines / max_lines * 100)
99
148
  violations.append(Violation(
100
149
  rule="file_size_warning", severity=Severity.WARNING, file=file_info.path, line=None,
101
- message=f"File has {file_info.lines} lines ({pct}% of {config.max_file_lines} limit)",
150
+ message=f"File has {file_info.lines} lines ({pct}% of {max_lines} limit)",
102
151
  suggestion=_build_size_suggestion("Consider splitting before reaching limit.", extraction_hint, func_hint),
103
152
  ))
104
153
  return violations
@@ -109,21 +158,38 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
109
158
  """
110
159
  Check if any function exceeds maximum line count.
111
160
 
161
+ LX-10: Uses layer-based limits (Core/Shell/Tests/Default).
112
162
  DX-22: Always uses code_lines (excluding docstring) and excludes doctest lines.
113
- These behaviors were previously optional but are now the default.
114
163
 
115
164
  Examples:
116
165
  >>> from invar.core.models import FileInfo, Symbol, SymbolKind
117
166
  >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=10)
118
167
  >>> info = FileInfo(path="test.py", lines=20, symbols=[sym])
119
- >>> cfg = RuleConfig(max_function_lines=50)
120
- >>> check_function_size(info, cfg)
168
+ >>> check_function_size(info, RuleConfig())
169
+ []
170
+ >>> # Shell layer: 100 lines max (more lenient)
171
+ >>> sym2 = Symbol(name="cli", kind=SymbolKind.FUNCTION, line=1, end_line=80)
172
+ >>> info2 = FileInfo(path="shell/cli.py", lines=100, symbols=[sym2], is_shell=True)
173
+ >>> check_function_size(info2, RuleConfig())
121
174
  []
175
+ >>> # Core layer: 50 lines max (strict)
176
+ >>> sym3 = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=60)
177
+ >>> info3 = FileInfo(path="core/calc.py", lines=100, symbols=[sym3], is_core=True)
178
+ >>> len(check_function_size(info3, RuleConfig()))
179
+ 1
122
180
  """
123
181
  violations: list[Violation] = []
124
182
 
183
+ # LX-10: Get layer-based limits
184
+ layer = get_layer(file_info)
185
+ limits = get_limits(layer)
186
+ max_func_lines = limits.max_function_lines
187
+
125
188
  for symbol in file_info.symbols:
126
189
  if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
190
+ # LX-10: Check for escape hatch on individual functions
191
+ if has_allow_marker(symbol, file_info.source, "function_size"):
192
+ continue
127
193
  total_lines = symbol.end_line - symbol.line + 1
128
194
  # DX-22: Always use code_lines when available (excluding docstring)
129
195
  if symbol.code_lines is not None:
@@ -137,14 +203,14 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
137
203
  func_lines -= symbol.doctest_lines
138
204
  line_type = f"{line_type} (excl. doctest)"
139
205
 
140
- if func_lines > config.max_function_lines:
206
+ if func_lines > max_func_lines:
141
207
  violations.append(
142
208
  Violation(
143
209
  rule="function_size",
144
210
  severity=Severity.WARNING,
145
211
  file=file_info.path,
146
212
  line=symbol.line,
147
- message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {config.max_function_lines})",
213
+ message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {max_func_lines} for {layer.value})",
148
214
  suggestion="Extract helper functions",
149
215
  )
150
216
  )
@@ -21,6 +21,10 @@ if TYPE_CHECKING:
21
21
  # =============================================================================
22
22
 
23
23
 
24
+ # LX-05: Valid language values for template rendering
25
+ VALID_LANGUAGES = frozenset({"python", "typescript"})
26
+
27
+
24
28
  @dataclass
25
29
  class SyncConfig:
26
30
  """Configuration for template sync operation.
@@ -29,12 +33,16 @@ class SyncConfig:
29
33
  >>> config = SyncConfig()
30
34
  >>> config.syntax
31
35
  'cli'
36
+ >>> config.language
37
+ 'python'
32
38
  >>> config.inject_project_additions
33
39
  False
34
40
 
35
- >>> config = SyncConfig(syntax="mcp", force=True)
41
+ >>> config = SyncConfig(syntax="mcp", language="typescript", force=True)
36
42
  >>> config.syntax
37
43
  'mcp'
44
+ >>> config.language
45
+ 'typescript'
38
46
  >>> config.force
39
47
  True
40
48
 
@@ -44,12 +52,30 @@ class SyncConfig:
44
52
  """
45
53
 
46
54
  syntax: str = "cli" # "cli" or "mcp"
55
+ language: str = "python" # "python" or "typescript" (LX-05)
47
56
  inject_project_additions: bool = False
48
57
  force: bool = False
49
58
  check: bool = False # Preview only
50
59
  reset: bool = False # Discard user content
51
60
  skip_patterns: list[str] = field(default_factory=list) # Glob patterns to skip
52
61
 
62
+ @post(lambda result: result is None) # Void method, validates or raises
63
+ def __post_init__(self) -> None:
64
+ """Validate configuration values.
65
+
66
+ Examples:
67
+ >>> SyncConfig(language="python") # Valid
68
+ SyncConfig(syntax='cli', language='python', inject_project_additions=False, force=False, check=False, reset=False, skip_patterns=[])
69
+
70
+ >>> SyncConfig(language="rust") # doctest: +IGNORE_EXCEPTION_DETAIL
71
+ Traceback (most recent call last):
72
+ ValueError: Invalid language 'rust'. Must be one of: python, typescript
73
+ """
74
+ if self.language not in VALID_LANGUAGES:
75
+ valid = ", ".join(sorted(VALID_LANGUAGES))
76
+ msg = f"Invalid language '{self.language}'. Must be one of: {valid}"
77
+ raise ValueError(msg)
78
+
53
79
 
54
80
  @dataclass
55
81
  class SyncReport:
@@ -0,0 +1,32 @@
1
+ """
2
+ Template transformation helpers.
3
+
4
+ Core module: pure logic for template content transformations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from deal import post
10
+
11
+
12
+ @post(lambda result: "`" not in result or "\\`" in result)
13
+ @post(lambda result: "${" not in result or "\\${" in result)
14
+ def escape_for_js_template(content: str) -> str:
15
+ """
16
+ Escape content for JavaScript template literal.
17
+
18
+ Escapes backticks and ${} sequences that would be interpreted
19
+ by JavaScript template literals.
20
+
21
+ >>> escape_for_js_template("Hello `world`")
22
+ 'Hello \\\\`world\\\\`'
23
+ >>> escape_for_js_template("Value: ${x}")
24
+ 'Value: \\\\${x}'
25
+ >>> escape_for_js_template("Normal text")
26
+ 'Normal text'
27
+ """
28
+ # Escape backticks
29
+ content = content.replace("`", "\\`")
30
+ # Escape ${} template expressions
31
+ content = content.replace("${", "\\${")
32
+ return content
@@ -0,0 +1,286 @@
1
+ """TypeScript tool output parsers (pure logic).
2
+
3
+ This module contains pure parsing functions for TypeScript tool outputs.
4
+ Part of LX-06 TypeScript tooling support.
5
+
6
+ All functions are pure - they transform strings to structured data
7
+ without any I/O operations.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Literal
16
+
17
+ from deal import post, pre
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class TSViolation:
22
+ """A single TypeScript verification issue (immutable)."""
23
+
24
+ file: str
25
+ line: int | None
26
+ column: int | None
27
+ rule: str
28
+ message: str
29
+ severity: Literal["error", "warning", "info"]
30
+ source: Literal["tsc", "eslint", "vitest"]
31
+
32
+
33
+ @pre(lambda line: "\n" not in line) # Single line only
34
+ @post(lambda result: result is None or result.source == "tsc")
35
+ def parse_tsc_line(line: str) -> TSViolation | None:
36
+ """Parse a single tsc output line into a violation.
37
+
38
+ Args:
39
+ line: Raw tsc output line.
40
+
41
+ Returns:
42
+ Parsed violation or None if parsing fails.
43
+
44
+ Examples:
45
+ >>> v = parse_tsc_line("src/foo.ts(10,5): error TS2322: Type mismatch")
46
+ >>> v.file if v else None
47
+ 'src/foo.ts'
48
+ >>> v.line if v else None
49
+ 10
50
+ >>> v.rule if v else None
51
+ 'TS2322'
52
+ >>> v.severity if v else None
53
+ 'error'
54
+
55
+ >>> v = parse_tsc_line("src/bar.ts(1,1): warning TS6133: Unused var")
56
+ >>> v.severity if v else None
57
+ 'warning'
58
+
59
+ >>> parse_tsc_line("random text") is None
60
+ True
61
+
62
+ >>> parse_tsc_line("") is None
63
+ True
64
+ """
65
+ # Pattern: file(line,col): severity TSxxxx: message
66
+ pattern = r"^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$"
67
+ match = re.match(pattern, line)
68
+
69
+ if not match:
70
+ return None
71
+
72
+ file_path, line_num, col, severity, code, message = match.groups()
73
+
74
+ return TSViolation(
75
+ file=file_path,
76
+ line=int(line_num),
77
+ column=int(col),
78
+ rule=code,
79
+ message=message,
80
+ severity="error" if severity == "error" else "warning",
81
+ source="tsc",
82
+ )
83
+
84
+
85
+ @pre(lambda output: output is not None) # Accepts any string including empty
86
+ @post(lambda result: all(v.source == "tsc" for v in result))
87
+ def parse_tsc_output(output: str) -> list[TSViolation]:
88
+ """Parse full tsc output into violations list.
89
+
90
+ Args:
91
+ output: Full tsc stdout output.
92
+
93
+ Returns:
94
+ List of parsed violations.
95
+
96
+ Examples:
97
+ >>> output = '''src/a.ts(1,1): error TS2322: Type A
98
+ ... src/b.ts(2,3): warning TS6133: Unused
99
+ ... Some other line'''
100
+ >>> violations = parse_tsc_output(output)
101
+ >>> len(violations)
102
+ 2
103
+ >>> violations[0].file
104
+ 'src/a.ts'
105
+
106
+ >>> parse_tsc_output("")
107
+ []
108
+ """
109
+ violations: list[TSViolation] = []
110
+ for line in output.splitlines():
111
+ if ": error TS" in line or ": warning TS" in line:
112
+ violation = parse_tsc_line(line)
113
+ if violation:
114
+ violations.append(violation)
115
+ return violations
116
+
117
+
118
+ @pre(lambda output, base_path="": output is not None) # Accepts any string including empty
119
+ @post(lambda result: all(v.source == "eslint" for v in result))
120
+ def parse_eslint_json(output: str, base_path: str = "") -> list[TSViolation]:
121
+ """Parse ESLint JSON output into violations list.
122
+
123
+ Args:
124
+ output: ESLint JSON stdout output.
125
+ base_path: Base path to make file paths relative (optional).
126
+
127
+ Returns:
128
+ List of parsed violations.
129
+
130
+ Examples:
131
+ >>> output = '''[{
132
+ ... "filePath": "/project/src/foo.ts",
133
+ ... "messages": [
134
+ ... {"line": 10, "column": 5, "severity": 2,
135
+ ... "ruleId": "no-unused-vars", "message": "Unused var"}
136
+ ... ]
137
+ ... }]'''
138
+ >>> violations = parse_eslint_json(output, "/project")
139
+ >>> len(violations)
140
+ 1
141
+ >>> violations[0].rule
142
+ 'no-unused-vars'
143
+ >>> violations[0].severity
144
+ 'error'
145
+
146
+ >>> parse_eslint_json("invalid json")
147
+ []
148
+
149
+ >>> parse_eslint_json("")
150
+ []
151
+ """
152
+ violations: list[TSViolation] = []
153
+
154
+ try:
155
+ eslint_output = json.loads(output)
156
+ except json.JSONDecodeError:
157
+ return violations
158
+
159
+ # ESLint output must be a list
160
+ if not isinstance(eslint_output, list):
161
+ return violations
162
+
163
+ for file_result in eslint_output:
164
+ # Each file result must be a dict
165
+ if not isinstance(file_result, dict):
166
+ continue
167
+
168
+ file_path = file_result.get("filePath", "")
169
+
170
+ # Make path relative if base_path provided
171
+ if base_path and isinstance(file_path, str) and file_path.startswith(base_path):
172
+ file_path = file_path[len(base_path) :].lstrip("/\\")
173
+
174
+ messages = file_result.get("messages", [])
175
+ if not isinstance(messages, list):
176
+ continue
177
+
178
+ for msg in messages:
179
+ if not isinstance(msg, dict):
180
+ continue
181
+ severity_num = msg.get("severity", 1)
182
+ violations.append(
183
+ TSViolation(
184
+ file=str(file_path),
185
+ line=msg.get("line"),
186
+ column=msg.get("column"),
187
+ rule=msg.get("ruleId") or "unknown",
188
+ message=str(msg.get("message", "")),
189
+ severity="error" if severity_num == 2 else "warning",
190
+ source="eslint",
191
+ )
192
+ )
193
+
194
+ return violations
195
+
196
+
197
+ @pre(lambda output, base_path="": output is not None) # Accepts any string including empty
198
+ @post(lambda result: all(v.source == "vitest" for v in result))
199
+ def parse_vitest_json(output: str, base_path: str = "") -> list[TSViolation]:
200
+ """Parse Vitest JSON output into violations list.
201
+
202
+ Args:
203
+ output: Vitest JSON stdout output.
204
+ base_path: Base path to make file paths relative (optional).
205
+
206
+ Returns:
207
+ List of violations (test failures only).
208
+
209
+ Examples:
210
+ >>> output = '''{
211
+ ... "testResults": [{
212
+ ... "name": "/project/tests/foo.test.ts",
213
+ ... "assertionResults": [
214
+ ... {"status": "failed", "title": "should work"}
215
+ ... ]
216
+ ... }]
217
+ ... }'''
218
+ >>> violations = parse_vitest_json(output, "/project")
219
+ >>> len(violations)
220
+ 1
221
+ >>> violations[0].rule
222
+ 'test_failure'
223
+
224
+ >>> parse_vitest_json("invalid json")
225
+ []
226
+
227
+ >>> output_pass = '{"testResults": [{"name": "x", "assertionResults": [{"status": "passed", "title": "ok"}]}]}'
228
+ >>> parse_vitest_json(output_pass)
229
+ []
230
+ """
231
+ violations: list[TSViolation] = []
232
+
233
+ try:
234
+ vitest_output = json.loads(output)
235
+ except json.JSONDecodeError:
236
+ return violations
237
+
238
+ # Vitest output must be a dict with testResults
239
+ if not isinstance(vitest_output, dict):
240
+ return violations
241
+
242
+ test_results = vitest_output.get("testResults", [])
243
+ if not isinstance(test_results, list):
244
+ return violations
245
+
246
+ for test_file in test_results:
247
+ # Each test file must be a dict
248
+ if not isinstance(test_file, dict):
249
+ continue
250
+
251
+ file_path = test_file.get("name", "")
252
+
253
+ # Make path relative if base_path provided
254
+ if base_path and isinstance(file_path, str) and file_path.startswith(base_path):
255
+ file_path = file_path[len(base_path) :].lstrip("/\\")
256
+
257
+ assertion_results = test_file.get("assertionResults", [])
258
+ if not isinstance(assertion_results, list):
259
+ continue
260
+
261
+ for assertion in assertion_results:
262
+ if not isinstance(assertion, dict):
263
+ continue
264
+ if assertion.get("status") == "failed":
265
+ # Extract detailed failure message from failureMessages if available
266
+ title = str(assertion.get("title", "Test failed"))
267
+ failure_msgs = assertion.get("failureMessages", [])
268
+ if isinstance(failure_msgs, list) and failure_msgs:
269
+ # Use first failure message, truncate if too long
270
+ detail = str(failure_msgs[0])[:200]
271
+ message = f"{title}: {detail}"
272
+ else:
273
+ message = title
274
+ violations.append(
275
+ TSViolation(
276
+ file=str(file_path),
277
+ line=None,
278
+ column=None,
279
+ rule="test_failure",
280
+ message=message,
281
+ severity="error",
282
+ source="vitest",
283
+ )
284
+ )
285
+
286
+ return violations