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.
- invar/node_tools/eslint-plugin/cli.js +81 -42
- invar/node_tools/eslint-plugin/rules/require-schema-validation.js +80 -66
- invar/shell/commands/guard.py +11 -2
- invar/shell/config.py +38 -5
- invar/shell/git.py +10 -11
- invar/shell/guard_helpers.py +69 -41
- invar/shell/property_tests.py +85 -38
- invar/shell/prove/crosshair.py +19 -12
- invar/shell/subprocess_env.py +9 -5
- invar/shell/testing.py +57 -30
- {invar_tools-1.17.18.dist-info → invar_tools-1.17.20.dist-info}/METADATA +3 -3
- {invar_tools-1.17.18.dist-info → invar_tools-1.17.20.dist-info}/RECORD +17 -17
- {invar_tools-1.17.18.dist-info → invar_tools-1.17.20.dist-info}/WHEEL +0 -0
- {invar_tools-1.17.18.dist-info → invar_tools-1.17.20.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.17.18.dist-info → invar_tools-1.17.20.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.17.18.dist-info → invar_tools-1.17.20.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.17.18.dist-info → invar_tools-1.17.20.dist-info}/licenses/NOTICE +0 -0
|
@@ -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
|
-
|
|
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
|
|
111
|
-
console.error("Install
|
|
112
|
-
|
|
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,
|
|
121
|
-
cwd:
|
|
122
|
-
resolvePluginsRelativeTo: __dirname,
|
|
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
|
-
});
|
|
150
|
-
|
|
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
|
|
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
|
/**
|
invar/shell/commands/guard.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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(),
|
|
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(),
|
|
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(),
|
|
71
|
+
changed.update(_parse_py_files(untracked.unwrap(), repo_root))
|
|
73
72
|
|
|
74
73
|
return Success(changed)
|
|
75
74
|
|