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.
@@ -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
- relative = ts_file.relative_to(project_root)
85
- relative_str = str(relative)
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
 
@@ -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
 
@@ -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
- import re as re_module
270
- param_match = re_module.search(r'"(\w+)"', v.message)
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 check_tool_available(tool: str, check_args: list[str]) -> bool:
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 = check_tool_available("npx", ["tsc", "--version"])
816
- result.eslint_available = check_tool_available("npx", ["eslint", "--version"])
817
- result.vitest_available = check_tool_available("npx", ["vitest", "--version"])
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