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.
- invar/__init__.py +8 -0
- invar/core/language.py +88 -0
- invar/core/models.py +106 -0
- invar/core/patterns/detector.py +6 -1
- invar/core/patterns/p0_exhaustive.py +15 -3
- invar/core/patterns/p0_literal.py +15 -3
- invar/core/patterns/p0_newtype.py +15 -3
- invar/core/patterns/p0_nonempty.py +15 -3
- invar/core/patterns/p0_validation.py +15 -3
- invar/core/patterns/registry.py +5 -1
- invar/core/patterns/types.py +5 -1
- invar/core/property_gen.py +4 -0
- invar/core/rules.py +84 -18
- invar/core/sync_helpers.py +27 -1
- invar/core/ts_parsers.py +286 -0
- invar/core/ts_sig_parser.py +307 -0
- invar/node_tools/MANIFEST +7 -0
- invar/node_tools/__init__.py +51 -0
- invar/node_tools/fc-runner/cli.js +77 -0
- invar/node_tools/quick-check/cli.js +28 -0
- invar/node_tools/ts-analyzer/cli.js +480 -0
- invar/shell/claude_hooks.py +35 -12
- invar/shell/commands/guard.py +36 -1
- invar/shell/commands/init.py +82 -3
- invar/shell/commands/perception.py +157 -33
- invar/shell/commands/skill.py +187 -0
- invar/shell/commands/template_sync.py +65 -13
- invar/shell/commands/uninstall.py +60 -12
- invar/shell/commands/update.py +6 -14
- invar/shell/contract_coverage.py +1 -0
- invar/shell/fs.py +66 -13
- invar/shell/pi_hooks.py +6 -0
- invar/shell/prove/guard_ts.py +899 -0
- invar/shell/skill_manager.py +353 -0
- invar/shell/template_engine.py +28 -4
- invar/shell/templates.py +4 -4
- invar/templates/claude-md/python/critical-rules.md +33 -0
- invar/templates/claude-md/python/quick-reference.md +24 -0
- invar/templates/claude-md/typescript/critical-rules.md +40 -0
- invar/templates/claude-md/typescript/quick-reference.md +24 -0
- invar/templates/claude-md/universal/check-in.md +25 -0
- invar/templates/claude-md/universal/skills.md +73 -0
- invar/templates/claude-md/universal/workflow.md +55 -0
- invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
- invar/templates/config/AGENT.md.jinja +58 -0
- invar/templates/config/CLAUDE.md.jinja +16 -209
- invar/templates/config/context.md.jinja +19 -0
- invar/templates/examples/{README.md → python/README.md} +2 -0
- invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
- invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
- invar/templates/examples/python/core_shell.py +227 -0
- invar/templates/examples/python/functional.py +613 -0
- invar/templates/examples/typescript/README.md +31 -0
- invar/templates/examples/typescript/contracts.ts +163 -0
- invar/templates/examples/typescript/core_shell.ts +374 -0
- invar/templates/examples/typescript/functional.ts +601 -0
- invar/templates/examples/typescript/workflow.md +95 -0
- invar/templates/hooks/PostToolUse.sh.jinja +10 -1
- invar/templates/hooks/PreToolUse.sh.jinja +38 -0
- invar/templates/hooks/Stop.sh.jinja +1 -1
- invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
- invar/templates/hooks/pi/invar.ts.jinja +9 -0
- invar/templates/manifest.toml +7 -6
- invar/templates/onboard/assessment.md.jinja +214 -0
- invar/templates/onboard/patterns/python.md +347 -0
- invar/templates/onboard/patterns/typescript.md +452 -0
- invar/templates/onboard/roadmap.md.jinja +168 -0
- invar/templates/protocol/INVAR.md.jinja +51 -0
- invar/templates/protocol/python/architecture-examples.md +41 -0
- invar/templates/protocol/python/contracts-syntax.md +56 -0
- invar/templates/protocol/python/markers.md +44 -0
- invar/templates/protocol/python/tools.md +24 -0
- invar/templates/protocol/python/troubleshooting.md +38 -0
- invar/templates/protocol/typescript/architecture-examples.md +52 -0
- invar/templates/protocol/typescript/contracts-syntax.md +73 -0
- invar/templates/protocol/typescript/markers.md +48 -0
- invar/templates/protocol/typescript/tools.md +65 -0
- invar/templates/protocol/typescript/troubleshooting.md +104 -0
- invar/templates/protocol/universal/architecture.md +36 -0
- invar/templates/protocol/universal/completion.md +14 -0
- invar/templates/protocol/universal/contracts-concept.md +37 -0
- invar/templates/protocol/universal/header.md +17 -0
- invar/templates/protocol/universal/session.md +17 -0
- invar/templates/protocol/universal/six-laws.md +10 -0
- invar/templates/protocol/universal/usbv.md +14 -0
- invar/templates/protocol/universal/visible-workflow.md +25 -0
- invar/templates/skills/develop/SKILL.md.jinja +39 -3
- invar/templates/skills/extensions/_registry.yaml +93 -0
- invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
- invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
- invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
- invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
- invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
- invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
- invar/templates/skills/extensions/security/SKILL.md +382 -0
- invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
- invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
- invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
- invar/templates/skills/review/SKILL.md.jinja +331 -71
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/METADATA +304 -12
- invar_tools-1.10.0.dist-info/RECORD +173 -0
- invar/templates/examples/core_shell.py +0 -127
- invar/templates/protocol/INVAR.md +0 -310
- invar_tools-1.8.0.dist-info/RECORD +0 -116
- /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.8.0.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
|
|
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
|
|
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
|
-
>>>
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
145
|
+
threshold = int(max_lines * config.size_warning_threshold)
|
|
97
146
|
if file_info.lines >= threshold:
|
|
98
|
-
pct = int(file_info.lines /
|
|
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 {
|
|
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
|
-
>>>
|
|
120
|
-
|
|
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 >
|
|
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: {
|
|
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
|
)
|
invar/core/sync_helpers.py
CHANGED
|
@@ -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:
|
invar/core/ts_parsers.py
ADDED
|
@@ -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
|