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.
- invar/__init__.py +8 -0
- invar/core/doc_edit.py +187 -0
- invar/core/doc_parser.py +563 -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 +310 -0
- invar/mcp/handlers.py +408 -0
- invar/mcp/server.py +288 -143
- 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/doc.py +409 -0
- invar/shell/commands/guard.py +41 -1
- invar/shell/commands/init.py +154 -16
- 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/doc_tools.py +459 -0
- invar/shell/fs.py +67 -13
- invar/shell/pi_hooks.py +6 -0
- invar/shell/prove/crosshair.py +3 -0
- invar/shell/prove/guard_ts.py +902 -0
- invar/shell/skill_manager.py +355 -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 +85 -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 +220 -248
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +336 -12
- invar_tools-1.11.0.dist-info/RECORD +178 -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.11.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/NOTICE +0 -0
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
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""TypeScript Signature Extraction (Core).
|
|
2
|
+
|
|
3
|
+
Pure logic for extracting function/class signatures from TypeScript source.
|
|
4
|
+
Part of LX-06 TypeScript tooling support.
|
|
5
|
+
|
|
6
|
+
Note: This is a regex-based MVP. Phase 2 can upgrade to tree-sitter for
|
|
7
|
+
more robust parsing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from deal import post, pre
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class TSSymbol:
|
|
21
|
+
"""Represents a TypeScript symbol (function, class, interface, type)."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
kind: Literal["function", "class", "interface", "type", "const", "method"]
|
|
25
|
+
signature: str
|
|
26
|
+
line: int
|
|
27
|
+
docstring: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Regex patterns for TypeScript constructs
|
|
31
|
+
# Note: These are simplified patterns suitable for common cases.
|
|
32
|
+
# Known limitation: Multiline parameter lists are truncated to first line.
|
|
33
|
+
# Phase 2 can upgrade to tree-sitter for full multiline support.
|
|
34
|
+
_FUNCTION_PATTERN = re.compile(
|
|
35
|
+
r"^\s*(?:@\w+(?:\([^)]*\))?\s*\n\s*)*" # Optional decorators
|
|
36
|
+
r"(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*"
|
|
37
|
+
r"(<[^>]*>)?" # Optional generics
|
|
38
|
+
r"\(([^)]*)\)" # Parameters
|
|
39
|
+
r"(?:\s*:\s*([^\n{]+))?" # Optional return type
|
|
40
|
+
r"\s*\{",
|
|
41
|
+
re.MULTILINE,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_ARROW_FUNCTION_PATTERN = re.compile(
|
|
45
|
+
r"^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*"
|
|
46
|
+
r"(?::\s*[^=]+)?\s*=\s*"
|
|
47
|
+
r"(?:async\s+)?\([^)]*\)\s*"
|
|
48
|
+
r"(?::\s*[^\n=>]+)?\s*=>",
|
|
49
|
+
re.MULTILINE,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_CLASS_PATTERN = re.compile(
|
|
53
|
+
r"^\s*(?:@\w+(?:\([^)]*\))?\s*\n\s*)*" # Optional decorators
|
|
54
|
+
r"(?:export\s+)?(?:abstract\s+)?class\s+(\w+)"
|
|
55
|
+
r"(?:<[^{]*>)?" # Optional generics (allow nested <> by stopping at {)
|
|
56
|
+
r"(?:\s+extends\s+[^\s{]+)?" # Optional extends
|
|
57
|
+
r"(?:\s+implements\s+[^\s{]+)?" # Optional implements
|
|
58
|
+
r"\s*\{",
|
|
59
|
+
re.MULTILINE,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_INTERFACE_PATTERN = re.compile(
|
|
63
|
+
r"^\s*(?:export\s+)?interface\s+(\w+)"
|
|
64
|
+
r"(?:<[^{]*>)?" # Optional generics (allow nested <> by stopping at {)
|
|
65
|
+
r"(?:\s+extends\s+[^\s{]+)?" # Optional extends
|
|
66
|
+
r"\s*\{",
|
|
67
|
+
re.MULTILINE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_TYPE_ALIAS_PATTERN = re.compile(
|
|
71
|
+
r"^\s*(?:export\s+)?type\s+(\w+)"
|
|
72
|
+
r"(?:<[^=]*>)?" # Optional generics (allow nested <> by stopping at =)
|
|
73
|
+
r"\s*=",
|
|
74
|
+
re.MULTILINE,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
_JSDOC_PATTERN = re.compile(
|
|
78
|
+
r"/\*\*\s*(.*?)\s*\*/",
|
|
79
|
+
re.DOTALL,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# @invar:allow function_size: Regex extraction inherently repetitive per TS construct type
|
|
84
|
+
# @invar:allow redundant_type_contract: Defense-in-depth for dynamic callers
|
|
85
|
+
@pre(lambda source: isinstance(source, str) and len(source) < 10_000_000) # ~10MB DoS limit
|
|
86
|
+
@post(lambda result: all(s.line > 0 and s.name for s in result)) # Valid line numbers and names
|
|
87
|
+
def extract_ts_signatures(source: str) -> list[TSSymbol]:
|
|
88
|
+
"""Extract TypeScript symbols from source code.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
source: TypeScript source code.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
List of TSSymbol objects representing functions, classes, etc.
|
|
95
|
+
|
|
96
|
+
>>> code = '''
|
|
97
|
+
... function greet(name: string): string {
|
|
98
|
+
... return `Hello, ${name}`;
|
|
99
|
+
... }
|
|
100
|
+
... '''
|
|
101
|
+
>>> symbols = extract_ts_signatures(code)
|
|
102
|
+
>>> len(symbols)
|
|
103
|
+
1
|
|
104
|
+
>>> symbols[0].name
|
|
105
|
+
'greet'
|
|
106
|
+
>>> symbols[0].kind
|
|
107
|
+
'function'
|
|
108
|
+
|
|
109
|
+
>>> code2 = '''
|
|
110
|
+
... export class User {
|
|
111
|
+
... constructor(public name: string) {}
|
|
112
|
+
... }
|
|
113
|
+
... '''
|
|
114
|
+
>>> symbols2 = extract_ts_signatures(code2)
|
|
115
|
+
>>> symbols2[0].name
|
|
116
|
+
'User'
|
|
117
|
+
>>> symbols2[0].kind
|
|
118
|
+
'class'
|
|
119
|
+
"""
|
|
120
|
+
# Early return for empty source (CrossHair-friendly)
|
|
121
|
+
if not source:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
symbols: list[TSSymbol] = []
|
|
125
|
+
lines = source.split("\n")
|
|
126
|
+
|
|
127
|
+
# Find JSDoc comments for association
|
|
128
|
+
jsdoc_positions: dict[int, str] = {}
|
|
129
|
+
for match in _JSDOC_PATTERN.finditer(source):
|
|
130
|
+
# Find line number of end of JSDoc
|
|
131
|
+
end_pos = match.end()
|
|
132
|
+
line_num = source[:end_pos].count("\n")
|
|
133
|
+
jsdoc_positions[line_num] = match.group(1).strip()
|
|
134
|
+
|
|
135
|
+
def get_jsdoc(line: int) -> str | None:
|
|
136
|
+
"""Get JSDoc for a symbol at given line."""
|
|
137
|
+
# Check previous lines for JSDoc
|
|
138
|
+
for i in range(max(0, line - 5), line):
|
|
139
|
+
if i in jsdoc_positions:
|
|
140
|
+
return jsdoc_positions[i]
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def get_line_number(pos: int) -> int:
|
|
144
|
+
"""Convert character position to line number (1-indexed)."""
|
|
145
|
+
return source[:pos].count("\n") + 1
|
|
146
|
+
|
|
147
|
+
# Extract functions
|
|
148
|
+
for match in _FUNCTION_PATTERN.finditer(source):
|
|
149
|
+
name = match.group(1)
|
|
150
|
+
# Find actual function line (skip decorators)
|
|
151
|
+
match_text = match.group(0)
|
|
152
|
+
func_keyword_pos = match_text.find("function ")
|
|
153
|
+
actual_start = match.start() + func_keyword_pos if func_keyword_pos >= 0 else match.start()
|
|
154
|
+
line = get_line_number(actual_start)
|
|
155
|
+
# Use line content for signature (handles complex types better than regex groups)
|
|
156
|
+
line_content = lines[line - 1].strip() if line <= len(lines) else ""
|
|
157
|
+
signature = line_content.rstrip("{").rstrip()
|
|
158
|
+
symbols.append(
|
|
159
|
+
TSSymbol(
|
|
160
|
+
name=name,
|
|
161
|
+
kind="function",
|
|
162
|
+
signature=signature,
|
|
163
|
+
line=line,
|
|
164
|
+
docstring=get_jsdoc(line - 1),
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Extract arrow functions (const declarations)
|
|
169
|
+
for match in _ARROW_FUNCTION_PATTERN.finditer(source):
|
|
170
|
+
name = match.group(1)
|
|
171
|
+
line = get_line_number(match.start())
|
|
172
|
+
# For arrow functions, extract the full line as signature approximation
|
|
173
|
+
line_content = lines[line - 1].strip() if line <= len(lines) else ""
|
|
174
|
+
signature = line_content.rstrip("{").rstrip()
|
|
175
|
+
symbols.append(
|
|
176
|
+
TSSymbol(
|
|
177
|
+
name=name,
|
|
178
|
+
kind="const",
|
|
179
|
+
signature=signature,
|
|
180
|
+
line=line,
|
|
181
|
+
docstring=get_jsdoc(line - 1),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Extract classes
|
|
186
|
+
for match in _CLASS_PATTERN.finditer(source):
|
|
187
|
+
name = match.group(1)
|
|
188
|
+
# Find actual class line (skip decorators)
|
|
189
|
+
match_text = match.group(0)
|
|
190
|
+
class_keyword_pos = match_text.find("class ")
|
|
191
|
+
actual_start = match.start() + class_keyword_pos if class_keyword_pos >= 0 else match.start()
|
|
192
|
+
line = get_line_number(actual_start)
|
|
193
|
+
line_content = lines[line - 1].strip() if line <= len(lines) else ""
|
|
194
|
+
signature = line_content.rstrip("{").rstrip()
|
|
195
|
+
symbols.append(
|
|
196
|
+
TSSymbol(
|
|
197
|
+
name=name,
|
|
198
|
+
kind="class",
|
|
199
|
+
signature=signature,
|
|
200
|
+
line=line,
|
|
201
|
+
docstring=get_jsdoc(line - 1),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Extract interfaces
|
|
206
|
+
for match in _INTERFACE_PATTERN.finditer(source):
|
|
207
|
+
name = match.group(1)
|
|
208
|
+
line = get_line_number(match.start())
|
|
209
|
+
line_content = lines[line - 1].strip() if line <= len(lines) else ""
|
|
210
|
+
signature = line_content.rstrip("{").rstrip()
|
|
211
|
+
symbols.append(
|
|
212
|
+
TSSymbol(
|
|
213
|
+
name=name,
|
|
214
|
+
kind="interface",
|
|
215
|
+
signature=signature,
|
|
216
|
+
line=line,
|
|
217
|
+
docstring=get_jsdoc(line - 1),
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Extract type aliases
|
|
222
|
+
for match in _TYPE_ALIAS_PATTERN.finditer(source):
|
|
223
|
+
name = match.group(1)
|
|
224
|
+
line = get_line_number(match.start())
|
|
225
|
+
line_content = lines[line - 1].strip() if line <= len(lines) else ""
|
|
226
|
+
# Include the full type definition
|
|
227
|
+
signature = line_content.rstrip(";").strip()
|
|
228
|
+
symbols.append(
|
|
229
|
+
TSSymbol(
|
|
230
|
+
name=name,
|
|
231
|
+
kind="type",
|
|
232
|
+
signature=signature,
|
|
233
|
+
line=line,
|
|
234
|
+
docstring=get_jsdoc(line - 1),
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Sort by line number
|
|
239
|
+
symbols.sort(key=lambda s: s.line)
|
|
240
|
+
|
|
241
|
+
return symbols
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pre(lambda symbols, file_path="": all(s.line > 0 for s in symbols)) # All symbols have valid line numbers
|
|
245
|
+
@post(lambda result: "file" in result and "symbols" in result)
|
|
246
|
+
def format_ts_signatures_json(
|
|
247
|
+
symbols: list[TSSymbol], file_path: str = ""
|
|
248
|
+
) -> dict:
|
|
249
|
+
"""Format TypeScript symbols as JSON output.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
symbols: List of TSSymbol objects.
|
|
253
|
+
file_path: Source file path.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
JSON-serializable dictionary.
|
|
257
|
+
|
|
258
|
+
>>> symbols = [TSSymbol("foo", "function", "function foo(): void", 1)]
|
|
259
|
+
>>> result = format_ts_signatures_json(symbols, "test.ts")
|
|
260
|
+
>>> result["file"]
|
|
261
|
+
'test.ts'
|
|
262
|
+
>>> len(result["symbols"])
|
|
263
|
+
1
|
|
264
|
+
"""
|
|
265
|
+
return {
|
|
266
|
+
"file": file_path,
|
|
267
|
+
"symbols": [
|
|
268
|
+
{
|
|
269
|
+
"name": s.name,
|
|
270
|
+
"kind": s.kind,
|
|
271
|
+
"signature": s.signature,
|
|
272
|
+
"line": s.line,
|
|
273
|
+
"docstring": s.docstring,
|
|
274
|
+
}
|
|
275
|
+
for s in symbols
|
|
276
|
+
],
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@pre(lambda symbols, file_path="": all(s.line > 0 for s in symbols)) # All symbols have valid line numbers
|
|
281
|
+
@post(lambda result: len(result) > 0) # Always produces output (at least header)
|
|
282
|
+
def format_ts_signatures_text(
|
|
283
|
+
symbols: list[TSSymbol], file_path: str = ""
|
|
284
|
+
) -> str:
|
|
285
|
+
"""Format TypeScript symbols as human-readable text.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
symbols: List of TSSymbol objects.
|
|
289
|
+
file_path: Source file path.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Formatted text output.
|
|
293
|
+
|
|
294
|
+
>>> symbols = [TSSymbol("foo", "function", "function foo(): void", 1)]
|
|
295
|
+
>>> text = format_ts_signatures_text(symbols, "test.ts")
|
|
296
|
+
>>> "foo" in text
|
|
297
|
+
True
|
|
298
|
+
"""
|
|
299
|
+
lines = [f"# {file_path}" if file_path else "# TypeScript Signatures", ""]
|
|
300
|
+
|
|
301
|
+
for symbol in symbols:
|
|
302
|
+
lines.append(f"[{symbol.kind}] {symbol.name} (line {symbol.line})")
|
|
303
|
+
lines.append(f" {symbol.signature}")
|
|
304
|
+
if symbol.docstring:
|
|
305
|
+
# Truncate long docstrings
|
|
306
|
+
doc = symbol.docstring[:100] + "..." if len(symbol.docstring) > 100 else symbol.docstring
|
|
307
|
+
lines.append(f" /** {doc} */")
|
|
308
|
+
lines.append("")
|
|
309
|
+
|
|
310
|
+
return "\n".join(lines)
|