invar-tools 1.8.0__py3-none-any.whl → 1.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- invar/__init__.py +8 -0
- invar/core/doc_edit.py +187 -0
- invar/core/doc_parser.py +563 -0
- invar/core/language.py +88 -0
- invar/core/models.py +106 -0
- invar/core/patterns/detector.py +6 -1
- invar/core/patterns/p0_exhaustive.py +15 -3
- invar/core/patterns/p0_literal.py +15 -3
- invar/core/patterns/p0_newtype.py +15 -3
- invar/core/patterns/p0_nonempty.py +15 -3
- invar/core/patterns/p0_validation.py +15 -3
- invar/core/patterns/registry.py +5 -1
- invar/core/patterns/types.py +5 -1
- invar/core/property_gen.py +4 -0
- invar/core/rules.py +84 -18
- invar/core/sync_helpers.py +27 -1
- invar/core/ts_parsers.py +286 -0
- invar/core/ts_sig_parser.py +310 -0
- invar/mcp/handlers.py +408 -0
- invar/mcp/server.py +288 -143
- invar/node_tools/MANIFEST +7 -0
- invar/node_tools/__init__.py +51 -0
- invar/node_tools/fc-runner/cli.js +77 -0
- invar/node_tools/quick-check/cli.js +28 -0
- invar/node_tools/ts-analyzer/cli.js +480 -0
- invar/shell/claude_hooks.py +35 -12
- invar/shell/commands/doc.py +409 -0
- invar/shell/commands/guard.py +41 -1
- invar/shell/commands/init.py +154 -16
- invar/shell/commands/perception.py +157 -33
- invar/shell/commands/skill.py +187 -0
- invar/shell/commands/template_sync.py +65 -13
- invar/shell/commands/uninstall.py +60 -12
- invar/shell/commands/update.py +6 -14
- invar/shell/contract_coverage.py +1 -0
- invar/shell/doc_tools.py +459 -0
- invar/shell/fs.py +67 -13
- invar/shell/pi_hooks.py +6 -0
- invar/shell/prove/crosshair.py +3 -0
- invar/shell/prove/guard_ts.py +902 -0
- invar/shell/skill_manager.py +355 -0
- invar/shell/template_engine.py +28 -4
- invar/shell/templates.py +4 -4
- invar/templates/claude-md/python/critical-rules.md +33 -0
- invar/templates/claude-md/python/quick-reference.md +24 -0
- invar/templates/claude-md/typescript/critical-rules.md +40 -0
- invar/templates/claude-md/typescript/quick-reference.md +24 -0
- invar/templates/claude-md/universal/check-in.md +25 -0
- invar/templates/claude-md/universal/skills.md +73 -0
- invar/templates/claude-md/universal/workflow.md +55 -0
- invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
- invar/templates/config/AGENT.md.jinja +58 -0
- invar/templates/config/CLAUDE.md.jinja +16 -209
- invar/templates/config/context.md.jinja +19 -0
- invar/templates/examples/{README.md → python/README.md} +2 -0
- invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
- invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
- invar/templates/examples/python/core_shell.py +227 -0
- invar/templates/examples/python/functional.py +613 -0
- invar/templates/examples/typescript/README.md +31 -0
- invar/templates/examples/typescript/contracts.ts +163 -0
- invar/templates/examples/typescript/core_shell.ts +374 -0
- invar/templates/examples/typescript/functional.ts +601 -0
- invar/templates/examples/typescript/workflow.md +95 -0
- invar/templates/hooks/PostToolUse.sh.jinja +10 -1
- invar/templates/hooks/PreToolUse.sh.jinja +38 -0
- invar/templates/hooks/Stop.sh.jinja +1 -1
- invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
- invar/templates/hooks/pi/invar.ts.jinja +9 -0
- invar/templates/manifest.toml +7 -6
- invar/templates/onboard/assessment.md.jinja +214 -0
- invar/templates/onboard/patterns/python.md +347 -0
- invar/templates/onboard/patterns/typescript.md +452 -0
- invar/templates/onboard/roadmap.md.jinja +168 -0
- invar/templates/protocol/INVAR.md.jinja +51 -0
- invar/templates/protocol/python/architecture-examples.md +41 -0
- invar/templates/protocol/python/contracts-syntax.md +56 -0
- invar/templates/protocol/python/markers.md +44 -0
- invar/templates/protocol/python/tools.md +24 -0
- invar/templates/protocol/python/troubleshooting.md +38 -0
- invar/templates/protocol/typescript/architecture-examples.md +52 -0
- invar/templates/protocol/typescript/contracts-syntax.md +73 -0
- invar/templates/protocol/typescript/markers.md +48 -0
- invar/templates/protocol/typescript/tools.md +65 -0
- invar/templates/protocol/typescript/troubleshooting.md +104 -0
- invar/templates/protocol/universal/architecture.md +36 -0
- invar/templates/protocol/universal/completion.md +14 -0
- invar/templates/protocol/universal/contracts-concept.md +37 -0
- invar/templates/protocol/universal/header.md +17 -0
- invar/templates/protocol/universal/session.md +17 -0
- invar/templates/protocol/universal/six-laws.md +10 -0
- invar/templates/protocol/universal/usbv.md +14 -0
- invar/templates/protocol/universal/visible-workflow.md +25 -0
- invar/templates/skills/develop/SKILL.md.jinja +85 -3
- invar/templates/skills/extensions/_registry.yaml +93 -0
- invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
- invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
- invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
- invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
- invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
- invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
- invar/templates/skills/extensions/security/SKILL.md +382 -0
- invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
- invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
- invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
- invar/templates/skills/review/SKILL.md.jinja +220 -248
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +336 -12
- invar_tools-1.11.0.dist-info/RECORD +178 -0
- invar/templates/examples/core_shell.py +0 -127
- invar/templates/protocol/INVAR.md +0 -310
- invar_tools-1.8.0.dist-info/RECORD +0 -116
- /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/NOTICE +0 -0
invar/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
|
@@ -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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|