invar-tools 1.10.0__py3-none-any.whl → 1.12.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/core/doc_edit.py +187 -0
- invar/core/doc_parser.py +563 -0
- invar/core/ts_sig_parser.py +6 -3
- invar/mcp/handlers.py +436 -0
- invar/mcp/server.py +351 -156
- invar/node_tools/ts-query.js +396 -0
- invar/shell/commands/doc.py +409 -0
- invar/shell/commands/guard.py +29 -0
- invar/shell/commands/init.py +72 -13
- invar/shell/commands/perception.py +302 -6
- invar/shell/doc_tools.py +459 -0
- invar/shell/fs.py +15 -14
- invar/shell/prove/crosshair.py +3 -0
- invar/shell/prove/guard_ts.py +13 -10
- invar/shell/py_refs.py +156 -0
- invar/shell/skill_manager.py +17 -15
- invar/shell/ts_compiler.py +238 -0
- invar/templates/examples/typescript/patterns.md +193 -0
- invar/templates/skills/develop/SKILL.md.jinja +46 -0
- invar/templates/skills/review/SKILL.md.jinja +205 -493
- {invar_tools-1.10.0.dist-info → invar_tools-1.12.0.dist-info}/METADATA +58 -8
- {invar_tools-1.10.0.dist-info → invar_tools-1.12.0.dist-info}/RECORD +27 -18
- {invar_tools-1.10.0.dist-info → invar_tools-1.12.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.10.0.dist-info → invar_tools-1.12.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.10.0.dist-info → invar_tools-1.12.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.10.0.dist-info → invar_tools-1.12.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.10.0.dist-info → invar_tools-1.12.0.dist-info}/licenses/NOTICE +0 -0
invar/shell/doc_tools.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shell layer for document tools.
|
|
3
|
+
|
|
4
|
+
DX-76: File I/O operations for structured document queries.
|
|
5
|
+
Returns Result[T, E] for error handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from returns.result import Failure, Result, Success
|
|
12
|
+
|
|
13
|
+
from invar.core.doc_edit import (
|
|
14
|
+
delete_section as core_delete_section,
|
|
15
|
+
)
|
|
16
|
+
from invar.core.doc_edit import (
|
|
17
|
+
insert_section as core_insert_section,
|
|
18
|
+
)
|
|
19
|
+
from invar.core.doc_edit import (
|
|
20
|
+
replace_section as core_replace_section,
|
|
21
|
+
)
|
|
22
|
+
from invar.core.doc_parser import (
|
|
23
|
+
DocumentToc,
|
|
24
|
+
Section,
|
|
25
|
+
extract_content,
|
|
26
|
+
find_section,
|
|
27
|
+
parse_toc,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# @shell_complexity: Multiple I/O error types (OSError, IsADirectoryError, etc.) require separate handling
|
|
32
|
+
def read_toc(path: Path) -> Result[DocumentToc, str]:
|
|
33
|
+
"""Read and parse document table of contents.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
>>> from pathlib import Path
|
|
37
|
+
>>> import tempfile
|
|
38
|
+
>>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
|
39
|
+
... _ = f.write("# Hello\\n\\nWorld")
|
|
40
|
+
... p = Path(f.name)
|
|
41
|
+
>>> result = read_toc(p)
|
|
42
|
+
>>> isinstance(result, Success)
|
|
43
|
+
True
|
|
44
|
+
>>> result.unwrap().sections[0].title
|
|
45
|
+
'Hello'
|
|
46
|
+
>>> p.unlink()
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
content = path.read_text(encoding="utf-8")
|
|
50
|
+
toc = parse_toc(content)
|
|
51
|
+
return Success(toc)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
return Failure(f"File not found: {path}")
|
|
54
|
+
except IsADirectoryError:
|
|
55
|
+
return Failure(f"Path is a directory, not a file: {path}")
|
|
56
|
+
except PermissionError:
|
|
57
|
+
return Failure(f"Permission denied: {path}")
|
|
58
|
+
except UnicodeDecodeError:
|
|
59
|
+
return Failure(f"Failed to decode file as UTF-8: {path}")
|
|
60
|
+
except OSError as e:
|
|
61
|
+
return Failure(f"OS error reading {path}: {e}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# @shell_complexity: Multiple I/O error types require separate handling
|
|
65
|
+
def read_section(
|
|
66
|
+
path: Path, section_path: str, include_children: bool = True
|
|
67
|
+
) -> Result[str, str]:
|
|
68
|
+
"""Read a specific section from a document.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: Path to markdown file
|
|
72
|
+
section_path: Section path (slug, fuzzy, index, or line anchor)
|
|
73
|
+
include_children: If True, include child sections in output
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Result containing section content or error message
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
>>> from pathlib import Path
|
|
80
|
+
>>> import tempfile
|
|
81
|
+
>>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
|
82
|
+
... _ = f.write("# Title\\n\\nContent here")
|
|
83
|
+
... p = Path(f.name)
|
|
84
|
+
>>> result = read_section(p, "title")
|
|
85
|
+
>>> isinstance(result, Success)
|
|
86
|
+
True
|
|
87
|
+
>>> "Title" in result.unwrap()
|
|
88
|
+
True
|
|
89
|
+
>>> p.unlink()
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
content = path.read_text(encoding="utf-8")
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return Failure(f"File not found: {path}")
|
|
95
|
+
except IsADirectoryError:
|
|
96
|
+
return Failure(f"Path is a directory, not a file: {path}")
|
|
97
|
+
except PermissionError:
|
|
98
|
+
return Failure(f"Permission denied: {path}")
|
|
99
|
+
except UnicodeDecodeError:
|
|
100
|
+
return Failure(f"Failed to decode file as UTF-8: {path}")
|
|
101
|
+
except OSError as e:
|
|
102
|
+
return Failure(f"OS error reading {path}: {e}")
|
|
103
|
+
|
|
104
|
+
toc = parse_toc(content)
|
|
105
|
+
section = find_section(toc.sections, section_path)
|
|
106
|
+
|
|
107
|
+
if section is None:
|
|
108
|
+
return Failure(f"Section not found: {section_path}")
|
|
109
|
+
|
|
110
|
+
extracted = extract_content(content, section, include_children=include_children)
|
|
111
|
+
return Success(extracted)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# @shell_complexity: Multiple I/O error types + batch section iteration
|
|
115
|
+
def read_sections_batch(
|
|
116
|
+
path: Path,
|
|
117
|
+
section_paths: list[str],
|
|
118
|
+
include_children: bool = True
|
|
119
|
+
) -> Result[list[dict[str, str]], str]:
|
|
120
|
+
"""
|
|
121
|
+
Read multiple sections from a document in one operation.
|
|
122
|
+
|
|
123
|
+
Returns a list of dicts, each containing 'path' and 'content' keys.
|
|
124
|
+
If any section fails to read, returns Failure with error message.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
path: Path to markdown file
|
|
128
|
+
section_paths: List of section paths (slug, fuzzy, index, or line anchor)
|
|
129
|
+
include_children: If True, include child sections in output
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Result containing list of section dicts or error message
|
|
133
|
+
|
|
134
|
+
Examples:
|
|
135
|
+
>>> from pathlib import Path
|
|
136
|
+
>>> import tempfile
|
|
137
|
+
>>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
|
138
|
+
... _ = f.write("# A\\n\\nContent A\\n\\n# B\\n\\nContent B\\n\\n# C\\n\\nContent C")
|
|
139
|
+
... p = Path(f.name)
|
|
140
|
+
>>> result = read_sections_batch(p, ["a", "b"])
|
|
141
|
+
>>> isinstance(result, Success)
|
|
142
|
+
True
|
|
143
|
+
>>> sections = result.unwrap()
|
|
144
|
+
>>> len(sections)
|
|
145
|
+
2
|
|
146
|
+
>>> sections[0]['path']
|
|
147
|
+
'a'
|
|
148
|
+
>>> "Content A" in sections[0]['content']
|
|
149
|
+
True
|
|
150
|
+
>>> p.unlink()
|
|
151
|
+
"""
|
|
152
|
+
# Read file once
|
|
153
|
+
try:
|
|
154
|
+
content = path.read_text(encoding="utf-8")
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
return Failure(f"File not found: {path}")
|
|
157
|
+
except IsADirectoryError:
|
|
158
|
+
return Failure(f"Path is a directory, not a file: {path}")
|
|
159
|
+
except PermissionError:
|
|
160
|
+
return Failure(f"Permission denied: {path}")
|
|
161
|
+
except UnicodeDecodeError:
|
|
162
|
+
return Failure(f"Failed to decode file as UTF-8: {path}")
|
|
163
|
+
except OSError as e:
|
|
164
|
+
return Failure(f"OS error reading {path}: {e}")
|
|
165
|
+
|
|
166
|
+
# Parse TOC once
|
|
167
|
+
toc = parse_toc(content)
|
|
168
|
+
|
|
169
|
+
# Extract all requested sections
|
|
170
|
+
results = []
|
|
171
|
+
for section_path in section_paths:
|
|
172
|
+
section = find_section(toc.sections, section_path)
|
|
173
|
+
|
|
174
|
+
if section is None:
|
|
175
|
+
return Failure(f"Section not found: {section_path}")
|
|
176
|
+
|
|
177
|
+
extracted = extract_content(content, section, include_children=include_children)
|
|
178
|
+
results.append({
|
|
179
|
+
"path": section_path,
|
|
180
|
+
"content": extracted
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return Success(results)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# @shell_complexity: Pattern matching + content filtering orchestration
|
|
187
|
+
def find_sections(
|
|
188
|
+
path: Path,
|
|
189
|
+
pattern: str,
|
|
190
|
+
content_pattern: str | None = None,
|
|
191
|
+
level: int | None = None,
|
|
192
|
+
) -> Result[list[Section], str]:
|
|
193
|
+
"""Find sections matching a pattern.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
path: Path to markdown file
|
|
197
|
+
pattern: Title pattern (glob-style)
|
|
198
|
+
content_pattern: Optional content search pattern
|
|
199
|
+
level: Optional filter by heading level (1-6)
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Result containing list of matching sections
|
|
203
|
+
|
|
204
|
+
Examples:
|
|
205
|
+
>>> from pathlib import Path
|
|
206
|
+
>>> import tempfile
|
|
207
|
+
>>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
|
208
|
+
... _ = f.write("# Intro\\n\\n## Overview\\n\\n# Summary")
|
|
209
|
+
... p = Path(f.name)
|
|
210
|
+
>>> result = find_sections(p, "*")
|
|
211
|
+
>>> isinstance(result, Success)
|
|
212
|
+
True
|
|
213
|
+
>>> len(result.unwrap()) >= 2
|
|
214
|
+
True
|
|
215
|
+
>>> p.unlink()
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
content = path.read_text(encoding="utf-8")
|
|
219
|
+
except FileNotFoundError:
|
|
220
|
+
return Failure(f"File not found: {path}")
|
|
221
|
+
except IsADirectoryError:
|
|
222
|
+
return Failure(f"Path is a directory, not a file: {path}")
|
|
223
|
+
except PermissionError:
|
|
224
|
+
return Failure(f"Permission denied: {path}")
|
|
225
|
+
except UnicodeDecodeError:
|
|
226
|
+
return Failure(f"Failed to decode file as UTF-8: {path}")
|
|
227
|
+
except OSError as e:
|
|
228
|
+
return Failure(f"OS error reading {path}: {e}")
|
|
229
|
+
|
|
230
|
+
toc = parse_toc(content)
|
|
231
|
+
|
|
232
|
+
# Collect all sections recursively
|
|
233
|
+
def collect_all(sections: list[Section]) -> list[Section]:
|
|
234
|
+
result: list[Section] = []
|
|
235
|
+
for s in sections:
|
|
236
|
+
result.append(s)
|
|
237
|
+
result.extend(collect_all(s.children))
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
all_sections = collect_all(toc.sections)
|
|
241
|
+
|
|
242
|
+
# Filter by pattern
|
|
243
|
+
import fnmatch
|
|
244
|
+
|
|
245
|
+
matches = [s for s in all_sections if fnmatch.fnmatch(s.title.lower(), pattern.lower())]
|
|
246
|
+
|
|
247
|
+
# Filter by level if specified
|
|
248
|
+
if level is not None:
|
|
249
|
+
matches = [s for s in matches if s.level == level]
|
|
250
|
+
|
|
251
|
+
# Filter by content if specified
|
|
252
|
+
if content_pattern:
|
|
253
|
+
content_matches = []
|
|
254
|
+
for s in matches:
|
|
255
|
+
section_content = extract_content(content, s)
|
|
256
|
+
if content_pattern.lower() in section_content.lower():
|
|
257
|
+
content_matches.append(s)
|
|
258
|
+
matches = content_matches
|
|
259
|
+
|
|
260
|
+
return Success(matches)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# DX-76 Phase A-2: Extended editing tools
|
|
264
|
+
|
|
265
|
+
# @shell_complexity: Read + find + edit + write orchestration
|
|
266
|
+
def replace_section_content(
|
|
267
|
+
path: Path,
|
|
268
|
+
section_path: str,
|
|
269
|
+
new_content: str,
|
|
270
|
+
keep_heading: bool = True,
|
|
271
|
+
) -> Result[dict[str, str | int], str]:
|
|
272
|
+
"""Replace a section's content in a document.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
path: Path to markdown file
|
|
276
|
+
section_path: Section path (slug, fuzzy, index, or line anchor)
|
|
277
|
+
new_content: New content to replace the section with
|
|
278
|
+
keep_heading: If True, preserve the original heading line
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Result containing info about the replacement or error message
|
|
282
|
+
|
|
283
|
+
Examples:
|
|
284
|
+
>>> from pathlib import Path
|
|
285
|
+
>>> import tempfile
|
|
286
|
+
>>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
|
287
|
+
... _ = f.write("# Title\\n\\nOld content\\n\\n# Next")
|
|
288
|
+
... p = Path(f.name)
|
|
289
|
+
>>> result = replace_section_content(p, "title", "New content\\n")
|
|
290
|
+
>>> isinstance(result, Success)
|
|
291
|
+
True
|
|
292
|
+
>>> "New content" in p.read_text()
|
|
293
|
+
True
|
|
294
|
+
>>> p.unlink()
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
content = path.read_text(encoding="utf-8")
|
|
298
|
+
except FileNotFoundError:
|
|
299
|
+
return Failure(f"File not found: {path}")
|
|
300
|
+
except IsADirectoryError:
|
|
301
|
+
return Failure(f"Path is a directory, not a file: {path}")
|
|
302
|
+
except PermissionError:
|
|
303
|
+
return Failure(f"Permission denied: {path}")
|
|
304
|
+
except UnicodeDecodeError:
|
|
305
|
+
return Failure(f"Failed to decode file as UTF-8: {path}")
|
|
306
|
+
except OSError as e:
|
|
307
|
+
return Failure(f"OS error reading {path}: {e}")
|
|
308
|
+
|
|
309
|
+
toc = parse_toc(content)
|
|
310
|
+
section = find_section(toc.sections, section_path)
|
|
311
|
+
|
|
312
|
+
if section is None:
|
|
313
|
+
return Failure(f"Section not found: {section_path}")
|
|
314
|
+
|
|
315
|
+
old_content = extract_content(content, section)
|
|
316
|
+
new_source = core_replace_section(content, section, new_content, keep_heading)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
path.write_text(new_source, encoding="utf-8")
|
|
320
|
+
except PermissionError:
|
|
321
|
+
return Failure(f"Permission denied: {path}")
|
|
322
|
+
except OSError as e:
|
|
323
|
+
return Failure(f"OS error writing {path}: {e}")
|
|
324
|
+
|
|
325
|
+
return Success({
|
|
326
|
+
"old_content": old_content,
|
|
327
|
+
"new_line_count": len(new_source.split("\n")),
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# @shell_complexity: Read + find + insert + write orchestration
|
|
332
|
+
def insert_section_content(
|
|
333
|
+
path: Path,
|
|
334
|
+
anchor_path: str,
|
|
335
|
+
content: str,
|
|
336
|
+
position: Literal["before", "after", "first_child", "last_child"] = "after",
|
|
337
|
+
) -> Result[dict[str, str | int], str]:
|
|
338
|
+
"""Insert new content relative to a section.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
path: Path to markdown file
|
|
342
|
+
anchor_path: Section path for the anchor
|
|
343
|
+
content: Content to insert (should include heading if adding a section)
|
|
344
|
+
position: Where to insert relative to anchor
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Result containing info about the insertion or error message
|
|
348
|
+
|
|
349
|
+
Examples:
|
|
350
|
+
>>> from pathlib import Path
|
|
351
|
+
>>> import tempfile
|
|
352
|
+
>>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
|
353
|
+
... _ = f.write("# Title\\n\\nContent")
|
|
354
|
+
... p = Path(f.name)
|
|
355
|
+
>>> result = insert_section_content(p, "title", "\\n## Subsection\\n\\nNew text", "after")
|
|
356
|
+
>>> isinstance(result, Success)
|
|
357
|
+
True
|
|
358
|
+
>>> "## Subsection" in p.read_text()
|
|
359
|
+
True
|
|
360
|
+
>>> p.unlink()
|
|
361
|
+
"""
|
|
362
|
+
try:
|
|
363
|
+
source = path.read_text(encoding="utf-8")
|
|
364
|
+
except FileNotFoundError:
|
|
365
|
+
return Failure(f"File not found: {path}")
|
|
366
|
+
except IsADirectoryError:
|
|
367
|
+
return Failure(f"Path is a directory, not a file: {path}")
|
|
368
|
+
except PermissionError:
|
|
369
|
+
return Failure(f"Permission denied: {path}")
|
|
370
|
+
except UnicodeDecodeError:
|
|
371
|
+
return Failure(f"Failed to decode file as UTF-8: {path}")
|
|
372
|
+
except OSError as e:
|
|
373
|
+
return Failure(f"OS error reading {path}: {e}")
|
|
374
|
+
|
|
375
|
+
toc = parse_toc(source)
|
|
376
|
+
anchor = find_section(toc.sections, anchor_path)
|
|
377
|
+
|
|
378
|
+
if anchor is None:
|
|
379
|
+
return Failure(f"Section not found: {anchor_path}")
|
|
380
|
+
|
|
381
|
+
new_source = core_insert_section(source, anchor, content, position)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
path.write_text(new_source, encoding="utf-8")
|
|
385
|
+
except PermissionError:
|
|
386
|
+
return Failure(f"Permission denied: {path}")
|
|
387
|
+
except OSError as e:
|
|
388
|
+
return Failure(f"OS error writing {path}: {e}")
|
|
389
|
+
|
|
390
|
+
return Success({
|
|
391
|
+
"inserted_at": anchor.line_end if position == "after" else anchor.line_start,
|
|
392
|
+
"new_line_count": len(new_source.split("\n")),
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# @shell_complexity: Read + find + delete + write orchestration
|
|
397
|
+
def delete_section_content(
|
|
398
|
+
path: Path,
|
|
399
|
+
section_path: str,
|
|
400
|
+
include_children: bool = True,
|
|
401
|
+
) -> Result[dict[str, str | int], str]:
|
|
402
|
+
"""Delete a section from a document.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
path: Path to markdown file
|
|
406
|
+
section_path: Section path (slug, fuzzy, index, or line anchor)
|
|
407
|
+
include_children: If True, delete child sections too
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Result containing info about the deletion or error message
|
|
411
|
+
|
|
412
|
+
Examples:
|
|
413
|
+
>>> from pathlib import Path
|
|
414
|
+
>>> import tempfile
|
|
415
|
+
>>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
|
416
|
+
... _ = f.write("# Keep\\n\\n# Delete\\n\\nContent\\n\\n# Also Keep")
|
|
417
|
+
... p = Path(f.name)
|
|
418
|
+
>>> result = delete_section_content(p, "delete")
|
|
419
|
+
>>> isinstance(result, Success)
|
|
420
|
+
True
|
|
421
|
+
>>> "# Delete" not in p.read_text()
|
|
422
|
+
True
|
|
423
|
+
>>> p.unlink()
|
|
424
|
+
"""
|
|
425
|
+
try:
|
|
426
|
+
source = path.read_text(encoding="utf-8")
|
|
427
|
+
except FileNotFoundError:
|
|
428
|
+
return Failure(f"File not found: {path}")
|
|
429
|
+
except IsADirectoryError:
|
|
430
|
+
return Failure(f"Path is a directory, not a file: {path}")
|
|
431
|
+
except PermissionError:
|
|
432
|
+
return Failure(f"Permission denied: {path}")
|
|
433
|
+
except UnicodeDecodeError:
|
|
434
|
+
return Failure(f"Failed to decode file as UTF-8: {path}")
|
|
435
|
+
except OSError as e:
|
|
436
|
+
return Failure(f"OS error reading {path}: {e}")
|
|
437
|
+
|
|
438
|
+
toc = parse_toc(source)
|
|
439
|
+
section = find_section(toc.sections, section_path)
|
|
440
|
+
|
|
441
|
+
if section is None:
|
|
442
|
+
return Failure(f"Section not found: {section_path}")
|
|
443
|
+
|
|
444
|
+
deleted_content = extract_content(source, section, include_children=include_children)
|
|
445
|
+
new_source = core_delete_section(source, section, include_children=include_children)
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
path.write_text(new_source, encoding="utf-8")
|
|
449
|
+
except PermissionError:
|
|
450
|
+
return Failure(f"Permission denied: {path}")
|
|
451
|
+
except OSError as e:
|
|
452
|
+
return Failure(f"OS error writing {path}: {e}")
|
|
453
|
+
|
|
454
|
+
return Success({
|
|
455
|
+
"deleted_content": deleted_content,
|
|
456
|
+
"deleted_line_start": section.line_start,
|
|
457
|
+
"deleted_line_end": section.line_end,
|
|
458
|
+
"new_line_count": len(new_source.split("\n")),
|
|
459
|
+
})
|
invar/shell/fs.py
CHANGED
|
@@ -21,7 +21,18 @@ if TYPE_CHECKING:
|
|
|
21
21
|
|
|
22
22
|
# @shell_orchestration: Helper for file discovery, co-located with I/O functions
|
|
23
23
|
def _is_excluded(relative_str: str, exclude_patterns: list[str]) -> bool:
|
|
24
|
-
"""Check if a relative path should be excluded.
|
|
24
|
+
"""Check if a relative path should be excluded.
|
|
25
|
+
|
|
26
|
+
Matches patterns as whole path components, not as prefixes:
|
|
27
|
+
- "dist" matches "dist", "dist/file.py", "src/dist/file.py"
|
|
28
|
+
- "dist" does NOT match "distribute" or "mydist" (prefix/suffix matching)
|
|
29
|
+
|
|
30
|
+
Note: A file literally named "some/dist" (not a directory) would not match
|
|
31
|
+
pattern "dist" - this is intentional as patterns target directory names.
|
|
32
|
+
|
|
33
|
+
Unix path assumption: Uses "/" separator. On Windows, paths should be
|
|
34
|
+
normalized before calling (Python's pathlib handles this).
|
|
35
|
+
"""
|
|
25
36
|
for pattern in exclude_patterns:
|
|
26
37
|
# Match whole path component, not prefix
|
|
27
38
|
if relative_str == pattern or relative_str.startswith(pattern + "/") or f"/{pattern}/" in f"/{relative_str}":
|
|
@@ -80,19 +91,9 @@ def discover_typescript_files(
|
|
|
80
91
|
|
|
81
92
|
for ext in ("*.ts", "*.tsx"):
|
|
82
93
|
for ts_file in project_root.rglob(ext):
|
|
83
|
-
# Check exclusions
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
excluded = False
|
|
88
|
-
for pattern in all_excludes:
|
|
89
|
-
# Match whole path component, not prefix
|
|
90
|
-
# e.g., "dist" should exclude "dist/file.ts" but NOT "dist_backup/file.ts"
|
|
91
|
-
if relative_str == pattern or relative_str.startswith(pattern + "/") or f"/{pattern}/" in f"/{relative_str}":
|
|
92
|
-
excluded = True
|
|
93
|
-
break
|
|
94
|
-
|
|
95
|
-
if not excluded:
|
|
94
|
+
# Check exclusions using shared helper (DX review: deduplicate)
|
|
95
|
+
relative_str = str(ts_file.relative_to(project_root))
|
|
96
|
+
if not _is_excluded(relative_str, all_excludes):
|
|
96
97
|
yield ts_file
|
|
97
98
|
|
|
98
99
|
|
invar/shell/prove/crosshair.py
CHANGED
|
@@ -164,6 +164,9 @@ def _verify_single_file(
|
|
|
164
164
|
"compile() arg 1 must be", # ast.parse limitation
|
|
165
165
|
"ValueError: wrong parameter order", # CrossHair signature bug
|
|
166
166
|
"ValueError: cannot determine truth", # Symbolic execution limit
|
|
167
|
+
"RecursionError:", # Infinite recursion in repr code
|
|
168
|
+
"maximum recursion depth exceeded", # Stack overflow
|
|
169
|
+
"format_boundargs", # CrossHair repr formatting bug
|
|
167
170
|
]
|
|
168
171
|
is_execution_error = any(err in output for err in execution_errors)
|
|
169
172
|
|
invar/shell/prove/guard_ts.py
CHANGED
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import contextlib
|
|
13
13
|
import json
|
|
14
|
+
import re
|
|
14
15
|
import subprocess
|
|
15
16
|
from dataclasses import dataclass, field
|
|
16
17
|
from pathlib import Path
|
|
@@ -265,9 +266,9 @@ def _generate_fix_suggestions(violations: list[TypeScriptViolation]) -> list[dic
|
|
|
265
266
|
|
|
266
267
|
# Customize code based on rule
|
|
267
268
|
if rule == "@invar/require-schema-validation":
|
|
268
|
-
# Extract param name from message
|
|
269
|
-
|
|
270
|
-
param_match =
|
|
269
|
+
# Extract param name from message (fragile: depends on ESLint message format)
|
|
270
|
+
# Falls back to "input" if extraction fails - user can adjust in fix suggestion
|
|
271
|
+
param_match = re.search(r'"(\w+)"', v.message)
|
|
271
272
|
param = param_match.group(1) if param_match else "input"
|
|
272
273
|
code = code.replace("{param}", param)
|
|
273
274
|
elif rule == "@invar/shell-result-type":
|
|
@@ -298,16 +299,20 @@ def _generate_fix_suggestions(violations: list[TypeScriptViolation]) -> list[dic
|
|
|
298
299
|
return fixes
|
|
299
300
|
|
|
300
301
|
|
|
301
|
-
def
|
|
302
|
+
def _check_tool_available(tool: str, check_args: list[str]) -> bool:
|
|
302
303
|
"""Check if a tool is available in PATH.
|
|
303
304
|
|
|
304
305
|
Args:
|
|
305
|
-
tool: Tool name (e.g., "npx", "tsc")
|
|
306
|
+
tool: Tool name (e.g., "npx", "tsc") - must be alphanumeric/dash/underscore
|
|
306
307
|
check_args: Arguments for version check
|
|
307
308
|
|
|
308
309
|
Returns:
|
|
309
310
|
True if tool is available and responds to check.
|
|
310
311
|
"""
|
|
312
|
+
# Security: validate tool name to prevent command injection
|
|
313
|
+
if not tool or not all(c.isalnum() or c in "-_" for c in tool):
|
|
314
|
+
return False
|
|
315
|
+
|
|
311
316
|
try:
|
|
312
317
|
result = subprocess.run(
|
|
313
318
|
[tool, *check_args],
|
|
@@ -637,8 +642,6 @@ def _parse_tsc_line(line: str) -> TypeScriptViolation | None:
|
|
|
637
642
|
>>> v.rule if v else None
|
|
638
643
|
'TS2322'
|
|
639
644
|
"""
|
|
640
|
-
import re
|
|
641
|
-
|
|
642
645
|
# Pattern: file(line,col): severity TSxxxx: message
|
|
643
646
|
pattern = r"^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$"
|
|
644
647
|
match = re.match(pattern, line)
|
|
@@ -812,9 +815,9 @@ def run_typescript_guard(
|
|
|
812
815
|
result = TypeScriptGuardResult(status="passed")
|
|
813
816
|
|
|
814
817
|
# Check tool availability
|
|
815
|
-
result.tsc_available =
|
|
816
|
-
result.eslint_available =
|
|
817
|
-
result.vitest_available =
|
|
818
|
+
result.tsc_available = _check_tool_available("npx", ["tsc", "--version"])
|
|
819
|
+
result.eslint_available = _check_tool_available("npx", ["eslint", "--version"])
|
|
820
|
+
result.vitest_available = _check_tool_available("npx", ["vitest", "--version"])
|
|
818
821
|
|
|
819
822
|
all_violations: list[TypeScriptViolation] = []
|
|
820
823
|
|