invar-tools 1.17.18__py3-none-any.whl → 1.17.20__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.
@@ -16,6 +16,7 @@
16
16
  import { ESLint } from 'eslint';
17
17
  import { resolve, dirname } from 'path';
18
18
  import { statSync, realpathSync } from 'fs';
19
+ import { spawnSync } from 'child_process';
19
20
  import { fileURLToPath } from 'url';
20
21
  import { createRequire } from 'module';
21
22
  import plugin from './index.js';
@@ -27,12 +28,49 @@ const require = createRequire(import.meta.url);
27
28
 
28
29
  function resolveTsParser(projectPath) {
29
30
  try {
30
- return require.resolve('@typescript-eslint/parser', { paths: [projectPath, __dirname] });
31
+ const tseslintEntry = require.resolve('typescript-eslint', { paths: [projectPath] });
32
+ if (tseslintEntry) {
33
+ const tseslintRoot = dirname(dirname(tseslintEntry));
34
+ return require.resolve('@typescript-eslint/parser', { paths: [tseslintRoot] });
35
+ }
36
+ }
37
+ catch {
38
+ }
39
+
40
+ try {
41
+ return require.resolve('@typescript-eslint/parser', { paths: [projectPath] });
42
+ }
43
+ catch {
44
+ }
45
+
46
+ try {
47
+ return require.resolve('@typescript-eslint/parser', { paths: [__dirname] });
31
48
  }
32
49
  catch {
33
50
  return null;
34
51
  }
35
52
  }
53
+ function _gitLsFiles(projectPath) {
54
+ const check = spawnSync('git', ['-C', projectPath, 'rev-parse', '--is-inside-work-tree'], {
55
+ encoding: 'utf8',
56
+ timeout: 2000,
57
+ });
58
+ if (check.status !== 0) {
59
+ return null;
60
+ }
61
+
62
+ const ls = spawnSync('git', ['-C', projectPath, 'ls-files', '-z', '--', '*.ts', '*.tsx'], {
63
+ encoding: 'utf8',
64
+ timeout: 15000,
65
+ });
66
+ if (ls.status !== 0 || !ls.stdout) {
67
+ return null;
68
+ }
69
+
70
+ const files = ls.stdout.split('\0').filter(Boolean);
71
+ return files.length > 0 ? files : null;
72
+ }
73
+
36
74
  function parseArgs(args) {
37
75
  const projectPath = args.find(arg => !arg.startsWith('--')) || '.';
38
76
  const configArg = args.find(arg => arg.startsWith('--config='));
@@ -107,20 +145,51 @@ async function main() {
107
145
  }
108
146
  const tsParser = resolveTsParser(projectPath);
109
147
  if (!tsParser) {
110
- console.error("ESLint failed: Failed to load parser '@typescript-eslint/parser'.");
111
- console.error("Install it in your project (recommended), or use npx-based ESLint.");
112
- console.error("Example: pnpm add -D @typescript-eslint/parser");
148
+ console.error("ESLint failed: Failed to load TypeScript parser.");
149
+ console.error("Install either 'typescript-eslint' or '@typescript-eslint/parser' in your project.");
150
+ process.exit(1);
151
+ }
152
+
153
+ let filesToLint;
154
+ let lintCwd = projectPath;
155
+ let globInputPaths = true;
156
+ try {
157
+ const stats = statSync(projectPath);
158
+ if (stats.isFile()) {
159
+ lintCwd = dirname(projectPath);
160
+ filesToLint = [projectPath];
161
+ globInputPaths = false;
162
+ }
163
+ else if (stats.isDirectory()) {
164
+ const gitFiles = _gitLsFiles(projectPath);
165
+ if (gitFiles) {
166
+ filesToLint = gitFiles;
167
+ globInputPaths = false;
168
+ }
169
+ else {
170
+ filesToLint = [
171
+ "**/*.ts",
172
+ "**/*.tsx",
173
+ ];
174
+ }
175
+ }
176
+ else {
177
+ console.error(`Error: Path is neither a file nor a directory: ${projectPath}`);
178
+ process.exit(1);
179
+ }
180
+ }
181
+ catch (error) {
182
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
183
+ console.error(`Error: Cannot access path: ${errorMessage}`);
113
184
  process.exit(1);
114
185
  }
115
186
 
116
- // Create ESLint instance with programmatic configuration
117
- // Use __dirname (where CLI is located) for module resolution
118
- // This allows ESLint to find embedded node_modules in site-packages
119
187
  const eslint = new ESLint({
120
- useEslintrc: false, // Don't load .eslintrc files
121
- cwd: projectPath, // Use project directory as working directory (fix for timeout issue)
122
- resolvePluginsRelativeTo: __dirname, // Resolve plugins from embedded location
188
+ useEslintrc: false,
189
+ cwd: lintCwd,
190
+ resolvePluginsRelativeTo: __dirname,
123
191
  errorOnUnmatchedPattern: false,
192
+ globInputPaths,
124
193
  baseConfig: {
125
194
  parser: tsParser,
126
195
  parserOptions: {
@@ -130,7 +199,6 @@ async function main() {
130
199
  plugins: ['@invar'],
131
200
  rules: selectedConfig.rules,
132
201
  ignorePatterns: [
133
- // Explicit ignores to prevent scanning generated/cached directories
134
202
  '**/node_modules/**',
135
203
  '**/.next/**',
136
204
  '**/dist/**',
@@ -146,37 +214,8 @@ async function main() {
146
214
  plugins: {
147
215
  '@invar': plugin, // Register plugin directly
148
216
  },
149
- }); // Type assertion for ESLint config complexity
150
- // Lint the project - detect if path is a file or directory
151
- // ESLint defaults to .js only, so we need glob patterns for .ts/.tsx
152
- let filesToLint;
153
- try {
154
- const stats = statSync(projectPath);
155
- // Note: Advisory check for optimization - TOCTOU race condition is acceptable
156
- // because ESLint will handle file system changes gracefully during actual linting
157
- if (stats.isFile()) {
158
- // Single file - lint it directly
159
- filesToLint = [projectPath];
160
- }
161
- else if (stats.isDirectory()) {
162
- // Directory - use relative glob patterns for TypeScript files
163
- // Note: Focus on TypeScript files as this is a TypeScript Guard tool
164
- // Use relative patterns (no projectPath prefix) since cwd is set to projectPath
165
- filesToLint = [
166
- "**/*.ts",
167
- "**/*.tsx",
168
- ];
169
- }
170
- else {
171
- console.error(`Error: Path is neither a file nor a directory: ${projectPath}`);
172
- process.exit(1);
173
- }
174
- }
175
- catch (error) {
176
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
177
- console.error(`Error: Cannot access path: ${errorMessage}`);
178
- process.exit(1);
179
- }
217
+ });
218
+
180
219
  const results = await eslint.lintFiles(filesToLint);
181
220
  // Output in standard ESLint JSON format (compatible with guard_ts.py)
182
221
  const formatter = await eslint.loadFormatter('json');
@@ -77,49 +77,58 @@ function matchesEnforcePattern(filePath, patterns) {
77
77
  function isZodType(typeAnnotation) {
78
78
  return ZOD_TYPE_PATTERNS.some(pattern => pattern.test(typeAnnotation));
79
79
  }
80
- function hasParseCall(body, paramName) {
80
+ function collectParseArgs(body, visitorKeys) {
81
+ const parsed = new Set();
81
82
  if (!body)
82
- return false;
83
- let found = false;
84
- const MAX_DEPTH = 50; // Prevent stack overflow on deeply nested types
85
- const visit = (node, depth = 0) => {
86
- if (found)
87
- return;
83
+ return parsed;
84
+
85
+ const MAX_DEPTH = 50;
86
+ const stack = [{ node: body, depth: 0 }];
87
+
88
+ while (stack.length > 0) {
89
+ const current = stack.pop();
90
+ if (!current)
91
+ continue;
92
+ const node = current.node;
93
+ const depth = current.depth;
94
+ if (!node || typeof node !== 'object')
95
+ continue;
88
96
  if (depth > MAX_DEPTH)
89
- return; // Depth limit to prevent stack overflow
97
+ continue;
98
+
90
99
  if (node.type === 'CallExpression') {
91
100
  const callee = node.callee;
92
- if (callee.type === 'MemberExpression') {
101
+ if (callee && callee.type === 'MemberExpression') {
93
102
  const property = callee.property;
94
- if (property.type === 'Identifier' &&
95
- (property.name === 'parse' || property.name === 'safeParse')) {
96
- // Check if argument is our param
97
- if (node.arguments.some(arg => arg.type === 'Identifier' && arg.name === paramName)) {
98
- found = true;
99
- return;
103
+ if (property && property.type === 'Identifier' && (property.name === 'parse' || property.name === 'safeParse')) {
104
+ for (const arg of node.arguments || []) {
105
+ if (arg && arg.type === 'Identifier') {
106
+ parsed.add(arg.name);
107
+ }
100
108
  }
101
109
  }
102
110
  }
103
111
  }
104
- // Recursively visit children with depth tracking
105
- for (const key of Object.keys(node)) {
112
+
113
+ const keys = (visitorKeys && node.type && visitorKeys[node.type]) || [];
114
+ for (const key of keys) {
106
115
  const value = node[key];
107
- if (value && typeof value === 'object') {
108
- if (Array.isArray(value)) {
109
- for (const item of value) {
110
- if (item && typeof item === 'object' && 'type' in item) {
111
- visit(item, depth + 1);
112
- }
116
+ if (!value)
117
+ continue;
118
+ if (Array.isArray(value)) {
119
+ for (const item of value) {
120
+ if (item && typeof item === 'object' && item.type) {
121
+ stack.push({ node: item, depth: depth + 1 });
113
122
  }
114
123
  }
115
- else if ('type' in value) {
116
- visit(value, depth + 1);
117
- }
124
+ }
125
+ else if (typeof value === 'object' && value.type) {
126
+ stack.push({ node: value, depth: depth + 1 });
118
127
  }
119
128
  }
120
- };
121
- visit(body);
122
- return found;
129
+ }
130
+
131
+ return parsed;
123
132
  }
124
133
  export const requireSchemaValidation = {
125
134
  meta: {
@@ -206,49 +215,54 @@ export const requireSchemaValidation = {
206
215
  }
207
216
  function checkFunction(node, params) {
208
217
  const functionName = getFunctionName(node);
209
- // Skip if shouldn't check based on mode
210
218
  if (!shouldCheck(functionName)) {
211
219
  return;
212
220
  }
221
+
213
222
  const body = 'body' in node ? node.body : null;
223
+ const zodParams = params.filter((p) => p.typeAnnotation && isZodType(p.typeAnnotation) && p.name && p.name !== '{...}' && p.name !== '[...]');
224
+ if (zodParams.length === 0) {
225
+ return;
226
+ }
227
+
228
+ const parsedArgs = collectParseArgs(body, sourceCode.visitorKeys);
214
229
  const isRiskFunction = isHighRiskFunction(functionName, filename);
215
- for (const param of params) {
216
- if (param.typeAnnotation && isZodType(param.typeAnnotation)) {
217
- if (!hasParseCall(body, param.name)) {
218
- // Extract schema name from type annotation (e.g., "z.infer<typeof UserSchema>" -> "UserSchema")
219
- const schemaMatch = param.typeAnnotation.match(/typeof\s+(\w+)/);
220
- const schemaName = schemaMatch ? schemaMatch[1] : 'Schema';
221
- const validatedVarName = `validated${param.name.charAt(0).toUpperCase()}${param.name.slice(1)}`;
222
- context.report({
223
- node: node,
224
- messageId: isRiskFunction ? 'missingValidationRisk' : 'missingValidation',
225
- data: {
226
- name: param.name,
227
- functionName: functionName,
228
- },
229
- suggest: [
230
- {
231
- messageId: 'addParseCall',
232
- data: { name: param.name },
233
- fix(fixer) {
234
- // Find the opening brace of the function body
235
- if (!body || body.type !== 'BlockStatement')
236
- return null;
237
- const blockBody = body;
238
- if (!blockBody.body || blockBody.body.length === 0)
239
- return null;
240
- const firstStatement = blockBody.body[0];
241
- // Detect indentation from the first statement
242
- const firstStatementStart = firstStatement.loc?.start.column ?? 2;
243
- const indent = ' '.repeat(firstStatementStart);
244
- const parseCode = `const ${validatedVarName} = ${schemaName}.parse(${param.name});\n${indent}`;
245
- return fixer.insertTextBefore(firstStatement, parseCode);
246
- },
247
- },
248
- ],
249
- });
250
- }
230
+
231
+ for (const param of zodParams) {
232
+ if (parsedArgs.has(param.name)) {
233
+ continue;
251
234
  }
235
+
236
+ const schemaMatch = param.typeAnnotation.match(/typeof\s+(\w+)/);
237
+ const schemaName = schemaMatch ? schemaMatch[1] : 'Schema';
238
+ const validatedVarName = `validated${param.name.charAt(0).toUpperCase()}${param.name.slice(1)}`;
239
+
240
+ context.report({
241
+ node: node,
242
+ messageId: isRiskFunction ? 'missingValidationRisk' : 'missingValidation',
243
+ data: {
244
+ name: param.name,
245
+ functionName: functionName,
246
+ },
247
+ suggest: [
248
+ {
249
+ messageId: 'addParseCall',
250
+ data: { name: param.name },
251
+ fix(fixer) {
252
+ if (!body || body.type !== 'BlockStatement')
253
+ return null;
254
+ const blockBody = body;
255
+ if (!blockBody.body || blockBody.body.length === 0)
256
+ return null;
257
+ const firstStatement = blockBody.body[0];
258
+ const firstStatementStart = firstStatement.loc?.start.column ?? 2;
259
+ const indent = ' '.repeat(firstStatementStart);
260
+ const parseCode = `const ${validatedVarName} = ${schemaName}.parse(${param.name});\n${indent}`;
261
+ return fixer.insertTextBefore(firstStatement, parseCode);
262
+ },
263
+ },
264
+ ],
265
+ });
252
266
  }
253
267
  }
254
268
  /**
@@ -17,7 +17,7 @@ from invar import __version__
17
17
  from invar.core.models import GuardReport, RuleConfig
18
18
  from invar.core.rules import check_all_rules
19
19
  from invar.core.utils import get_exit_code
20
- from invar.shell.config import find_project_root, load_config
20
+ from invar.shell.config import find_project_root, find_pyproject_root, load_config
21
21
  from invar.shell.fs import scan_project
22
22
  from invar.shell.guard_output import output_agent, output_rich
23
23
 
@@ -225,7 +225,14 @@ def guard(
225
225
  console.print(f"[red]Error:[/red] {path} is not a Python file")
226
226
  raise typer.Exit(1)
227
227
  single_file = path.resolve()
228
- path = find_project_root(path)
228
+
229
+ pyproject_root = find_pyproject_root(single_file if single_file else path)
230
+ if pyproject_root is None:
231
+ console.print(
232
+ "[red]Error:[/red] pyproject.toml not found (searched upward from the target path)"
233
+ )
234
+ raise typer.Exit(1)
235
+ path = pyproject_root
229
236
 
230
237
  # Load and configure
231
238
  config_result = load_config(path)
@@ -373,6 +380,7 @@ def guard(
373
380
 
374
381
  # Phase 1: Doctests (DX-37: with optional coverage)
375
382
  doctest_passed, doctest_output, doctest_coverage = run_doctests_phase(
383
+ path,
376
384
  checked_files,
377
385
  explain,
378
386
  timeout=config.timeout_doctest,
@@ -393,6 +401,7 @@ def guard(
393
401
 
394
402
  # Phase 3: Hypothesis property tests (DX-37: with optional coverage)
395
403
  property_passed, property_output, property_coverage = run_property_tests_phase(
404
+ path,
396
405
  checked_files,
397
406
  doctest_passed,
398
407
  static_exit_code,
invar/shell/config.py CHANGED
@@ -39,11 +39,27 @@ class ModuleType(Enum):
39
39
 
40
40
 
41
41
  # I/O libraries that indicate Shell module (for AST import checking)
42
- _IO_LIBRARIES = frozenset([
43
- "os", "sys", "subprocess", "pathlib", "shutil", "io", "socket",
44
- "requests", "aiohttp", "httpx", "urllib", "sqlite3", "psycopg2",
45
- "pymongo", "sqlalchemy", "typer", "click",
46
- ])
42
+ _IO_LIBRARIES = frozenset(
43
+ [
44
+ "os",
45
+ "sys",
46
+ "subprocess",
47
+ "pathlib",
48
+ "shutil",
49
+ "io",
50
+ "socket",
51
+ "requests",
52
+ "aiohttp",
53
+ "httpx",
54
+ "urllib",
55
+ "sqlite3",
56
+ "psycopg2",
57
+ "pymongo",
58
+ "sqlalchemy",
59
+ "typer",
60
+ "click",
61
+ ]
62
+ )
47
63
 
48
64
  # Contract decorator names
49
65
  _CONTRACT_DECORATORS = frozenset(["pre", "post", "invariant"])
@@ -226,6 +242,7 @@ def auto_detect_module_type(source: str, file_path: str = "") -> ModuleType:
226
242
  # Unknown: neither clear pattern
227
243
  return ModuleType.UNKNOWN
228
244
 
245
+
229
246
  if TYPE_CHECKING:
230
247
  from pathlib import Path
231
248
 
@@ -268,6 +285,20 @@ def _find_config_source(project_root: Path) -> Result[tuple[Path | None, ConfigS
268
285
 
269
286
 
270
287
  # @shell_complexity: Project root discovery requires checking multiple markers
288
+ def find_pyproject_root(start_path: "Path") -> "Path | None": # noqa: UP037
289
+ from pathlib import Path
290
+
291
+ current = Path(start_path).resolve()
292
+ if current.is_file():
293
+ current = current.parent
294
+
295
+ for parent in [current, *current.parents]:
296
+ if (parent / "pyproject.toml").exists():
297
+ return parent
298
+
299
+ return None
300
+
301
+
271
302
  def find_project_root(start_path: "Path") -> "Path": # noqa: UP037
272
303
  """
273
304
  Find project root by walking up from start_path looking for config files.
@@ -492,6 +523,7 @@ def classify_file(
492
523
  else:
493
524
  # Log warning about config error, use defaults
494
525
  import logging
526
+
495
527
  logging.getLogger(__name__).debug(
496
528
  "Pattern classification failed: %s, using defaults", pattern_result.failure()
497
529
  )
@@ -503,6 +535,7 @@ def classify_file(
503
535
  else:
504
536
  # Log warning about config error, use defaults
505
537
  import logging
538
+
506
539
  logging.getLogger(__name__).debug(
507
540
  "Path classification failed: %s, using defaults", path_result.failure()
508
541
  )
invar/shell/git.py CHANGED
@@ -7,13 +7,10 @@ Shell module: handles git I/O for changed file detection.
7
7
  from __future__ import annotations
8
8
 
9
9
  import subprocess
10
- from typing import TYPE_CHECKING
10
+ from pathlib import Path
11
11
 
12
12
  from returns.result import Failure, Result, Success
13
13
 
14
- if TYPE_CHECKING:
15
- from pathlib import Path
16
-
17
14
 
18
15
  def _run_git(args: list[str], cwd: Path) -> Result[str, str]:
19
16
  """Run a git command and return stdout."""
@@ -49,27 +46,29 @@ def get_changed_files(project_root: Path) -> Result[set[Path], str]:
49
46
  >>> isinstance(result, (Success, Failure))
50
47
  True
51
48
  """
52
- # Verify git repo
53
49
  check = _run_git(["rev-parse", "--git-dir"], project_root)
54
50
  if isinstance(check, Failure):
55
51
  return Failure(f"Not a git repository: {project_root}")
56
52
 
53
+ repo_root_result = _run_git(["rev-parse", "--show-toplevel"], project_root)
54
+ if isinstance(repo_root_result, Failure):
55
+ return Failure(repo_root_result.failure())
56
+
57
+ repo_root = Path(repo_root_result.unwrap().strip())
58
+
57
59
  changed: set[Path] = set()
58
60
 
59
- # Staged changes
60
61
  staged = _run_git(["diff", "--cached", "--name-only"], project_root)
61
62
  if isinstance(staged, Success):
62
- changed.update(_parse_py_files(staged.unwrap(), project_root))
63
+ changed.update(_parse_py_files(staged.unwrap(), repo_root))
63
64
 
64
- # Unstaged changes
65
65
  unstaged = _run_git(["diff", "--name-only"], project_root)
66
66
  if isinstance(unstaged, Success):
67
- changed.update(_parse_py_files(unstaged.unwrap(), project_root))
67
+ changed.update(_parse_py_files(unstaged.unwrap(), repo_root))
68
68
 
69
- # Untracked files
70
69
  untracked = _run_git(["ls-files", "--others", "--exclude-standard"], project_root)
71
70
  if isinstance(untracked, Success):
72
- changed.update(_parse_py_files(untracked.unwrap(), project_root))
71
+ changed.update(_parse_py_files(untracked.unwrap(), repo_root))
73
72
 
74
73
  return Success(changed)
75
74