invar-tools 1.17.12__py3-none-any.whl → 1.17.24__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/core/models.py +7 -3
- invar/core/rules.py +50 -15
- invar/mcp/handlers.py +58 -2
- invar/node_tools/eslint-plugin/cli.js +105 -31
- invar/node_tools/eslint-plugin/rules/require-schema-validation.js +80 -66
- invar/shell/commands/guard.py +46 -6
- invar/shell/config.py +64 -21
- invar/shell/git.py +10 -11
- invar/shell/guard_helpers.py +105 -43
- invar/shell/property_tests.py +129 -41
- invar/shell/prove/crosshair.py +147 -13
- invar/shell/prove/guard_ts.py +39 -17
- invar/shell/subprocess_env.py +58 -5
- invar/shell/testing.py +59 -31
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/METADATA +3 -3
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/RECORD +21 -21
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/WHEEL +0 -0
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/licenses/NOTICE +0 -0
invar/core/models.py
CHANGED
|
@@ -160,7 +160,10 @@ def get_layer(file_info: FileInfo) -> CodeLayer:
|
|
|
160
160
|
return CodeLayer.DEFAULT
|
|
161
161
|
|
|
162
162
|
|
|
163
|
-
@pre(
|
|
163
|
+
@pre(
|
|
164
|
+
lambda layer, language="python": isinstance(layer, CodeLayer)
|
|
165
|
+
and language in ("python", "typescript")
|
|
166
|
+
)
|
|
164
167
|
@post(lambda result: result.max_file_lines > 0 and result.max_function_lines > 0)
|
|
165
168
|
def get_limits(layer: CodeLayer, language: str = "python") -> LayerLimits:
|
|
166
169
|
"""
|
|
@@ -424,8 +427,9 @@ class RuleConfig(BaseModel):
|
|
|
424
427
|
"""
|
|
425
428
|
|
|
426
429
|
# MINOR-6: Added ge=1 constraints for numeric fields
|
|
427
|
-
|
|
428
|
-
|
|
430
|
+
# BUG-55: These override layer-based limits when set to non-default values
|
|
431
|
+
max_file_lines: int = Field(default=500, ge=1) # Override all layers if != 500
|
|
432
|
+
max_function_lines: int = Field(default=50, ge=1) # Override all layers if != 50
|
|
429
433
|
entry_max_lines: int = Field(default=15, ge=1) # DX-23: Entry point max lines
|
|
430
434
|
shell_max_branches: int = Field(default=3, ge=1) # DX-22: Shell function max branches
|
|
431
435
|
shell_complexity_debt_limit: int = Field(default=5, ge=0) # DX-22: 0 = no limit
|
invar/core/rules.py
CHANGED
|
@@ -71,7 +71,11 @@ def _build_size_suggestion(base: str, extraction_hint: str, func_hint: str) -> s
|
|
|
71
71
|
def _get_func_hint(file_info: FileInfo) -> str:
|
|
72
72
|
"""Get top 5 largest functions as hint string."""
|
|
73
73
|
funcs = sorted(
|
|
74
|
-
[
|
|
74
|
+
[
|
|
75
|
+
(s.name, s.end_line - s.line + 1)
|
|
76
|
+
for s in file_info.symbols
|
|
77
|
+
if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)
|
|
78
|
+
],
|
|
75
79
|
key=lambda x: -x[1],
|
|
76
80
|
)[:5]
|
|
77
81
|
return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
|
|
@@ -104,6 +108,7 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
104
108
|
Check if file exceeds maximum line count or warning threshold.
|
|
105
109
|
|
|
106
110
|
LX-10: Uses layer-based limits (Core/Shell/Tests/Default).
|
|
111
|
+
BUG-55: Config override - if max_file_lines is set to non-default, use it.
|
|
107
112
|
P18: Shows function groups in size warnings to help agents decide what to extract.
|
|
108
113
|
P25: Shows extractable groups with dependencies for warnings.
|
|
109
114
|
|
|
@@ -121,6 +126,10 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
121
126
|
>>> # Core layer: 500 lines max (strict)
|
|
122
127
|
>>> len(check_file_size(FileInfo(path="core/calc.py", lines=550, is_core=True), RuleConfig()))
|
|
123
128
|
1
|
|
129
|
+
>>> # BUG-55: Config override allows larger files (no error at 550 with max 600)
|
|
130
|
+
>>> vs = check_file_size(FileInfo(path="core/calc.py", lines=550, is_core=True), RuleConfig(max_file_lines=600, size_warning_threshold=0))
|
|
131
|
+
>>> any(v.rule == "file_size" for v in vs)
|
|
132
|
+
False
|
|
124
133
|
"""
|
|
125
134
|
# Check for escape hatch
|
|
126
135
|
if _has_file_escape(file_info, "file_size"):
|
|
@@ -133,23 +142,38 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
133
142
|
# LX-10: Get layer-based limits
|
|
134
143
|
layer = get_layer(file_info)
|
|
135
144
|
limits = get_limits(layer)
|
|
136
|
-
|
|
145
|
+
# BUG-55: Allow config override if user sets non-default value
|
|
146
|
+
max_lines = config.max_file_lines if config.max_file_lines != 500 else limits.max_file_lines
|
|
137
147
|
|
|
138
148
|
if file_info.lines > max_lines:
|
|
139
|
-
violations.append(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
violations.append(
|
|
150
|
+
Violation(
|
|
151
|
+
rule="file_size",
|
|
152
|
+
severity=Severity.ERROR,
|
|
153
|
+
file=file_info.path,
|
|
154
|
+
line=None,
|
|
155
|
+
message=f"File has {file_info.lines} lines (max: {max_lines} for {layer.value})",
|
|
156
|
+
suggestion=_build_size_suggestion(
|
|
157
|
+
"Split into smaller modules.", extraction_hint, func_hint
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
144
161
|
elif config.size_warning_threshold > 0:
|
|
145
162
|
threshold = int(max_lines * config.size_warning_threshold)
|
|
146
163
|
if file_info.lines >= threshold:
|
|
147
164
|
pct = int(file_info.lines / max_lines * 100)
|
|
148
|
-
violations.append(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
violations.append(
|
|
166
|
+
Violation(
|
|
167
|
+
rule="file_size_warning",
|
|
168
|
+
severity=Severity.WARNING,
|
|
169
|
+
file=file_info.path,
|
|
170
|
+
line=None,
|
|
171
|
+
message=f"File has {file_info.lines} lines ({pct}% of {max_lines} limit)",
|
|
172
|
+
suggestion=_build_size_suggestion(
|
|
173
|
+
"Consider splitting before reaching limit.", extraction_hint, func_hint
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
153
177
|
return violations
|
|
154
178
|
|
|
155
179
|
|
|
@@ -159,6 +183,7 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
159
183
|
Check if any function exceeds maximum line count.
|
|
160
184
|
|
|
161
185
|
LX-10: Uses layer-based limits (Core/Shell/Tests/Default).
|
|
186
|
+
BUG-55: Config override - if max_function_lines is set to non-default, use it.
|
|
162
187
|
DX-22: Always uses code_lines (excluding docstring) and excludes doctest lines.
|
|
163
188
|
|
|
164
189
|
Examples:
|
|
@@ -177,13 +202,19 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
177
202
|
>>> info3 = FileInfo(path="core/calc.py", lines=100, symbols=[sym3], is_core=True)
|
|
178
203
|
>>> len(check_function_size(info3, RuleConfig()))
|
|
179
204
|
1
|
|
205
|
+
>>> # BUG-55: Config override allows larger functions
|
|
206
|
+
>>> len(check_function_size(info3, RuleConfig(max_function_lines=70)))
|
|
207
|
+
0
|
|
180
208
|
"""
|
|
181
209
|
violations: list[Violation] = []
|
|
182
210
|
|
|
183
211
|
# LX-10: Get layer-based limits
|
|
184
212
|
layer = get_layer(file_info)
|
|
185
213
|
limits = get_limits(layer)
|
|
186
|
-
|
|
214
|
+
# BUG-55: Allow config override if user sets non-default value
|
|
215
|
+
max_func_lines = (
|
|
216
|
+
config.max_function_lines if config.max_function_lines != 50 else limits.max_function_lines
|
|
217
|
+
)
|
|
187
218
|
|
|
188
219
|
for symbol in file_info.symbols:
|
|
189
220
|
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
@@ -392,7 +423,9 @@ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violatio
|
|
|
392
423
|
):
|
|
393
424
|
continue
|
|
394
425
|
# DX-23: Skip entry points; DX-22: Skip if @invar:allow marker
|
|
395
|
-
if is_entry_point(symbol, file_info.source) or has_allow_marker(
|
|
426
|
+
if is_entry_point(symbol, file_info.source) or has_allow_marker(
|
|
427
|
+
symbol, file_info.source, "shell_result"
|
|
428
|
+
):
|
|
396
429
|
continue
|
|
397
430
|
if "Result[" not in symbol.signature:
|
|
398
431
|
violations.append(
|
|
@@ -435,7 +468,9 @@ def check_entry_point_thin(file_info: FileInfo, config: RuleConfig) -> list[Viol
|
|
|
435
468
|
continue
|
|
436
469
|
|
|
437
470
|
# Only check entry points; DX-22: Skip if @invar:allow marker
|
|
438
|
-
if not is_entry_point(symbol, file_info.source) or has_allow_marker(
|
|
471
|
+
if not is_entry_point(symbol, file_info.source) or has_allow_marker(
|
|
472
|
+
symbol, file_info.source, "entry_point_too_thick"
|
|
473
|
+
):
|
|
439
474
|
continue
|
|
440
475
|
lines = get_symbol_lines(symbol)
|
|
441
476
|
if lines > max_lines:
|
invar/mcp/handlers.py
CHANGED
|
@@ -427,11 +427,24 @@ async def _execute_command(
|
|
|
427
427
|
timeout=timeout,
|
|
428
428
|
)
|
|
429
429
|
|
|
430
|
+
stdout = result.stdout.strip()
|
|
431
|
+
|
|
432
|
+
# Try to parse as JSON
|
|
430
433
|
try:
|
|
431
|
-
parsed = json.loads(
|
|
434
|
+
parsed = json.loads(stdout)
|
|
432
435
|
return ([TextContent(type="text", text=json.dumps(parsed, indent=2))], parsed)
|
|
433
436
|
except json.JSONDecodeError:
|
|
434
|
-
|
|
437
|
+
# Try to fix unescaped newlines in JSON strings
|
|
438
|
+
# Guard/map commands may output multiline JSON with literal newlines
|
|
439
|
+
fixed = _fix_json_newlines(stdout)
|
|
440
|
+
try:
|
|
441
|
+
parsed = json.loads(fixed)
|
|
442
|
+
return ([TextContent(type="text", text=json.dumps(parsed, indent=2))], parsed)
|
|
443
|
+
except json.JSONDecodeError:
|
|
444
|
+
pass
|
|
445
|
+
|
|
446
|
+
# Fall back to text output
|
|
447
|
+
output = stdout
|
|
435
448
|
if result.stderr:
|
|
436
449
|
output += f"\n\nStderr:\n{result.stderr}"
|
|
437
450
|
return [TextContent(type="text", text=output)]
|
|
@@ -440,3 +453,46 @@ async def _execute_command(
|
|
|
440
453
|
return [TextContent(type="text", text=f"Error: Command timed out ({timeout}s)")]
|
|
441
454
|
except Exception as e:
|
|
442
455
|
return [TextContent(type="text", text=f"Error: {e}")]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# @invar:allow shell_too_complex: Simple state machine, 6 branches is minimal
|
|
459
|
+
# @invar:allow shell_pure_logic: No I/O, but called from shell context
|
|
460
|
+
# @invar:allow shell_result: Pure transformation, returns str not Result
|
|
461
|
+
def _fix_json_newlines(text: str) -> str:
|
|
462
|
+
"""Fix unescaped newlines in JSON strings.
|
|
463
|
+
|
|
464
|
+
When subprocess outputs multiline JSON, newlines inside string values
|
|
465
|
+
are not escaped, causing json.loads() to fail. This function escapes them.
|
|
466
|
+
|
|
467
|
+
DX-33: Escape hatch for complex pure logic helper.
|
|
468
|
+
"""
|
|
469
|
+
result = []
|
|
470
|
+
i = 0
|
|
471
|
+
while i < len(text):
|
|
472
|
+
if text[i] == '"':
|
|
473
|
+
# Inside a string - collect until closing quote
|
|
474
|
+
result.append('"')
|
|
475
|
+
i += 1
|
|
476
|
+
while i < len(text):
|
|
477
|
+
c = text[i]
|
|
478
|
+
if c == "\\" and i + 1 < len(text):
|
|
479
|
+
# Escaped character - keep as is
|
|
480
|
+
result.append("\\")
|
|
481
|
+
result.append(text[i + 1])
|
|
482
|
+
i += 2
|
|
483
|
+
elif c == '"':
|
|
484
|
+
# End of string
|
|
485
|
+
result.append('"')
|
|
486
|
+
i += 1
|
|
487
|
+
break
|
|
488
|
+
elif c == "\n" or c == "\r":
|
|
489
|
+
# Unescaped newline - escape it
|
|
490
|
+
result.append("\\n")
|
|
491
|
+
i += 1
|
|
492
|
+
else:
|
|
493
|
+
result.append(c)
|
|
494
|
+
i += 1
|
|
495
|
+
else:
|
|
496
|
+
result.append(text[i])
|
|
497
|
+
i += 1
|
|
498
|
+
return "".join(result)
|
|
@@ -16,11 +16,61 @@
|
|
|
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';
|
|
21
|
+
import { createRequire } from 'module';
|
|
20
22
|
import plugin from './index.js';
|
|
21
23
|
// Get the directory where this CLI script is located (embedded in site-packages)
|
|
22
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
23
25
|
const __dirname = dirname(__filename);
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
|
|
29
|
+
function resolveTsParser(projectPath) {
|
|
30
|
+
try {
|
|
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] });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
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
|
+
|
|
24
74
|
function parseArgs(args) {
|
|
25
75
|
const projectPath = args.find(arg => !arg.startsWith('--')) || '.';
|
|
26
76
|
const configArg = args.find(arg => arg.startsWith('--config='));
|
|
@@ -93,44 +143,35 @@ async function main() {
|
|
|
93
143
|
console.error(`Config "${args.config}" not found or invalid`);
|
|
94
144
|
process.exit(1);
|
|
95
145
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
baseConfig: {
|
|
104
|
-
parser: '@typescript-eslint/parser', // Will resolve from __dirname/node_modules
|
|
105
|
-
parserOptions: {
|
|
106
|
-
ecmaVersion: 2022,
|
|
107
|
-
sourceType: 'module',
|
|
108
|
-
},
|
|
109
|
-
plugins: ['@invar'],
|
|
110
|
-
rules: selectedConfig.rules,
|
|
111
|
-
},
|
|
112
|
-
plugins: {
|
|
113
|
-
'@invar': plugin, // Register plugin directly
|
|
114
|
-
},
|
|
115
|
-
}); // Type assertion for ESLint config complexity
|
|
116
|
-
// Lint the project - detect if path is a file or directory
|
|
117
|
-
// ESLint defaults to .js only, so we need glob patterns for .ts/.tsx
|
|
146
|
+
const tsParser = resolveTsParser(projectPath);
|
|
147
|
+
if (!tsParser) {
|
|
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
|
+
|
|
118
153
|
let filesToLint;
|
|
154
|
+
let lintCwd = projectPath;
|
|
155
|
+
let globInputPaths = true;
|
|
119
156
|
try {
|
|
120
157
|
const stats = statSync(projectPath);
|
|
121
|
-
// Note: Advisory check for optimization - TOCTOU race condition is acceptable
|
|
122
|
-
// because ESLint will handle file system changes gracefully during actual linting
|
|
123
158
|
if (stats.isFile()) {
|
|
124
|
-
|
|
159
|
+
lintCwd = dirname(projectPath);
|
|
125
160
|
filesToLint = [projectPath];
|
|
161
|
+
globInputPaths = false;
|
|
126
162
|
}
|
|
127
163
|
else if (stats.isDirectory()) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|
|
134
175
|
}
|
|
135
176
|
else {
|
|
136
177
|
console.error(`Error: Path is neither a file nor a directory: ${projectPath}`);
|
|
@@ -142,6 +183,39 @@ async function main() {
|
|
|
142
183
|
console.error(`Error: Cannot access path: ${errorMessage}`);
|
|
143
184
|
process.exit(1);
|
|
144
185
|
}
|
|
186
|
+
|
|
187
|
+
const eslint = new ESLint({
|
|
188
|
+
useEslintrc: false,
|
|
189
|
+
cwd: lintCwd,
|
|
190
|
+
resolvePluginsRelativeTo: __dirname,
|
|
191
|
+
errorOnUnmatchedPattern: false,
|
|
192
|
+
globInputPaths,
|
|
193
|
+
baseConfig: {
|
|
194
|
+
parser: tsParser,
|
|
195
|
+
parserOptions: {
|
|
196
|
+
ecmaVersion: 2022,
|
|
197
|
+
sourceType: 'module',
|
|
198
|
+
},
|
|
199
|
+
plugins: ['@invar'],
|
|
200
|
+
rules: selectedConfig.rules,
|
|
201
|
+
ignorePatterns: [
|
|
202
|
+
'**/node_modules/**',
|
|
203
|
+
'**/.next/**',
|
|
204
|
+
'**/dist/**',
|
|
205
|
+
'**/build/**',
|
|
206
|
+
'**/.cache/**',
|
|
207
|
+
'**/coverage/**',
|
|
208
|
+
'**/.turbo/**',
|
|
209
|
+
'**/.vercel/**',
|
|
210
|
+
'**/playwright-report/**',
|
|
211
|
+
'**/test-results/**',
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
plugins: {
|
|
215
|
+
'@invar': plugin, // Register plugin directly
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
145
219
|
const results = await eslint.lintFiles(filesToLint);
|
|
146
220
|
// Output in standard ESLint JSON format (compatible with guard_ts.py)
|
|
147
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
|
|
80
|
+
function collectParseArgs(body, visitorKeys) {
|
|
81
|
+
const parsed = new Set();
|
|
81
82
|
if (!body)
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
const MAX_DEPTH = 50;
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
124
|
+
}
|
|
125
|
+
else if (typeof value === 'object' && value.type) {
|
|
126
|
+
stack.push({ node: value, depth: depth + 1 });
|
|
118
127
|
}
|
|
119
128
|
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
/**
|