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,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
@@ -19,6 +19,27 @@ if TYPE_CHECKING:
19
19
  from pathlib import Path
20
20
 
21
21
 
22
+ # @shell_orchestration: Helper for file discovery, co-located with I/O functions
23
+ def _is_excluded(relative_str: str, exclude_patterns: list[str]) -> bool:
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
+ """
36
+ for pattern in exclude_patterns:
37
+ # Match whole path component, not prefix
38
+ if relative_str == pattern or relative_str.startswith(pattern + "/") or f"/{pattern}/" in f"/{relative_str}":
39
+ return True
40
+ return False
41
+
42
+
22
43
  # @shell_complexity: Recursive file discovery with gitignore and exclusions
23
44
  def discover_python_files(
24
45
  project_root: Path,
@@ -39,18 +60,41 @@ def discover_python_files(
39
60
  exclude_patterns = exclude_result.unwrap() if isinstance(exclude_result, Success) else []
40
61
 
41
62
  for py_file in project_root.rglob("*.py"):
42
- # Check exclusions
43
- relative = py_file.relative_to(project_root)
44
- relative_str = str(relative)
63
+ # Check exclusions using shared helper
64
+ relative_str = str(py_file.relative_to(project_root))
65
+ if not _is_excluded(relative_str, exclude_patterns):
66
+ yield py_file
45
67
 
46
- excluded = False
47
- for pattern in exclude_patterns:
48
- if relative_str.startswith(pattern) or f"/{pattern}/" in f"/{relative_str}":
49
- excluded = True
50
- break
51
68
 
52
- if not excluded:
53
- yield py_file
69
+ # @shell_complexity: Recursive TypeScript file discovery with exclusions
70
+ def discover_typescript_files(
71
+ project_root: Path,
72
+ exclude_patterns: list[str] | None = None,
73
+ ) -> Iterator[Path]:
74
+ """
75
+ Discover all TypeScript files in a project (LX-06).
76
+
77
+ Args:
78
+ project_root: Root directory to search
79
+ exclude_patterns: Patterns to exclude (uses config defaults if None)
80
+
81
+ Yields:
82
+ Path objects for each TypeScript file found
83
+ """
84
+ if exclude_patterns is None:
85
+ exclude_result = get_exclude_paths(project_root)
86
+ exclude_patterns = exclude_result.unwrap() if isinstance(exclude_result, Success) else []
87
+
88
+ # Always exclude node_modules and common build directories
89
+ default_ts_excludes = ["node_modules", "dist", "build", ".next", "out"]
90
+ all_excludes = list(set(list(exclude_patterns) + default_ts_excludes))
91
+
92
+ for ext in ("*.ts", "*.tsx"):
93
+ for ts_file in project_root.rglob(ext):
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):
97
+ yield ts_file
54
98
 
55
99
 
56
100
  # @shell_complexity: File reading with AST parsing and error handling
@@ -105,11 +149,21 @@ def scan_project(
105
149
  Yields:
106
150
  Result containing FileInfo or error message for each file
107
151
  """
152
+ # Get exclusion patterns once
153
+ exclude_result = get_exclude_paths(project_root)
154
+ exclude_patterns = exclude_result.unwrap() if isinstance(exclude_result, Success) else []
155
+
108
156
  if only_files is not None:
109
- # Phase 8.1: --changed mode - only scan specified files
157
+ # Phase 8.1: --changed mode - only scan specified files (with exclusions)
110
158
  for py_file in only_files:
111
159
  if py_file.exists() and py_file.suffix == ".py":
112
- yield read_and_parse_file(py_file, project_root)
160
+ # Apply exclusions even in --changed mode
161
+ try:
162
+ relative_str = str(py_file.relative_to(project_root))
163
+ except ValueError:
164
+ relative_str = str(py_file)
165
+ if not _is_excluded(relative_str, exclude_patterns):
166
+ yield read_and_parse_file(py_file, project_root)
113
167
  else:
114
- for py_file in discover_python_files(project_root):
168
+ for py_file in discover_python_files(project_root, exclude_patterns):
115
169
  yield read_and_parse_file(py_file, project_root)
invar/shell/pi_hooks.py CHANGED
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING
16
16
  from jinja2 import Environment, FileSystemLoader
17
17
  from returns.result import Failure, Result, Success
18
18
 
19
+ from invar.core.language import detect_language_from_markers
19
20
  from invar.core.template_helpers import escape_for_js_template
20
21
  from invar.shell.claude_hooks import detect_syntax, get_invar_md_content
21
22
 
@@ -52,6 +53,10 @@ def generate_pi_hook_content(project_path: Path) -> Result[str, str]:
52
53
  syntax = detect_syntax(project_path)
53
54
  guard_cmd = "invar_guard" if syntax == "mcp" else "invar guard"
54
55
 
56
+ # Detect project language from marker files
57
+ markers = frozenset(f.name for f in project_path.iterdir() if f.is_file())
58
+ language = detect_language_from_markers(markers)
59
+
55
60
  # Get and escape protocol content for JS template literal
56
61
  protocol_content = get_invar_md_content(project_path)
57
62
  protocol_escaped = escape_for_js_template(protocol_content)
@@ -61,6 +66,7 @@ def generate_pi_hook_content(project_path: Path) -> Result[str, str]:
61
66
  "protocol_version": PROTOCOL_VERSION,
62
67
  "generated_date": datetime.now().strftime("%Y-%m-%d"),
63
68
  "guard_cmd": guard_cmd,
69
+ "language": language,
64
70
  "invar_protocol_escaped": protocol_escaped,
65
71
  }
66
72
 
@@ -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