invar-tools 1.10.0__py3-none-any.whl → 1.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
invar/shell/py_refs.py ADDED
@@ -0,0 +1,156 @@
1
+ """Python reference finding using jedi.
2
+
3
+ DX-78: Provides cross-file reference finding for Python symbols.
4
+ Shell module: Uses jedi library for I/O-based symbol analysis.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import jedi
13
+
14
+
15
+ @dataclass
16
+ class Reference:
17
+ """A reference to a Python symbol."""
18
+
19
+ file: Path
20
+ line: int
21
+ column: int
22
+ context: str
23
+ is_definition: bool = False
24
+
25
+
26
+ # @shell_complexity: Reference finding with jedi library and error handling
27
+ def find_references(
28
+ file_path: Path,
29
+ line: int,
30
+ column: int,
31
+ project_root: Path | None = None,
32
+ ) -> list[Reference]:
33
+ """Find all references to symbol at position using jedi.
34
+
35
+ Args:
36
+ file_path: File containing the symbol
37
+ line: 1-based line number
38
+ column: 0-based column number
39
+ project_root: Project root for cross-file resolution
40
+
41
+ Returns:
42
+ List of references found
43
+
44
+ >>> from pathlib import Path
45
+ >>> import tempfile, os
46
+ >>> # Test with a simple Python file
47
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
48
+ ... _ = f.write('def hello():\\n pass\\n')
49
+ ... temp_file = Path(f.name)
50
+ >>> # Find references returns a list (may be empty if jedi not configured)
51
+ >>> refs = find_references(temp_file, 1, 4)
52
+ >>> isinstance(refs, list)
53
+ True
54
+ >>> os.unlink(temp_file)
55
+ """
56
+ source = file_path.read_text(encoding="utf-8")
57
+
58
+ project = None
59
+ if project_root:
60
+ project = jedi.Project(path=str(project_root))
61
+
62
+ script = jedi.Script(source, path=str(file_path), project=project)
63
+ refs = script.get_references(line, column)
64
+
65
+ results: list[Reference] = []
66
+ for ref in refs:
67
+ # Skip builtins (no module_path)
68
+ if not ref.module_path:
69
+ continue
70
+
71
+ line_code = ref.get_line_code()
72
+ context = line_code.strip() if line_code else ""
73
+
74
+ results.append(
75
+ Reference(
76
+ file=Path(ref.module_path),
77
+ line=ref.line,
78
+ column=ref.column,
79
+ context=context,
80
+ is_definition=ref.is_definition(),
81
+ )
82
+ )
83
+
84
+ return results
85
+
86
+
87
+ # @shell_complexity: Symbol search using jedi library
88
+ def find_symbol_position(file_path: Path, symbol_name: str) -> tuple[int, int] | None:
89
+ """Find the position of a symbol definition in a file.
90
+
91
+ Args:
92
+ file_path: File to search
93
+ symbol_name: Name of the symbol to find
94
+
95
+ Returns:
96
+ Tuple of (line, column) or None if not found
97
+
98
+ >>> from pathlib import Path
99
+ >>> import tempfile, os
100
+ >>> # Test finding a function definition
101
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
102
+ ... _ = f.write('def test_func():\\n return 42\\n')
103
+ ... temp_file = Path(f.name)
104
+ >>> pos = find_symbol_position(temp_file, "test_func")
105
+ >>> isinstance(pos, tuple) or pos is None # Returns tuple or None
106
+ True
107
+ >>> os.unlink(temp_file)
108
+ """
109
+ source = file_path.read_text(encoding="utf-8")
110
+ script = jedi.Script(source, path=str(file_path))
111
+
112
+ # Get all names defined in the file
113
+ names = script.get_names(all_scopes=True)
114
+
115
+ for name in names:
116
+ if name.name == symbol_name and name.is_definition():
117
+ return (name.line, name.column)
118
+
119
+ return None
120
+
121
+
122
+ # @shell_complexity: Combines symbol position lookup and reference finding
123
+ def find_all_references_to_symbol(
124
+ file_path: Path,
125
+ symbol_name: str,
126
+ project_root: Path | None = None,
127
+ ) -> list[Reference]:
128
+ """Find all references to a named symbol.
129
+
130
+ Convenience function that combines find_symbol_position and find_references.
131
+
132
+ Args:
133
+ file_path: File containing the symbol definition
134
+ symbol_name: Name of the symbol
135
+ project_root: Project root for cross-file resolution
136
+
137
+ Returns:
138
+ List of references found
139
+
140
+ >>> from pathlib import Path
141
+ >>> import tempfile, os
142
+ >>> # Test finding all references to a symbol
143
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
144
+ ... _ = f.write('def greet():\\n pass\\ngreet()\\n')
145
+ ... temp_file = Path(f.name)
146
+ >>> refs = find_all_references_to_symbol(temp_file, "greet")
147
+ >>> isinstance(refs, list) # Returns list of references
148
+ True
149
+ >>> os.unlink(temp_file)
150
+ """
151
+ position = find_symbol_position(file_path, symbol_name)
152
+ if position is None:
153
+ return []
154
+
155
+ line, column = position
156
+ return find_references(file_path, line, column, project_root)
@@ -11,6 +11,7 @@ DX-71: Simplified to idempotent `add` command with region merge.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import re
14
15
  import shutil
15
16
  from dataclasses import dataclass
16
17
  from pathlib import Path
@@ -35,12 +36,15 @@ CORE_SKILLS = {"develop", "review", "investigate", "propose", "guard", "audit"}
35
36
 
36
37
  # @shell_orchestration: Validation helper used only by shell add_skill/remove_skill
37
38
  def _is_valid_skill_name(name: str) -> bool:
38
- """Validate skill name to prevent path traversal attacks."""
39
- # Block path traversal characters
40
- if ".." in name or "/" in name or "\\" in name:
39
+ """Validate skill name to prevent path traversal and filesystem attacks."""
40
+ # Block path traversal characters and null bytes
41
+ if ".." in name or "/" in name or "\\" in name or "\x00" in name:
41
42
  return False
42
- # Must be non-empty and not start with dot or underscore
43
- return bool(name) and not name.startswith(".") and not name.startswith("_")
43
+ # Block special names that could cause issues
44
+ if name in (".", ""):
45
+ return False
46
+ # Must not start with dot or underscore
47
+ return not name.startswith(".") and not name.startswith("_")
44
48
 
45
49
 
46
50
  def _merge_md_file(src: Path, dst: Path) -> tuple[bool, str]:
@@ -74,10 +78,10 @@ def _merge_md_file(src: Path, dst: Path) -> tuple[bool, str]:
74
78
  shutil.copy2(src, dst)
75
79
  return False, "Updated"
76
80
 
77
- except Exception:
78
- # On parse error, preserve existing file - don't silently lose user data
79
- # Return warning message so caller can inform user
80
- return False, "Skipped (merge failed, existing file preserved)"
81
+ except (OSError, UnicodeDecodeError, ValueError, KeyError) as e:
82
+ # On I/O or parse error, preserve existing file - don't silently lose user data
83
+ # Include error details for debugging
84
+ return False, f"Skipped (merge failed: {type(e).__name__}: {e})"
81
85
 
82
86
 
83
87
  @dataclass
@@ -109,7 +113,7 @@ def load_registry() -> Result[dict, str]:
109
113
  content = registry_path.read_text()
110
114
  data = yaml.safe_load(content)
111
115
  return Success(data)
112
- except Exception as e:
116
+ except (yaml.YAMLError, OSError, UnicodeDecodeError) as e:
113
117
  return Failure(f"Failed to parse registry: {e}")
114
118
 
115
119
 
@@ -245,7 +249,7 @@ def add_skill(
245
249
  result_msg = "updated" if is_update else "installed"
246
250
  return Success(f"Skill '{skill_name}' {result_msg} successfully")
247
251
 
248
- except Exception as e:
252
+ except (OSError, shutil.Error) as e:
249
253
  # Clean up on failure (only for fresh install)
250
254
  # M3 note: Updates that fail mid-way may leave directory in partial state.
251
255
  # This is acceptable because: (1) user extensions are preserved via merge,
@@ -258,8 +262,6 @@ def add_skill(
258
262
 
259
263
  def has_user_extensions(skill_dir: Path) -> bool:
260
264
  """Check if SKILL.md has user content in extensions region."""
261
- import re
262
-
263
265
  skill_md = skill_dir / "SKILL.md"
264
266
  if not skill_md.exists():
265
267
  return False
@@ -283,7 +285,7 @@ def has_user_extensions(skill_dir: Path) -> bool:
283
285
 
284
286
  # Check if any non-whitespace content remains
285
287
  return bool(cleaned.strip())
286
- except Exception:
288
+ except (ValueError, KeyError):
287
289
  # Parse error - assume extensions exist (safe default)
288
290
  return True
289
291
 
@@ -334,7 +336,7 @@ def remove_skill(
334
336
  try:
335
337
  shutil.rmtree(dest_dir)
336
338
  return Success(f"Skill '{skill_name}' removed successfully")
337
- except Exception as e:
339
+ except (OSError, shutil.Error) as e:
338
340
  return Failure(f"Failed to remove skill: {e}")
339
341
 
340
342
 
@@ -0,0 +1,238 @@
1
+ """TypeScript Compiler API wrapper (single-shot subprocess).
2
+
3
+ DX-78: Provides Python interface to ts-query.js for TypeScript analysis.
4
+
5
+ Architecture:
6
+ - Single-shot subprocess: starts, runs query, exits
7
+ - No persistent process, no orphan risk
8
+ - Falls back to regex parser if Node.js unavailable
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from returns.result import Failure, Result, Success
20
+
21
+
22
+ @dataclass
23
+ class TSSymbolInfo:
24
+ """TypeScript symbol information from Compiler API."""
25
+
26
+ name: str
27
+ kind: str
28
+ signature: str
29
+ line: int
30
+ file: str = ""
31
+ contracts: dict[str, list[str]] | None = None
32
+ members: list[dict[str, Any]] | None = None
33
+
34
+
35
+ @dataclass
36
+ class TSReference:
37
+ """A reference to a TypeScript symbol."""
38
+
39
+ file: str
40
+ line: int
41
+ column: int
42
+ context: str
43
+ is_definition: bool = False
44
+
45
+
46
+ def _find_ts_query_js() -> Path:
47
+ """Find the ts-query.js script bundled with invar-tools."""
48
+ # Look relative to this file's location
49
+ this_dir = Path(__file__).parent.parent
50
+ ts_query_path = this_dir / "node_tools" / "ts-query.js"
51
+
52
+ if ts_query_path.exists():
53
+ return ts_query_path
54
+
55
+ # Fallback: check if installed globally or in node_modules
56
+ raise FileNotFoundError("ts-query.js not found")
57
+
58
+
59
+ # @shell_complexity: Project root discovery with parent traversal
60
+ def _find_tsconfig_root(file_path: Path) -> Path:
61
+ """Find the project root containing tsconfig.json."""
62
+ current = file_path.parent if file_path.is_file() else file_path
63
+
64
+ while current != current.parent:
65
+ if (current / "tsconfig.json").exists():
66
+ return current
67
+ current = current.parent
68
+
69
+ # Fallback to file's directory
70
+ return file_path.parent if file_path.is_file() else file_path
71
+
72
+
73
+ # @shell_complexity: Subprocess orchestration with error handling and JSON parsing
74
+ def query_typescript(
75
+ project_root: Path,
76
+ command: str,
77
+ **params: Any,
78
+ ) -> Result[dict[str, Any], str]:
79
+ """Run ts-query.js and return parsed result.
80
+
81
+ Single-shot subprocess: starts, runs query, exits.
82
+ No persistent process, no orphan risk.
83
+
84
+ Args:
85
+ project_root: Project root containing tsconfig.json
86
+ command: Query command (sig, map, refs)
87
+ **params: Command-specific parameters
88
+
89
+ Returns:
90
+ Parsed JSON result or error message
91
+ """
92
+ try:
93
+ ts_query_path = _find_ts_query_js()
94
+ except FileNotFoundError:
95
+ return Failure("ts-query.js not found. Install Node.js to use TypeScript tools.")
96
+
97
+ query = {"command": command, **params}
98
+
99
+ try:
100
+ result = subprocess.run(
101
+ ["node", str(ts_query_path), json.dumps(query)],
102
+ cwd=str(project_root),
103
+ capture_output=True,
104
+ text=True,
105
+ timeout=30, # Safety timeout
106
+ )
107
+
108
+ if result.returncode != 0:
109
+ error_msg = result.stderr.strip() if result.stderr else "Unknown error"
110
+ return Failure(f"ts-query failed: {error_msg}")
111
+
112
+ output = json.loads(result.stdout)
113
+
114
+ # Check for error in output
115
+ if "error" in output:
116
+ return Failure(output["error"])
117
+
118
+ return Success(output)
119
+
120
+ except subprocess.TimeoutExpired:
121
+ return Failure("TypeScript query timed out (30s)")
122
+ except FileNotFoundError:
123
+ return Failure(
124
+ "Node.js not found.\n\n"
125
+ "To use TypeScript tools, install Node.js:\n"
126
+ "- macOS: brew install node\n"
127
+ "- Ubuntu: apt install nodejs\n"
128
+ "- Windows: https://nodejs.org/"
129
+ )
130
+ except json.JSONDecodeError as e:
131
+ return Failure(f"Invalid JSON from ts-query: {e}")
132
+ except Exception as e:
133
+ return Failure(f"TypeScript query error: {e}")
134
+
135
+
136
+ def run_sig_typescript(file_path: Path) -> Result[list[TSSymbolInfo], str]:
137
+ """Get signatures for TypeScript file using Compiler API.
138
+
139
+ Args:
140
+ file_path: Path to TypeScript file
141
+
142
+ Returns:
143
+ List of symbol information or error message
144
+ """
145
+ project_root = _find_tsconfig_root(file_path)
146
+ result = query_typescript(project_root, "sig", file=str(file_path))
147
+
148
+ if isinstance(result, Failure):
149
+ return result
150
+
151
+ data = result.unwrap()
152
+ symbols = []
153
+
154
+ for sym in data.get("symbols", []):
155
+ symbols.append(
156
+ TSSymbolInfo(
157
+ name=sym.get("name", ""),
158
+ kind=sym.get("kind", ""),
159
+ signature=sym.get("signature", ""),
160
+ line=sym.get("line", 0),
161
+ file=str(file_path),
162
+ contracts=sym.get("contracts"),
163
+ members=sym.get("members"),
164
+ )
165
+ )
166
+
167
+ return Success(symbols)
168
+
169
+
170
+ def run_map_typescript(path: Path, top_n: int) -> Result[dict[str, Any], str]:
171
+ """Get symbol map with reference counts for TypeScript project.
172
+
173
+ Args:
174
+ path: Project path to scan
175
+ top_n: Maximum number of symbols to return
176
+
177
+ Returns:
178
+ Symbol map data or error message
179
+ """
180
+ return query_typescript(path, "map", path=str(path), top=top_n)
181
+
182
+
183
+ def run_refs_typescript(
184
+ file_path: Path, line: int, column: int
185
+ ) -> Result[list[TSReference], str]:
186
+ """Find all references to symbol at position.
187
+
188
+ Args:
189
+ file_path: File containing the symbol
190
+ line: 1-based line number
191
+ column: 0-based column number
192
+
193
+ Returns:
194
+ List of references or error message
195
+ """
196
+ project_root = _find_tsconfig_root(file_path)
197
+ result = query_typescript(
198
+ project_root,
199
+ "refs",
200
+ file=str(file_path),
201
+ line=line,
202
+ column=column,
203
+ )
204
+
205
+ if isinstance(result, Failure):
206
+ return result
207
+
208
+ data = result.unwrap()
209
+ references = []
210
+
211
+ for ref in data.get("references", []):
212
+ references.append(
213
+ TSReference(
214
+ file=ref.get("file", ""),
215
+ line=ref.get("line", 0),
216
+ column=ref.get("column", 0),
217
+ context=ref.get("context", ""),
218
+ is_definition=ref.get("isDefinition", False),
219
+ )
220
+ )
221
+
222
+ return Success(references)
223
+
224
+
225
+ def is_typescript_available() -> bool:
226
+ """Check if TypeScript Compiler API tools are available."""
227
+ try:
228
+ _find_ts_query_js()
229
+ # Also check if Node.js is available
230
+ result = subprocess.run(
231
+ ["node", "--version"],
232
+ capture_output=True,
233
+ text=True,
234
+ timeout=5,
235
+ )
236
+ return result.returncode == 0
237
+ except (FileNotFoundError, subprocess.TimeoutExpired):
238
+ return False
@@ -0,0 +1,193 @@
1
+ # TypeScript Patterns for Agents
2
+
3
+ Reference patterns for AI agents working with TypeScript under Invar Protocol.
4
+
5
+ ## Tool × Feature Matrix
6
+
7
+ | Feature | TypeScript Pattern | Tool Command |
8
+ |---------|-------------------|--------------|
9
+ | Signatures | `function name(params): Return` | `invar sig file.ts` |
10
+ | Contracts | `@pre`, `@post` JSDoc + Zod | `invar sig file.ts` |
11
+ | References | Cross-file symbol usage | `invar refs file.ts::Symbol` |
12
+ | Verification | tsc + eslint + vitest | `invar guard` |
13
+ | Document nav | Markdown structure | `invar doc toc file.md` |
14
+
15
+ ---
16
+
17
+ ## Pattern 1: Preconditions with Zod
18
+
19
+ ```typescript
20
+ import { z } from 'zod';
21
+
22
+ // Schema IS the precondition
23
+ const UserInputSchema = z.object({
24
+ email: z.string().email(),
25
+ age: z.number().int().positive().max(150),
26
+ });
27
+
28
+ type UserInput = z.infer<typeof UserInputSchema>;
29
+
30
+ /**
31
+ * Create a user with validated input.
32
+ * @pre UserInputSchema.parse(input) succeeds
33
+ * @post result.id is set
34
+ */
35
+ function createUser(input: UserInput): User {
36
+ // Zod already validated - safe to use
37
+ return { id: generateId(), ...input };
38
+ }
39
+ ```
40
+
41
+ **Agent workflow:**
42
+ 1. Define Zod schema FIRST (the @pre)
43
+ 2. Derive TypeScript type from schema
44
+ 3. Implement function body
45
+ 4. Zod validates at runtime
46
+
47
+ ---
48
+
49
+ ## Pattern 2: Postconditions
50
+
51
+ ```typescript
52
+ /**
53
+ * Calculate discount price.
54
+ * @pre price > 0 && discount >= 0 && discount <= 1
55
+ * @post result >= 0 && result <= price
56
+ */
57
+ function applyDiscount(price: number, discount: number): number {
58
+ const result = price * (1 - discount);
59
+
60
+ // Postcondition check (development only)
61
+ console.assert(result >= 0 && result <= price,
62
+ `Postcondition failed: ${result}`);
63
+
64
+ return result;
65
+ }
66
+ ```
67
+
68
+ **Note:** Unlike Python's `@post` decorator, TypeScript postconditions
69
+ are documented in JSDoc and checked manually or via assertion.
70
+
71
+ ---
72
+
73
+ ## Pattern 3: Core/Shell Separation
74
+
75
+ ```typescript
76
+ // ─── Core (Pure) ───
77
+ // No I/O, no side effects, only data transformations
78
+
79
+ function validateEmail(email: string): boolean {
80
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
81
+ }
82
+
83
+ function calculateTotal(items: CartItem[]): number {
84
+ return items.reduce((sum, item) => sum + item.price * item.qty, 0);
85
+ }
86
+
87
+ // ─── Shell (I/O) ───
88
+ // All external interactions, returns Result<T, E>
89
+
90
+ import { Result, ok, err } from 'neverthrow';
91
+
92
+ async function fetchUser(id: string): Promise<Result<User, ApiError>> {
93
+ try {
94
+ const response = await fetch(`/api/users/${id}`);
95
+ if (!response.ok) return err({ code: response.status });
96
+ return ok(await response.json());
97
+ } catch (e) {
98
+ return err({ code: 500, message: String(e) });
99
+ }
100
+ }
101
+ ```
102
+
103
+ **Agent checklist:**
104
+ - [ ] Core functions: No imports from `fs`, `http`, `fetch`, etc.
105
+ - [ ] Shell functions: Return `Result<T, E>` for fallible operations
106
+ - [ ] Dependency injection: Pass data to Core, not paths
107
+
108
+ ---
109
+
110
+ ## Pattern 4: Exhaustive Switch
111
+
112
+ ```typescript
113
+ type Status = 'pending' | 'approved' | 'rejected';
114
+
115
+ function getStatusMessage(status: Status): string {
116
+ switch (status) {
117
+ case 'pending': return 'Waiting for review';
118
+ case 'approved': return 'Request approved';
119
+ case 'rejected': return 'Request denied';
120
+ default:
121
+ // TypeScript ensures this is never reached
122
+ const _exhaustive: never = status;
123
+ throw new Error(`Unknown status: ${_exhaustive}`);
124
+ }
125
+ }
126
+ ```
127
+
128
+ **Why:** Adding a new status forces handling in all switches.
129
+
130
+ ---
131
+
132
+ ## Pattern 5: Branded Types
133
+
134
+ ```typescript
135
+ // Nominal typing for semantic safety
136
+ type UserId = string & { readonly __brand: 'UserId' };
137
+ type OrderId = string & { readonly __brand: 'OrderId' };
138
+
139
+ function createUserId(id: string): UserId {
140
+ return id as UserId;
141
+ }
142
+
143
+ // Compiler prevents mixing IDs
144
+ function getUser(id: UserId): User { ... }
145
+ function getOrder(id: OrderId): Order { ... }
146
+
147
+ getUser(orderId); // ❌ Type error: OrderId is not UserId
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Tool Usage Examples
153
+
154
+ ### View signatures
155
+
156
+ ```bash
157
+ $ invar sig src/auth.ts
158
+ src/auth.ts
159
+ function validateToken(token: string): boolean
160
+ @pre token.length > 0
161
+ @post result indicates valid JWT
162
+
163
+ class AuthService
164
+ method login(email: string, password: string): Promise<Result<Token, AuthError>>
165
+ method logout(): Promise<void>
166
+ ```
167
+
168
+ ### Find references
169
+
170
+ ```bash
171
+ $ invar refs src/auth.ts::validateToken
172
+ src/auth.ts:15 — Definition
173
+ src/routes/api.ts:42 — if (validateToken(req.headers.auth)) {
174
+ src/middleware/auth.ts:18 — const isValid = validateToken(token);
175
+ tests/auth.test.ts:8 — expect(validateToken('invalid')).toBe(false);
176
+ ```
177
+
178
+ ### Verify code
179
+
180
+ ```bash
181
+ $ invar guard
182
+ TypeScript Guard Report
183
+ ========================================
184
+ [PASS] tsc --noEmit (no type errors)
185
+ [PASS] eslint (0 errors, 2 warnings)
186
+ [PASS] vitest (24 tests passed)
187
+ ----------------------------------------
188
+ Guard passed.
189
+ ```
190
+
191
+ ---
192
+
193
+ *Managed by Invar - regenerated on `invar update`*