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/claude_hooks.py
CHANGED
|
@@ -15,6 +15,8 @@ from typing import TYPE_CHECKING
|
|
|
15
15
|
from jinja2 import Environment, FileSystemLoader
|
|
16
16
|
from returns.result import Failure, Result, Success
|
|
17
17
|
|
|
18
|
+
from invar.core.language import detect_language_from_markers
|
|
19
|
+
|
|
18
20
|
if TYPE_CHECKING:
|
|
19
21
|
from rich.console import Console
|
|
20
22
|
|
|
@@ -32,6 +34,9 @@ DISABLED_MARKER = ".invar_disabled"
|
|
|
32
34
|
# Marker for identifying Invar hooks in settings
|
|
33
35
|
INVAR_HOOK_MARKER = ".claude/hooks/"
|
|
34
36
|
|
|
37
|
+
# Regex pattern for extracting protocol version from hook files
|
|
38
|
+
PROTOCOL_VERSION_PATTERN = r"Protocol: v([\d.]+)"
|
|
39
|
+
|
|
35
40
|
|
|
36
41
|
# @shell_orchestration: Tightly coupled to Claude Code settings.local.json format
|
|
37
42
|
def is_invar_hook(hook_entry: dict) -> bool:
|
|
@@ -102,11 +107,16 @@ def generate_hook_content(
|
|
|
102
107
|
syntax = detect_syntax(project_path)
|
|
103
108
|
guard_cmd = "invar_guard" if syntax == "mcp" else "invar guard"
|
|
104
109
|
|
|
110
|
+
# Detect project language from marker files
|
|
111
|
+
markers = frozenset(f.name for f in project_path.iterdir() if f.is_file())
|
|
112
|
+
language = detect_language_from_markers(markers)
|
|
113
|
+
|
|
105
114
|
# Build context for template
|
|
106
115
|
context = {
|
|
107
116
|
"protocol_version": PROTOCOL_VERSION,
|
|
108
117
|
"generated_date": datetime.now().strftime("%Y-%m-%d"),
|
|
109
118
|
"guard_cmd": guard_cmd,
|
|
119
|
+
"language": language,
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
# For UserPromptSubmit, add the full INVAR.md content
|
|
@@ -138,9 +148,11 @@ def _register_hooks_in_settings(project_path: Path) -> Result[bool, str]:
|
|
|
138
148
|
|
|
139
149
|
def build_invar_hook(hook_type: str) -> dict:
|
|
140
150
|
"""Build Invar hook entry for a hook type."""
|
|
151
|
+
# Use $CLAUDE_PROJECT_DIR for portable paths that work regardless of cwd
|
|
152
|
+
# Claude Code provides this env var to all hooks (see code.claude.com/docs/hooks)
|
|
141
153
|
hook_cmd = {
|
|
142
154
|
"type": "command",
|
|
143
|
-
"command": f"
|
|
155
|
+
"command": f'"$CLAUDE_PROJECT_DIR"/.claude/hooks/{hook_type}.sh',
|
|
144
156
|
}
|
|
145
157
|
if hook_type in ("PreToolUse", "PostToolUse"):
|
|
146
158
|
# These need a matcher - use "*" to match all tools
|
|
@@ -239,20 +251,24 @@ def install_claude_hooks(
|
|
|
239
251
|
# Create merged wrapper
|
|
240
252
|
merged = f'''#!/bin/bash
|
|
241
253
|
# Merged hook: User + Invar (DX-57)
|
|
242
|
-
#
|
|
243
|
-
|
|
244
|
-
|
|
254
|
+
# Ensure correct working directory regardless of where Claude Code invokes from
|
|
255
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
256
|
+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
257
|
+
if ! cd "$PROJECT_ROOT" 2>/dev/null; then
|
|
258
|
+
echo "[invar] Warning: Could not cd to $PROJECT_ROOT" >&2
|
|
259
|
+
exit 0 # Don't block Claude Code
|
|
260
|
+
fi
|
|
245
261
|
|
|
246
|
-
# Run user hook first
|
|
247
|
-
if [[ -f "$
|
|
248
|
-
source "$
|
|
262
|
+
# Run user hook first (higher priority)
|
|
263
|
+
if [[ -f "$SCRIPT_DIR/{hook_type}.sh.user_backup" ]]; then
|
|
264
|
+
source "$SCRIPT_DIR/{hook_type}.sh.user_backup" "$@"
|
|
249
265
|
USER_EXIT=$?
|
|
250
266
|
[[ $USER_EXIT -ne 0 ]] && exit $USER_EXIT
|
|
251
267
|
fi
|
|
252
268
|
|
|
253
269
|
# Run Invar hook
|
|
254
|
-
if [[ -f "$
|
|
255
|
-
source "$
|
|
270
|
+
if [[ -f "$SCRIPT_DIR/invar.{hook_type}.sh" ]]; then
|
|
271
|
+
source "$SCRIPT_DIR/invar.{hook_type}.sh" "$@"
|
|
256
272
|
fi
|
|
257
273
|
'''
|
|
258
274
|
wrapper_hook.write_text(merged)
|
|
@@ -262,7 +278,14 @@ fi
|
|
|
262
278
|
# No user hook, create simple wrapper
|
|
263
279
|
wrapper = f'''#!/bin/bash
|
|
264
280
|
# Invar hook wrapper (DX-57)
|
|
265
|
-
|
|
281
|
+
# Ensure correct working directory regardless of where Claude Code invokes from
|
|
282
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
283
|
+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
284
|
+
if ! cd "$PROJECT_ROOT" 2>/dev/null; then
|
|
285
|
+
echo "[invar] Warning: Could not cd to $PROJECT_ROOT" >&2
|
|
286
|
+
exit 0 # Don't block Claude Code
|
|
287
|
+
fi
|
|
288
|
+
source "$SCRIPT_DIR/invar.{hook_type}.sh" "$@"
|
|
266
289
|
'''
|
|
267
290
|
wrapper_hook.write_text(wrapper)
|
|
268
291
|
wrapper_hook.chmod(0o755)
|
|
@@ -311,7 +334,7 @@ def sync_claude_hooks(
|
|
|
311
334
|
try:
|
|
312
335
|
existing_content = check_hook.read_text()
|
|
313
336
|
# Extract version from header comment
|
|
314
|
-
version_match = re.search(
|
|
337
|
+
version_match = re.search(PROTOCOL_VERSION_PATTERN, existing_content)
|
|
315
338
|
old_version = version_match.group(1) if version_match else "unknown"
|
|
316
339
|
|
|
317
340
|
if old_version != PROTOCOL_VERSION:
|
|
@@ -456,7 +479,7 @@ def hooks_status(
|
|
|
456
479
|
# Try to get version
|
|
457
480
|
try:
|
|
458
481
|
content = invar_hook.read_text()
|
|
459
|
-
match = re.search(
|
|
482
|
+
match = re.search(PROTOCOL_VERSION_PATTERN, content)
|
|
460
483
|
if match:
|
|
461
484
|
status[f"{hook_type}_version"] = match.group(1)
|
|
462
485
|
except OSError:
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for document tools.
|
|
3
|
+
|
|
4
|
+
DX-76: Structured document query and editing commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from returns.result import Success
|
|
16
|
+
|
|
17
|
+
from invar.shell.doc_tools import (
|
|
18
|
+
delete_section_content,
|
|
19
|
+
find_sections,
|
|
20
|
+
insert_section_content,
|
|
21
|
+
read_section,
|
|
22
|
+
read_toc,
|
|
23
|
+
replace_section_content,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Max content size for stdin reading (10MB) - matches parse_toc limit
|
|
27
|
+
MAX_STDIN_SIZE = 10_000_000
|
|
28
|
+
|
|
29
|
+
# Create doc subcommand app
|
|
30
|
+
doc_app = typer.Typer(
|
|
31
|
+
name="doc",
|
|
32
|
+
help="Structured document query and editing tools.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_stdin_limited() -> str:
|
|
38
|
+
"""Read from stdin with size limit to prevent OOM."""
|
|
39
|
+
content = sys.stdin.read(MAX_STDIN_SIZE + 1)
|
|
40
|
+
if len(content) > MAX_STDIN_SIZE:
|
|
41
|
+
raise typer.BadParameter(f"Input exceeds maximum size of {MAX_STDIN_SIZE} bytes")
|
|
42
|
+
return content
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_file_limited(path: Path) -> str:
|
|
46
|
+
"""Read file with size limit to prevent OOM.
|
|
47
|
+
|
|
48
|
+
Uses single read to avoid TOCTOU race between stat and read.
|
|
49
|
+
"""
|
|
50
|
+
content = path.read_text(encoding="utf-8")
|
|
51
|
+
if len(content) > MAX_STDIN_SIZE:
|
|
52
|
+
raise typer.BadParameter(f"File {path} exceeds maximum size of {MAX_STDIN_SIZE} bytes")
|
|
53
|
+
return content
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# @shell_orchestration: CLI helper for glob pattern resolution
|
|
57
|
+
def _resolve_glob(pattern: str) -> list[Path]:
|
|
58
|
+
"""Resolve glob pattern to list of files."""
|
|
59
|
+
path = Path(pattern)
|
|
60
|
+
if path.exists() and path.is_file():
|
|
61
|
+
return [path]
|
|
62
|
+
# Try as glob pattern
|
|
63
|
+
if "*" in pattern or "?" in pattern:
|
|
64
|
+
# Handle ** for recursive
|
|
65
|
+
if "**" in pattern:
|
|
66
|
+
base = Path()
|
|
67
|
+
matches = list(base.glob(pattern))
|
|
68
|
+
else:
|
|
69
|
+
matches = list(Path().glob(pattern))
|
|
70
|
+
return [p for p in matches if p.is_file()]
|
|
71
|
+
# Single file that doesn't exist
|
|
72
|
+
return [path]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# @shell_orchestration: CLI output formatter for text mode
|
|
76
|
+
# @shell_complexity: Recursive formatting with depth filtering
|
|
77
|
+
def _format_toc_text(toc_data: dict, depth: int | None = None) -> str:
|
|
78
|
+
"""Format TOC as human-readable text."""
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
|
|
81
|
+
if toc_data.get("frontmatter"):
|
|
82
|
+
fm = toc_data["frontmatter"]
|
|
83
|
+
lines.append(f"[frontmatter] ({fm['line_start']}-{fm['line_end']})")
|
|
84
|
+
|
|
85
|
+
def format_section(section: dict, current_depth: int = 1) -> None:
|
|
86
|
+
if depth is not None and section["level"] > depth:
|
|
87
|
+
return
|
|
88
|
+
indent = " " * (section["level"] - 1)
|
|
89
|
+
prefix = "#" * section["level"]
|
|
90
|
+
char_display = _format_size(section["char_count"])
|
|
91
|
+
lines.append(
|
|
92
|
+
f"{indent}{prefix} {section['title']} "
|
|
93
|
+
f"({section['line_start']}-{section['line_end']}, {char_display})"
|
|
94
|
+
)
|
|
95
|
+
for child in section.get("children", []):
|
|
96
|
+
format_section(child, current_depth + 1)
|
|
97
|
+
|
|
98
|
+
for section in toc_data.get("sections", []):
|
|
99
|
+
format_section(section)
|
|
100
|
+
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _format_size(chars: int) -> str:
|
|
105
|
+
"""Format character count as human-readable size."""
|
|
106
|
+
if chars >= 1000:
|
|
107
|
+
return f"{chars / 1000:.1f}K"
|
|
108
|
+
return f"{chars}B"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# @shell_orchestration: CLI helper for JSON serialization
|
|
112
|
+
def _section_to_dict(section) -> dict:
|
|
113
|
+
"""Convert Section to dict (recursive)."""
|
|
114
|
+
return {
|
|
115
|
+
"title": section.title,
|
|
116
|
+
"slug": section.slug,
|
|
117
|
+
"level": section.level,
|
|
118
|
+
"line_start": section.line_start,
|
|
119
|
+
"line_end": section.line_end,
|
|
120
|
+
"char_count": section.char_count,
|
|
121
|
+
"path": section.path,
|
|
122
|
+
"children": [_section_to_dict(c) for c in section.children],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# @shell_orchestration: CLI helper for depth filtering
|
|
127
|
+
def _filter_by_depth(sections: list[dict], max_depth: int) -> list[dict]:
|
|
128
|
+
"""Filter sections by maximum depth."""
|
|
129
|
+
result = []
|
|
130
|
+
for s in sections:
|
|
131
|
+
if s["level"] <= max_depth:
|
|
132
|
+
filtered = s.copy()
|
|
133
|
+
filtered["children"] = _filter_by_depth(s.get("children", []), max_depth)
|
|
134
|
+
result.append(filtered)
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# @invar:allow entry_point_too_thick: Multi-file glob + dual output format orchestration
|
|
139
|
+
@doc_app.command("toc")
|
|
140
|
+
def toc_command(
|
|
141
|
+
files: Annotated[
|
|
142
|
+
list[str],
|
|
143
|
+
typer.Argument(help="File(s) or glob pattern (e.g., 'docs/*.md')"),
|
|
144
|
+
],
|
|
145
|
+
depth: Annotated[
|
|
146
|
+
int | None,
|
|
147
|
+
typer.Option("--depth", "-d", help="Maximum heading depth (1-6)"),
|
|
148
|
+
] = None,
|
|
149
|
+
output_format: Annotated[
|
|
150
|
+
str,
|
|
151
|
+
typer.Option("--format", "-f", help="Output format: json or text"),
|
|
152
|
+
] = "json",
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Extract document structure (Table of Contents).
|
|
155
|
+
|
|
156
|
+
Shows headings hierarchy with line numbers and character counts.
|
|
157
|
+
"""
|
|
158
|
+
all_results: list[dict] = []
|
|
159
|
+
has_error = False
|
|
160
|
+
|
|
161
|
+
for pattern in files:
|
|
162
|
+
resolved = _resolve_glob(pattern)
|
|
163
|
+
if not resolved or (len(resolved) == 1 and not resolved[0].exists()):
|
|
164
|
+
typer.echo(f"Error: No files found matching '{pattern}'", err=True)
|
|
165
|
+
has_error = True
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
for path in resolved:
|
|
169
|
+
result = read_toc(path)
|
|
170
|
+
if isinstance(result, Success):
|
|
171
|
+
toc = result.unwrap()
|
|
172
|
+
from dataclasses import asdict
|
|
173
|
+
|
|
174
|
+
toc_dict = {
|
|
175
|
+
"file": str(path),
|
|
176
|
+
"sections": [_section_to_dict(s) for s in toc.sections],
|
|
177
|
+
"frontmatter": asdict(toc.frontmatter) if toc.frontmatter else None,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Apply depth filter if specified
|
|
181
|
+
if depth is not None:
|
|
182
|
+
toc_dict["sections"] = _filter_by_depth(toc_dict["sections"], depth)
|
|
183
|
+
|
|
184
|
+
all_results.append(toc_dict)
|
|
185
|
+
else:
|
|
186
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
187
|
+
has_error = True
|
|
188
|
+
|
|
189
|
+
if not all_results:
|
|
190
|
+
raise typer.Exit(1)
|
|
191
|
+
|
|
192
|
+
# Output
|
|
193
|
+
if output_format == "text":
|
|
194
|
+
for toc_data in all_results:
|
|
195
|
+
if len(all_results) > 1:
|
|
196
|
+
typer.echo(f"\n=== {toc_data['file']} ===")
|
|
197
|
+
typer.echo(_format_toc_text(toc_data, depth))
|
|
198
|
+
else:
|
|
199
|
+
# JSON output
|
|
200
|
+
if len(all_results) == 1:
|
|
201
|
+
typer.echo(json.dumps(all_results[0], indent=2))
|
|
202
|
+
else:
|
|
203
|
+
typer.echo(json.dumps({"files": all_results}, indent=2))
|
|
204
|
+
|
|
205
|
+
if has_error:
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# @invar:allow entry_point_too_thick: Section addressing + output format orchestration
|
|
210
|
+
@doc_app.command("read")
|
|
211
|
+
def read_command(
|
|
212
|
+
file: Annotated[Path, typer.Argument(help="Path to markdown file")],
|
|
213
|
+
section: Annotated[str, typer.Argument(help="Section path (slug, fuzzy, index, or @line)")],
|
|
214
|
+
include_children: Annotated[
|
|
215
|
+
bool,
|
|
216
|
+
typer.Option("--children/--no-children", help="Include child sections"),
|
|
217
|
+
] = True,
|
|
218
|
+
json_output: Annotated[
|
|
219
|
+
bool,
|
|
220
|
+
typer.Option("--json", "-j", help="Output as JSON"),
|
|
221
|
+
] = False,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Read a specific section from a document.
|
|
224
|
+
|
|
225
|
+
Section addressing:
|
|
226
|
+
- Slug path: "requirements/auth"
|
|
227
|
+
- Fuzzy: "auth" (matches first containing)
|
|
228
|
+
- Index: "#0/#1" (positional)
|
|
229
|
+
- Line anchor: "@48" (section at line 48)
|
|
230
|
+
"""
|
|
231
|
+
result = read_section(file, section, include_children=include_children)
|
|
232
|
+
|
|
233
|
+
if isinstance(result, Success):
|
|
234
|
+
content = result.unwrap()
|
|
235
|
+
if json_output:
|
|
236
|
+
typer.echo(json.dumps({"path": section, "content": content}, indent=2))
|
|
237
|
+
else:
|
|
238
|
+
typer.echo(content)
|
|
239
|
+
else:
|
|
240
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# @invar:allow entry_point_too_thick: Multi-file glob + pattern/level filtering orchestration
|
|
245
|
+
@doc_app.command("find")
|
|
246
|
+
def find_command(
|
|
247
|
+
pattern: Annotated[str, typer.Argument(help="Title pattern (glob-style, e.g., '*auth*')")],
|
|
248
|
+
files: Annotated[
|
|
249
|
+
list[str],
|
|
250
|
+
typer.Argument(help="File(s) or glob pattern"),
|
|
251
|
+
],
|
|
252
|
+
content: Annotated[
|
|
253
|
+
str | None,
|
|
254
|
+
typer.Option("--content", "-c", help="Content search pattern"),
|
|
255
|
+
] = None,
|
|
256
|
+
level: Annotated[
|
|
257
|
+
int | None,
|
|
258
|
+
typer.Option("--level", "-l", help="Filter by heading level (1-6)"),
|
|
259
|
+
] = None,
|
|
260
|
+
json_output: Annotated[
|
|
261
|
+
bool,
|
|
262
|
+
typer.Option("--json", "-j", help="Output as JSON"),
|
|
263
|
+
] = True,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Find sections matching a pattern.
|
|
266
|
+
|
|
267
|
+
Supports glob patterns for titles and optional content search.
|
|
268
|
+
"""
|
|
269
|
+
all_matches: list[dict] = []
|
|
270
|
+
has_error = False
|
|
271
|
+
|
|
272
|
+
for file_pattern in files:
|
|
273
|
+
resolved = _resolve_glob(file_pattern)
|
|
274
|
+
if not resolved or (len(resolved) == 1 and not resolved[0].exists()):
|
|
275
|
+
typer.echo(f"Error: No files found matching '{file_pattern}'", err=True)
|
|
276
|
+
has_error = True
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
for path in resolved:
|
|
280
|
+
result = find_sections(path, pattern, content, level=level)
|
|
281
|
+
if isinstance(result, Success):
|
|
282
|
+
sections = result.unwrap()
|
|
283
|
+
for s in sections:
|
|
284
|
+
all_matches.append({
|
|
285
|
+
"file": str(path),
|
|
286
|
+
"path": s.path,
|
|
287
|
+
"title": s.title,
|
|
288
|
+
"level": s.level,
|
|
289
|
+
"line_start": s.line_start,
|
|
290
|
+
"line_end": s.line_end,
|
|
291
|
+
"char_count": s.char_count,
|
|
292
|
+
})
|
|
293
|
+
else:
|
|
294
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
295
|
+
has_error = True
|
|
296
|
+
|
|
297
|
+
if json_output:
|
|
298
|
+
typer.echo(json.dumps({"matches": all_matches}, indent=2))
|
|
299
|
+
else:
|
|
300
|
+
for m in all_matches:
|
|
301
|
+
typer.echo(f"{m['file']}:{m['line_start']} {m['path']} ({m['char_count']}B)")
|
|
302
|
+
|
|
303
|
+
if has_error:
|
|
304
|
+
raise typer.Exit(1)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# @invar:allow entry_point_too_thick: Content input + section replacement orchestration
|
|
308
|
+
@doc_app.command("replace")
|
|
309
|
+
def replace_command(
|
|
310
|
+
file: Annotated[Path, typer.Argument(help="Path to markdown file")],
|
|
311
|
+
section: Annotated[str, typer.Argument(help="Section path to replace")],
|
|
312
|
+
content_file: Annotated[
|
|
313
|
+
Path | None,
|
|
314
|
+
typer.Option("--content", "-c", help="File containing new content (use - for stdin)"),
|
|
315
|
+
] = None,
|
|
316
|
+
keep_heading: Annotated[
|
|
317
|
+
bool,
|
|
318
|
+
typer.Option("--keep-heading/--no-keep-heading", help="Preserve original heading"),
|
|
319
|
+
] = True,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Replace a section's content.
|
|
322
|
+
|
|
323
|
+
Content can be provided via --content file or stdin.
|
|
324
|
+
"""
|
|
325
|
+
# Read content from file or stdin
|
|
326
|
+
if content_file is None:
|
|
327
|
+
typer.echo("Reading content from stdin (Ctrl+D to end)...", err=True)
|
|
328
|
+
new_content = _read_stdin_limited()
|
|
329
|
+
elif str(content_file) == "-":
|
|
330
|
+
new_content = _read_stdin_limited()
|
|
331
|
+
else:
|
|
332
|
+
new_content = _read_file_limited(content_file)
|
|
333
|
+
|
|
334
|
+
result = replace_section_content(file, section, new_content, keep_heading)
|
|
335
|
+
|
|
336
|
+
if isinstance(result, Success):
|
|
337
|
+
info = result.unwrap()
|
|
338
|
+
typer.echo(json.dumps({"success": True, **info}, indent=2))
|
|
339
|
+
else:
|
|
340
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
341
|
+
raise typer.Exit(1)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# @invar:allow entry_point_too_thick: Content input + position-based insertion orchestration
|
|
345
|
+
@doc_app.command("insert")
|
|
346
|
+
def insert_command(
|
|
347
|
+
file: Annotated[Path, typer.Argument(help="Path to markdown file")],
|
|
348
|
+
anchor: Annotated[str, typer.Argument(help="Section path for anchor")],
|
|
349
|
+
content_file: Annotated[
|
|
350
|
+
Path | None,
|
|
351
|
+
typer.Option("--content", "-c", help="File containing content to insert"),
|
|
352
|
+
] = None,
|
|
353
|
+
position: Annotated[
|
|
354
|
+
str,
|
|
355
|
+
typer.Option("--position", "-p", help="Where to insert: before, after, first_child, last_child"),
|
|
356
|
+
] = "after",
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Insert new content relative to a section.
|
|
359
|
+
|
|
360
|
+
Content should include heading if adding a new section.
|
|
361
|
+
"""
|
|
362
|
+
valid_positions = ("before", "after", "first_child", "last_child")
|
|
363
|
+
if position not in valid_positions:
|
|
364
|
+
typer.echo(f"Error: position must be one of {valid_positions}", err=True)
|
|
365
|
+
raise typer.Exit(1)
|
|
366
|
+
|
|
367
|
+
# Read content from file or stdin
|
|
368
|
+
if content_file is None:
|
|
369
|
+
typer.echo("Reading content from stdin (Ctrl+D to end)...", err=True)
|
|
370
|
+
content = _read_stdin_limited()
|
|
371
|
+
elif str(content_file) == "-":
|
|
372
|
+
content = _read_stdin_limited()
|
|
373
|
+
else:
|
|
374
|
+
content = _read_file_limited(content_file)
|
|
375
|
+
|
|
376
|
+
from typing import Literal
|
|
377
|
+
pos: Literal["before", "after", "first_child", "last_child"] = position # type: ignore[assignment]
|
|
378
|
+
result = insert_section_content(file, anchor, content, pos)
|
|
379
|
+
|
|
380
|
+
if isinstance(result, Success):
|
|
381
|
+
info = result.unwrap()
|
|
382
|
+
typer.echo(json.dumps({"success": True, **info}, indent=2))
|
|
383
|
+
else:
|
|
384
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
385
|
+
raise typer.Exit(1)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# @invar:allow entry_point_too_thick: Section deletion with children handling
|
|
389
|
+
@doc_app.command("delete")
|
|
390
|
+
def delete_command(
|
|
391
|
+
file: Annotated[Path, typer.Argument(help="Path to markdown file")],
|
|
392
|
+
section: Annotated[str, typer.Argument(help="Section path to delete")],
|
|
393
|
+
include_children: Annotated[
|
|
394
|
+
bool,
|
|
395
|
+
typer.Option("--children/--no-children", help="Include child sections in deletion"),
|
|
396
|
+
] = True,
|
|
397
|
+
) -> None:
|
|
398
|
+
"""Delete a section from a document.
|
|
399
|
+
|
|
400
|
+
Removes the heading and all content until the next same-level heading.
|
|
401
|
+
"""
|
|
402
|
+
result = delete_section_content(file, section, include_children=include_children)
|
|
403
|
+
|
|
404
|
+
if isinstance(result, Success):
|
|
405
|
+
info = result.unwrap()
|
|
406
|
+
typer.echo(json.dumps({"success": True, **info}, indent=2))
|
|
407
|
+
else:
|
|
408
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
409
|
+
raise typer.Exit(1)
|
invar/shell/commands/guard.py
CHANGED
|
@@ -36,6 +36,11 @@ app = typer.Typer(
|
|
|
36
36
|
)
|
|
37
37
|
console = Console()
|
|
38
38
|
|
|
39
|
+
# DX-76: Register doc subcommand
|
|
40
|
+
from invar.shell.commands.doc import doc_app
|
|
41
|
+
|
|
42
|
+
app.add_typer(doc_app, name="doc")
|
|
43
|
+
|
|
39
44
|
|
|
40
45
|
# @shell_orchestration: Statistics helper for CLI guard output
|
|
41
46
|
# @shell_complexity: Iterates symbols checking kind and contracts (4 branches minimal)
|
|
@@ -158,6 +163,8 @@ def guard(
|
|
|
158
163
|
Use --suggest to get functional pattern suggestions (NewType, Validation, etc.).
|
|
159
164
|
Use --contracts-only (-c) to check contract coverage without running tests (DX-63).
|
|
160
165
|
"""
|
|
166
|
+
# LX-06: Language detection and dispatch
|
|
167
|
+
from invar.shell.commands.init import detect_language
|
|
161
168
|
from invar.shell.guard_helpers import (
|
|
162
169
|
collect_files_to_check,
|
|
163
170
|
handle_changed_mode,
|
|
@@ -168,7 +175,38 @@ def guard(
|
|
|
168
175
|
)
|
|
169
176
|
from invar.shell.testing import VerificationLevel
|
|
170
177
|
|
|
171
|
-
|
|
178
|
+
project_language = detect_language(path if path.is_dir() else find_project_root(path))
|
|
179
|
+
|
|
180
|
+
# Dispatch to language-specific guard if not Python
|
|
181
|
+
if project_language == "typescript":
|
|
182
|
+
from invar.shell.prove.guard_ts import run_typescript_guard
|
|
183
|
+
|
|
184
|
+
ts_result = run_typescript_guard(path if path.is_dir() else find_project_root(path))
|
|
185
|
+
match ts_result:
|
|
186
|
+
case Success(result):
|
|
187
|
+
if json_output or agent:
|
|
188
|
+
import json as json_mod
|
|
189
|
+
|
|
190
|
+
from invar.shell.prove.guard_ts import format_typescript_guard_v2
|
|
191
|
+
|
|
192
|
+
output = format_typescript_guard_v2(result)
|
|
193
|
+
console.print(json_mod.dumps(output, indent=2))
|
|
194
|
+
else:
|
|
195
|
+
console.print(f"[bold]TypeScript Guard[/bold] ({project_language})")
|
|
196
|
+
if result.status == "passed":
|
|
197
|
+
console.print("[green]✓ PASSED[/green]")
|
|
198
|
+
elif result.status == "skipped":
|
|
199
|
+
console.print("[yellow]⚠ SKIPPED[/yellow] (no TypeScript tools available)")
|
|
200
|
+
else:
|
|
201
|
+
console.print(f"[red]✗ FAILED[/red] ({result.error_count} errors)")
|
|
202
|
+
for v in result.violations[:10]: # Show first 10
|
|
203
|
+
console.print(f" {v.file}:{v.line}: [{v.severity}] {v.message}")
|
|
204
|
+
raise typer.Exit(0 if result.status == "passed" else 1)
|
|
205
|
+
case Failure(err):
|
|
206
|
+
console.print(f"[red]Error:[/red] {err}")
|
|
207
|
+
raise typer.Exit(1)
|
|
208
|
+
|
|
209
|
+
# DX-65: Handle single file mode (Python only from here)
|
|
172
210
|
single_file_mode = path.is_file()
|
|
173
211
|
single_file: Path | None = None
|
|
174
212
|
if single_file_mode:
|
|
@@ -519,6 +557,7 @@ def rules(
|
|
|
519
557
|
from invar.shell.commands.hooks import app as hooks_app # DX-57
|
|
520
558
|
from invar.shell.commands.init import init
|
|
521
559
|
from invar.shell.commands.mutate import mutate # DX-28
|
|
560
|
+
from invar.shell.commands.skill import app as skill_app # LX-07
|
|
522
561
|
from invar.shell.commands.sync_self import sync_self # DX-49
|
|
523
562
|
from invar.shell.commands.test import test, verify
|
|
524
563
|
from invar.shell.commands.uninstall import uninstall # DX-69
|
|
@@ -531,6 +570,7 @@ app.command()(test)
|
|
|
531
570
|
app.command()(verify)
|
|
532
571
|
app.command()(mutate) # DX-28: Mutation testing
|
|
533
572
|
app.add_typer(hooks_app, name="hooks") # DX-57: Claude Code hooks management
|
|
573
|
+
app.add_typer(skill_app, name="skill") # LX-07: Extension skills management
|
|
534
574
|
|
|
535
575
|
# DX-56: Create dev subcommand group for developer commands
|
|
536
576
|
dev_app = typer.Typer(
|