invar-tools 1.8.0__py3-none-any.whl → 1.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. invar/__init__.py +8 -0
  2. invar/core/doc_edit.py +187 -0
  3. invar/core/doc_parser.py +563 -0
  4. invar/core/language.py +88 -0
  5. invar/core/models.py +106 -0
  6. invar/core/patterns/detector.py +6 -1
  7. invar/core/patterns/p0_exhaustive.py +15 -3
  8. invar/core/patterns/p0_literal.py +15 -3
  9. invar/core/patterns/p0_newtype.py +15 -3
  10. invar/core/patterns/p0_nonempty.py +15 -3
  11. invar/core/patterns/p0_validation.py +15 -3
  12. invar/core/patterns/registry.py +5 -1
  13. invar/core/patterns/types.py +5 -1
  14. invar/core/property_gen.py +4 -0
  15. invar/core/rules.py +84 -18
  16. invar/core/sync_helpers.py +27 -1
  17. invar/core/ts_parsers.py +286 -0
  18. invar/core/ts_sig_parser.py +310 -0
  19. invar/mcp/handlers.py +408 -0
  20. invar/mcp/server.py +288 -143
  21. invar/node_tools/MANIFEST +7 -0
  22. invar/node_tools/__init__.py +51 -0
  23. invar/node_tools/fc-runner/cli.js +77 -0
  24. invar/node_tools/quick-check/cli.js +28 -0
  25. invar/node_tools/ts-analyzer/cli.js +480 -0
  26. invar/shell/claude_hooks.py +35 -12
  27. invar/shell/commands/doc.py +409 -0
  28. invar/shell/commands/guard.py +41 -1
  29. invar/shell/commands/init.py +154 -16
  30. invar/shell/commands/perception.py +157 -33
  31. invar/shell/commands/skill.py +187 -0
  32. invar/shell/commands/template_sync.py +65 -13
  33. invar/shell/commands/uninstall.py +60 -12
  34. invar/shell/commands/update.py +6 -14
  35. invar/shell/contract_coverage.py +1 -0
  36. invar/shell/doc_tools.py +459 -0
  37. invar/shell/fs.py +67 -13
  38. invar/shell/pi_hooks.py +6 -0
  39. invar/shell/prove/crosshair.py +3 -0
  40. invar/shell/prove/guard_ts.py +902 -0
  41. invar/shell/skill_manager.py +355 -0
  42. invar/shell/template_engine.py +28 -4
  43. invar/shell/templates.py +4 -4
  44. invar/templates/claude-md/python/critical-rules.md +33 -0
  45. invar/templates/claude-md/python/quick-reference.md +24 -0
  46. invar/templates/claude-md/typescript/critical-rules.md +40 -0
  47. invar/templates/claude-md/typescript/quick-reference.md +24 -0
  48. invar/templates/claude-md/universal/check-in.md +25 -0
  49. invar/templates/claude-md/universal/skills.md +73 -0
  50. invar/templates/claude-md/universal/workflow.md +55 -0
  51. invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
  52. invar/templates/config/AGENT.md.jinja +58 -0
  53. invar/templates/config/CLAUDE.md.jinja +16 -209
  54. invar/templates/config/context.md.jinja +19 -0
  55. invar/templates/examples/{README.md → python/README.md} +2 -0
  56. invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
  57. invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
  58. invar/templates/examples/python/core_shell.py +227 -0
  59. invar/templates/examples/python/functional.py +613 -0
  60. invar/templates/examples/typescript/README.md +31 -0
  61. invar/templates/examples/typescript/contracts.ts +163 -0
  62. invar/templates/examples/typescript/core_shell.ts +374 -0
  63. invar/templates/examples/typescript/functional.ts +601 -0
  64. invar/templates/examples/typescript/workflow.md +95 -0
  65. invar/templates/hooks/PostToolUse.sh.jinja +10 -1
  66. invar/templates/hooks/PreToolUse.sh.jinja +38 -0
  67. invar/templates/hooks/Stop.sh.jinja +1 -1
  68. invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
  69. invar/templates/hooks/pi/invar.ts.jinja +9 -0
  70. invar/templates/manifest.toml +7 -6
  71. invar/templates/onboard/assessment.md.jinja +214 -0
  72. invar/templates/onboard/patterns/python.md +347 -0
  73. invar/templates/onboard/patterns/typescript.md +452 -0
  74. invar/templates/onboard/roadmap.md.jinja +168 -0
  75. invar/templates/protocol/INVAR.md.jinja +51 -0
  76. invar/templates/protocol/python/architecture-examples.md +41 -0
  77. invar/templates/protocol/python/contracts-syntax.md +56 -0
  78. invar/templates/protocol/python/markers.md +44 -0
  79. invar/templates/protocol/python/tools.md +24 -0
  80. invar/templates/protocol/python/troubleshooting.md +38 -0
  81. invar/templates/protocol/typescript/architecture-examples.md +52 -0
  82. invar/templates/protocol/typescript/contracts-syntax.md +73 -0
  83. invar/templates/protocol/typescript/markers.md +48 -0
  84. invar/templates/protocol/typescript/tools.md +65 -0
  85. invar/templates/protocol/typescript/troubleshooting.md +104 -0
  86. invar/templates/protocol/universal/architecture.md +36 -0
  87. invar/templates/protocol/universal/completion.md +14 -0
  88. invar/templates/protocol/universal/contracts-concept.md +37 -0
  89. invar/templates/protocol/universal/header.md +17 -0
  90. invar/templates/protocol/universal/session.md +17 -0
  91. invar/templates/protocol/universal/six-laws.md +10 -0
  92. invar/templates/protocol/universal/usbv.md +14 -0
  93. invar/templates/protocol/universal/visible-workflow.md +25 -0
  94. invar/templates/skills/develop/SKILL.md.jinja +85 -3
  95. invar/templates/skills/extensions/_registry.yaml +93 -0
  96. invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
  97. invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
  98. invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
  99. invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
  100. invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
  101. invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
  102. invar/templates/skills/extensions/security/SKILL.md +382 -0
  103. invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
  104. invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
  105. invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
  106. invar/templates/skills/review/SKILL.md.jinja +220 -248
  107. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +336 -12
  108. invar_tools-1.11.0.dist-info/RECORD +178 -0
  109. invar/templates/examples/core_shell.py +0 -127
  110. invar/templates/protocol/INVAR.md +0 -310
  111. invar_tools-1.8.0.dist-info/RECORD +0 -116
  112. /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
  113. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/WHEEL +0 -0
  114. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
  115. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
  116. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
  117. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,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)