scitex-linter 0.1.0__py3-none-any.whl → 0.1.2__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.
- scitex_linter/__init__.py +1 -1
- scitex_linter/_cmd_format.py +70 -0
- scitex_linter/_mcp/tools/lint.py +8 -4
- scitex_linter/_server.py +1 -1
- scitex_linter/checker.py +63 -25
- scitex_linter/cli.py +31 -23
- scitex_linter/config.py +206 -0
- scitex_linter/fixer.py +333 -0
- scitex_linter/formatter.py +0 -1
- scitex_linter/rules.py +20 -0
- {scitex_linter-0.1.0.dist-info → scitex_linter-0.1.2.dist-info}/METADATA +53 -15
- scitex_linter-0.1.2.dist-info/RECORD +20 -0
- scitex_linter-0.1.0.dist-info/RECORD +0 -17
- {scitex_linter-0.1.0.dist-info → scitex_linter-0.1.2.dist-info}/WHEEL +0 -0
- {scitex_linter-0.1.0.dist-info → scitex_linter-0.1.2.dist-info}/entry_points.txt +0 -0
- {scitex_linter-0.1.0.dist-info → scitex_linter-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {scitex_linter-0.1.0.dist-info → scitex_linter-0.1.2.dist-info}/top_level.txt +0 -0
scitex_linter/__init__.py
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""CLI handler for the 'format' subcommand."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .fixer import fix_source
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(subparsers) -> None:
|
|
11
|
+
p = subparsers.add_parser(
|
|
12
|
+
"format",
|
|
13
|
+
help="Auto-fix SciTeX pattern issues",
|
|
14
|
+
description="Auto-fix SciTeX pattern issues (e.g. insert missing INJECTED parameters).",
|
|
15
|
+
)
|
|
16
|
+
p.add_argument("path", help="Python file or directory to format")
|
|
17
|
+
p.add_argument(
|
|
18
|
+
"--check",
|
|
19
|
+
action="store_true",
|
|
20
|
+
help="Check if changes needed without writing (exit 1 if changes needed)",
|
|
21
|
+
)
|
|
22
|
+
p.add_argument("--diff", action="store_true", help="Show diff of changes")
|
|
23
|
+
p.set_defaults(func=cmd_format)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cmd_format(args) -> int:
|
|
27
|
+
from .cli import _collect_files
|
|
28
|
+
from .config import load_config
|
|
29
|
+
|
|
30
|
+
config = load_config(args.path)
|
|
31
|
+
target = Path(args.path)
|
|
32
|
+
if not target.exists():
|
|
33
|
+
print(f"Error: {args.path} not found", file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
|
|
36
|
+
files = _collect_files(target, config=config)
|
|
37
|
+
if not files:
|
|
38
|
+
print(f"No Python files found in {args.path}", file=sys.stderr)
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
changed_count = 0
|
|
42
|
+
for f in files:
|
|
43
|
+
original = f.read_text(encoding="utf-8")
|
|
44
|
+
fixed = fix_source(original, filepath=str(f), config=config)
|
|
45
|
+
if fixed != original:
|
|
46
|
+
changed_count += 1
|
|
47
|
+
if args.diff:
|
|
48
|
+
diff = difflib.unified_diff(
|
|
49
|
+
original.splitlines(keepends=True),
|
|
50
|
+
fixed.splitlines(keepends=True),
|
|
51
|
+
fromfile=str(f),
|
|
52
|
+
tofile=str(f),
|
|
53
|
+
)
|
|
54
|
+
sys.stdout.writelines(diff)
|
|
55
|
+
if not args.check:
|
|
56
|
+
f.write_text(fixed, encoding="utf-8")
|
|
57
|
+
print(f"Fixed {f}")
|
|
58
|
+
else:
|
|
59
|
+
print(f"Would fix {f}")
|
|
60
|
+
|
|
61
|
+
if changed_count == 0:
|
|
62
|
+
print("All files clean")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
if args.check:
|
|
66
|
+
print(f"\n{changed_count} file(s) would be changed")
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
print(f"\n{changed_count} file(s) fixed")
|
|
70
|
+
return 0
|
scitex_linter/_mcp/tools/lint.py
CHANGED
|
@@ -7,15 +7,17 @@ def register_lint_tools(mcp) -> None:
|
|
|
7
7
|
"""Register lint-related MCP tools."""
|
|
8
8
|
|
|
9
9
|
@mcp.tool()
|
|
10
|
-
def
|
|
10
|
+
def linter_check(
|
|
11
11
|
path: str, severity: str = "info", category: Optional[str] = None
|
|
12
12
|
) -> dict:
|
|
13
|
-
"""[linter]
|
|
13
|
+
"""[linter] Check a Python file for SciTeX pattern compliance."""
|
|
14
14
|
from ...checker import lint_file
|
|
15
|
+
from ...config import load_config
|
|
15
16
|
from ...formatter import to_json
|
|
16
17
|
from ...rules import SEVERITY_ORDER
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
config = load_config(path)
|
|
20
|
+
issues = lint_file(path, config=config)
|
|
19
21
|
min_sev = SEVERITY_ORDER.get(severity, 0)
|
|
20
22
|
categories = set(category.split(",")) if category else None
|
|
21
23
|
|
|
@@ -61,7 +63,9 @@ def register_lint_tools(mcp) -> None:
|
|
|
61
63
|
def linter_check_source(source: str, filepath: str = "<stdin>") -> dict:
|
|
62
64
|
"""[linter] Lint Python source code string for SciTeX pattern compliance."""
|
|
63
65
|
from ...checker import lint_source
|
|
66
|
+
from ...config import load_config
|
|
64
67
|
from ...formatter import to_json
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
config = load_config(filepath)
|
|
70
|
+
issues = lint_source(source, filepath=filepath, config=config)
|
|
67
71
|
return to_json(issues, filepath)
|
scitex_linter/_server.py
CHANGED
|
@@ -8,7 +8,7 @@ _INSTRUCTIONS = """\
|
|
|
8
8
|
SciTeX Linter: AST-based linter enforcing reproducible research patterns.
|
|
9
9
|
|
|
10
10
|
Tools:
|
|
11
|
-
-
|
|
11
|
+
- linter_check: Check a Python file
|
|
12
12
|
- linter_list_rules: List all lint rules
|
|
13
13
|
- linter_check_source: Lint source code string
|
|
14
14
|
"""
|
scitex_linter/checker.py
CHANGED
|
@@ -8,13 +8,8 @@ from . import rules
|
|
|
8
8
|
from .rules import Rule
|
|
9
9
|
|
|
10
10
|
# Shortcuts for Phase 1 rules
|
|
11
|
-
S001, S002, S003,
|
|
12
|
-
|
|
13
|
-
rules.S002,
|
|
14
|
-
rules.S003,
|
|
15
|
-
rules.S004,
|
|
16
|
-
rules.S005,
|
|
17
|
-
)
|
|
11
|
+
S001, S002, S003 = rules.S001, rules.S002, rules.S003
|
|
12
|
+
S004, S005, S006 = rules.S004, rules.S005, rules.S006
|
|
18
13
|
I001, I002, I003 = rules.I001, rules.I002, rules.I003
|
|
19
14
|
I006, I007 = rules.I006, rules.I007
|
|
20
15
|
|
|
@@ -67,24 +62,42 @@ class Issue:
|
|
|
67
62
|
source_line: str = ""
|
|
68
63
|
|
|
69
64
|
|
|
70
|
-
def is_script(filepath: str) -> bool:
|
|
71
|
-
"""Check if file is a script (not a library module).
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
65
|
+
def is_script(filepath: str, config=None) -> bool:
|
|
66
|
+
"""Check if file is a script (not a library module).
|
|
67
|
+
|
|
68
|
+
Uses config.library_patterns and config.library_dirs to determine
|
|
69
|
+
which files are library modules (exempt from script-only rules).
|
|
70
|
+
"""
|
|
71
|
+
from .config import LinterConfig, matches_library_pattern
|
|
72
|
+
|
|
73
|
+
if config is None:
|
|
74
|
+
config = LinterConfig()
|
|
75
|
+
|
|
76
|
+
path = Path(filepath)
|
|
77
|
+
name = path.name
|
|
78
|
+
|
|
79
|
+
# Check filename against library patterns (e.g., __*__.py, test_*.py)
|
|
80
|
+
if matches_library_pattern(name, config):
|
|
78
81
|
return False
|
|
82
|
+
|
|
83
|
+
# Check if file is inside a library directory (e.g., src/)
|
|
84
|
+
parts = path.parts
|
|
85
|
+
for lib_dir in config.library_dirs:
|
|
86
|
+
if lib_dir in parts:
|
|
87
|
+
return False
|
|
88
|
+
|
|
79
89
|
return True
|
|
80
90
|
|
|
81
91
|
|
|
82
92
|
class SciTeXChecker(ast.NodeVisitor):
|
|
83
93
|
"""AST visitor detecting non-SciTeX patterns."""
|
|
84
94
|
|
|
85
|
-
def __init__(self, source_lines: list, filepath: str = "<stdin>"):
|
|
95
|
+
def __init__(self, source_lines: list, filepath: str = "<stdin>", config=None):
|
|
96
|
+
from .config import LinterConfig
|
|
97
|
+
|
|
86
98
|
self.source_lines = source_lines
|
|
87
99
|
self.filepath = filepath
|
|
100
|
+
self.config = config or LinterConfig()
|
|
88
101
|
self.issues: list = []
|
|
89
102
|
|
|
90
103
|
# Tracking state
|
|
@@ -93,7 +106,7 @@ class SciTeXChecker(ast.NodeVisitor):
|
|
|
93
106
|
self._has_session_decorator = False
|
|
94
107
|
self._session_func_returns_int = False
|
|
95
108
|
self._imports: dict = {} # alias -> full module path
|
|
96
|
-
self._is_script = is_script(filepath)
|
|
109
|
+
self._is_script = is_script(filepath, self.config)
|
|
97
110
|
|
|
98
111
|
# -----------------------------------------------------------------
|
|
99
112
|
# Import visitors
|
|
@@ -343,10 +356,15 @@ class SciTeXChecker(ast.NodeVisitor):
|
|
|
343
356
|
# Function/decorator visitors
|
|
344
357
|
# -----------------------------------------------------------------
|
|
345
358
|
|
|
359
|
+
@property
|
|
360
|
+
def _REQUIRED_INJECTED(self):
|
|
361
|
+
return set(self.config.required_injected)
|
|
362
|
+
|
|
346
363
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
347
364
|
if self._has_session_deco(node):
|
|
348
365
|
self._has_session_decorator = True
|
|
349
366
|
self._check_session_return(node)
|
|
367
|
+
self._check_injected_params(node)
|
|
350
368
|
self.generic_visit(node)
|
|
351
369
|
|
|
352
370
|
visit_AsyncFunctionDef = visit_FunctionDef
|
|
@@ -380,6 +398,25 @@ class SciTeXChecker(ast.NodeVisitor):
|
|
|
380
398
|
line = self._get_source(node.lineno)
|
|
381
399
|
self._add(S004, node.lineno, node.col_offset, line)
|
|
382
400
|
|
|
401
|
+
def _check_injected_params(self, node: ast.FunctionDef) -> None:
|
|
402
|
+
"""Check that @stx.session function declares all INJECTED parameters."""
|
|
403
|
+
declared = {arg.arg for arg in node.args.args}
|
|
404
|
+
missing = sorted(self._REQUIRED_INJECTED - declared)
|
|
405
|
+
if missing:
|
|
406
|
+
line = self._get_source(node.lineno)
|
|
407
|
+
missing_str = ", ".join(missing)
|
|
408
|
+
dynamic_rule = Rule(
|
|
409
|
+
id=S006.id,
|
|
410
|
+
severity=S006.severity,
|
|
411
|
+
category=S006.category,
|
|
412
|
+
message=(
|
|
413
|
+
f"@stx.session function missing INJECTED parameters: {missing_str}. "
|
|
414
|
+
f"All 5 must be declared: CONFIG, COLORS, logger, plt, rngg"
|
|
415
|
+
),
|
|
416
|
+
suggestion=S006.suggestion,
|
|
417
|
+
)
|
|
418
|
+
self._add(dynamic_rule, node.lineno, node.col_offset, line)
|
|
419
|
+
|
|
383
420
|
# -----------------------------------------------------------------
|
|
384
421
|
# Module-level checks (run after visiting entire tree)
|
|
385
422
|
# -----------------------------------------------------------------
|
|
@@ -427,11 +464,12 @@ class SciTeXChecker(ast.NodeVisitor):
|
|
|
427
464
|
self.issues.sort(key=lambda i: (-SEVERITY_ORDER[i.rule.severity], i.line))
|
|
428
465
|
return self.issues
|
|
429
466
|
|
|
430
|
-
# -----------------------------------------------------------------
|
|
431
|
-
# Helpers
|
|
432
|
-
# -----------------------------------------------------------------
|
|
433
|
-
|
|
434
467
|
def _add(self, rule: Rule, line: int, col: int, source_line: str) -> None:
|
|
468
|
+
if rule.id in self.config.disable:
|
|
469
|
+
return
|
|
470
|
+
sev = self.config.per_rule_severity.get(rule.id)
|
|
471
|
+
if sev:
|
|
472
|
+
rule = Rule(rule.id, sev, rule.category, rule.message, rule.suggestion)
|
|
435
473
|
self.issues.append(
|
|
436
474
|
Issue(rule=rule, line=line, col=col, source_line=source_line)
|
|
437
475
|
)
|
|
@@ -447,7 +485,7 @@ class SciTeXChecker(ast.NodeVisitor):
|
|
|
447
485
|
# =============================================================================
|
|
448
486
|
|
|
449
487
|
|
|
450
|
-
def lint_source(source: str, filepath: str = "<stdin>") -> list:
|
|
488
|
+
def lint_source(source: str, filepath: str = "<stdin>", config=None) -> list:
|
|
451
489
|
"""Lint Python source code and return list of Issues."""
|
|
452
490
|
try:
|
|
453
491
|
tree = ast.parse(source, filename=filepath)
|
|
@@ -455,15 +493,15 @@ def lint_source(source: str, filepath: str = "<stdin>") -> list:
|
|
|
455
493
|
return []
|
|
456
494
|
|
|
457
495
|
lines = source.splitlines()
|
|
458
|
-
checker = SciTeXChecker(lines, filepath=filepath)
|
|
496
|
+
checker = SciTeXChecker(lines, filepath=filepath, config=config)
|
|
459
497
|
checker.visit(tree)
|
|
460
498
|
return checker.get_issues()
|
|
461
499
|
|
|
462
500
|
|
|
463
|
-
def lint_file(filepath: str) -> list:
|
|
501
|
+
def lint_file(filepath: str, config=None) -> list:
|
|
464
502
|
"""Lint a Python file and return list of Issues."""
|
|
465
503
|
path = Path(filepath)
|
|
466
504
|
if not path.exists() or not path.is_file():
|
|
467
505
|
return []
|
|
468
506
|
source = path.read_text(encoding="utf-8")
|
|
469
|
-
return lint_source(source, filepath=str(path))
|
|
507
|
+
return lint_source(source, filepath=str(path), config=config)
|
scitex_linter/cli.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""CLI entry point for scitex-linter.
|
|
2
2
|
|
|
3
3
|
Usage:
|
|
4
|
-
scitex-linter
|
|
4
|
+
scitex-linter check <path> [--json] [--severity] [--category] [--no-color]
|
|
5
|
+
scitex-linter format <path> [--check] [--diff]
|
|
5
6
|
scitex-linter python <script.py> [--strict] [-- script_args...]
|
|
6
|
-
scitex-linter
|
|
7
|
+
scitex-linter rule [--json] [--category] [--severity]
|
|
7
8
|
scitex-linter mcp start
|
|
8
9
|
scitex-linter mcp list-tools
|
|
9
10
|
scitex-linter --help-recursive
|
|
@@ -15,7 +16,9 @@ import sys
|
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
17
18
|
from . import __version__
|
|
19
|
+
from ._cmd_format import register as _register_format
|
|
18
20
|
from .checker import lint_file
|
|
21
|
+
from .config import load_config
|
|
19
22
|
from .formatter import format_issue, format_summary, to_json
|
|
20
23
|
from .rules import ALL_RULES, SEVERITY_ORDER
|
|
21
24
|
|
|
@@ -24,13 +27,17 @@ from .rules import ALL_RULES, SEVERITY_ORDER
|
|
|
24
27
|
# =========================================================================
|
|
25
28
|
|
|
26
29
|
|
|
27
|
-
def _collect_files(path: Path, recursive: bool = True) -> list:
|
|
30
|
+
def _collect_files(path: Path, recursive: bool = True, config=None) -> list:
|
|
28
31
|
"""Collect Python files from a path."""
|
|
29
32
|
if path.is_file():
|
|
30
33
|
return [path]
|
|
31
34
|
if path.is_dir():
|
|
32
35
|
pattern = "**/*.py" if recursive else "*.py"
|
|
33
|
-
skip =
|
|
36
|
+
skip = (
|
|
37
|
+
set(config.exclude_dirs)
|
|
38
|
+
if config
|
|
39
|
+
else {"__pycache__", ".git", "node_modules", ".tox", "venv", ".venv"}
|
|
40
|
+
)
|
|
34
41
|
return sorted(
|
|
35
42
|
p for p in path.glob(pattern) if not any(s in p.parts for s in skip)
|
|
36
43
|
)
|
|
@@ -38,17 +45,17 @@ def _collect_files(path: Path, recursive: bool = True) -> list:
|
|
|
38
45
|
|
|
39
46
|
|
|
40
47
|
# =========================================================================
|
|
41
|
-
# Subcommand:
|
|
48
|
+
# Subcommand: check
|
|
42
49
|
# =========================================================================
|
|
43
50
|
|
|
44
51
|
|
|
45
|
-
def
|
|
52
|
+
def _register_check(subparsers) -> None:
|
|
46
53
|
p = subparsers.add_parser(
|
|
47
|
-
"
|
|
48
|
-
help="
|
|
49
|
-
description="
|
|
54
|
+
"check",
|
|
55
|
+
help="Check Python files for SciTeX pattern compliance",
|
|
56
|
+
description="Check Python files for SciTeX pattern compliance.",
|
|
50
57
|
)
|
|
51
|
-
p.add_argument("path", help="Python file or directory to
|
|
58
|
+
p.add_argument("path", help="Python file or directory to check")
|
|
52
59
|
p.add_argument("--json", action="store_true", dest="as_json", help="Output as JSON")
|
|
53
60
|
p.add_argument("--no-color", action="store_true", help="Disable colored output")
|
|
54
61
|
p.add_argument(
|
|
@@ -61,10 +68,11 @@ def _register_lint(subparsers) -> None:
|
|
|
61
68
|
"--category",
|
|
62
69
|
help="Filter by category (comma-separated: structure,import,io,plot,stats)",
|
|
63
70
|
)
|
|
64
|
-
p.set_defaults(func=
|
|
71
|
+
p.set_defaults(func=_cmd_check)
|
|
65
72
|
|
|
66
73
|
|
|
67
|
-
def
|
|
74
|
+
def _cmd_check(args) -> int:
|
|
75
|
+
config = load_config(args.path)
|
|
68
76
|
use_color = not args.no_color and sys.stdout.isatty()
|
|
69
77
|
min_sev = SEVERITY_ORDER[args.severity]
|
|
70
78
|
categories = set(args.category.split(",")) if args.category else None
|
|
@@ -74,14 +82,14 @@ def _cmd_lint(args) -> int:
|
|
|
74
82
|
print(f"Error: {args.path} not found", file=sys.stderr)
|
|
75
83
|
return 2
|
|
76
84
|
|
|
77
|
-
files = _collect_files(target)
|
|
85
|
+
files = _collect_files(target, config=config)
|
|
78
86
|
if not files:
|
|
79
87
|
print(f"No Python files found in {args.path}", file=sys.stderr)
|
|
80
88
|
return 0
|
|
81
89
|
|
|
82
90
|
all_results = {}
|
|
83
91
|
for f in files:
|
|
84
|
-
issues = lint_file(str(f))
|
|
92
|
+
issues = lint_file(str(f), config=config)
|
|
85
93
|
issues = [
|
|
86
94
|
i
|
|
87
95
|
for i in issues
|
|
@@ -151,13 +159,13 @@ def _cmd_python(args) -> int:
|
|
|
151
159
|
|
|
152
160
|
|
|
153
161
|
# =========================================================================
|
|
154
|
-
# Subcommand:
|
|
162
|
+
# Subcommand: rule
|
|
155
163
|
# =========================================================================
|
|
156
164
|
|
|
157
165
|
|
|
158
|
-
def
|
|
166
|
+
def _register_rule(subparsers) -> None:
|
|
159
167
|
p = subparsers.add_parser(
|
|
160
|
-
"
|
|
168
|
+
"rule",
|
|
161
169
|
help="List all lint rules",
|
|
162
170
|
description="List all available SciTeX lint rules.",
|
|
163
171
|
)
|
|
@@ -171,10 +179,10 @@ def _register_list_rules(subparsers) -> None:
|
|
|
171
179
|
choices=["error", "warning", "info"],
|
|
172
180
|
help="Filter by severity",
|
|
173
181
|
)
|
|
174
|
-
p.set_defaults(func=
|
|
182
|
+
p.set_defaults(func=_cmd_rule)
|
|
175
183
|
|
|
176
184
|
|
|
177
|
-
def
|
|
185
|
+
def _cmd_rule(args) -> int:
|
|
178
186
|
categories = set(args.category.split(",")) if args.category else None
|
|
179
187
|
rules_list = list(ALL_RULES.values())
|
|
180
188
|
|
|
@@ -272,7 +280,7 @@ def _cmd_mcp_start(args) -> int:
|
|
|
272
280
|
|
|
273
281
|
def _cmd_mcp_list_tools(args) -> int:
|
|
274
282
|
tools = [
|
|
275
|
-
("
|
|
283
|
+
("linter_check", "Check a Python file for SciTeX pattern compliance"),
|
|
276
284
|
("linter_list_rules", "List all available lint rules"),
|
|
277
285
|
("linter_check_source", "Lint Python source code string"),
|
|
278
286
|
]
|
|
@@ -364,7 +372,6 @@ def _cmd_mcp_installation(args) -> int:
|
|
|
364
372
|
def _print_help_recursive(parser, subparsers_actions) -> None:
|
|
365
373
|
"""Print help for all commands recursively."""
|
|
366
374
|
cyan = "\033[96m" if sys.stdout.isatty() else ""
|
|
367
|
-
bold = "\033[1m" if sys.stdout.isatty() else ""
|
|
368
375
|
reset = "\033[0m" if sys.stdout.isatty() else ""
|
|
369
376
|
|
|
370
377
|
bar = "\u2501" * 3
|
|
@@ -409,9 +416,10 @@ def main(argv: list = None) -> int:
|
|
|
409
416
|
|
|
410
417
|
subparsers = parser.add_subparsers(dest="command")
|
|
411
418
|
|
|
412
|
-
|
|
419
|
+
_register_check(subparsers)
|
|
420
|
+
_register_format(subparsers)
|
|
413
421
|
_register_python(subparsers)
|
|
414
|
-
|
|
422
|
+
_register_rule(subparsers)
|
|
415
423
|
_register_mcp(subparsers)
|
|
416
424
|
|
|
417
425
|
# Split on -- to capture script args for the 'python' subcommand
|
scitex_linter/config.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Configuration system for scitex-linter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else:
|
|
14
|
+
try:
|
|
15
|
+
import tomli as tomllib
|
|
16
|
+
except ImportError:
|
|
17
|
+
tomllib = None # type: ignore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class LinterConfig:
|
|
22
|
+
"""Configuration for scitex-linter behavior."""
|
|
23
|
+
|
|
24
|
+
severity: str = "info"
|
|
25
|
+
exclude_dirs: list[str] = field(
|
|
26
|
+
default_factory=lambda: [
|
|
27
|
+
"__pycache__",
|
|
28
|
+
".git",
|
|
29
|
+
"node_modules",
|
|
30
|
+
".tox",
|
|
31
|
+
"venv",
|
|
32
|
+
".venv",
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
library_patterns: list[str] = field(
|
|
36
|
+
default_factory=lambda: [
|
|
37
|
+
"__*__.py",
|
|
38
|
+
"test_*.py",
|
|
39
|
+
"conftest.py",
|
|
40
|
+
"setup.py",
|
|
41
|
+
"manage.py",
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
library_dirs: list[str] = field(default_factory=lambda: ["src"])
|
|
45
|
+
disable: list[str] = field(default_factory=list)
|
|
46
|
+
per_rule_severity: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
required_injected: list[str] = field(
|
|
48
|
+
default_factory=lambda: ["CONFIG", "plt", "COLORS", "rngg", "logger"]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# =============================================================================
|
|
53
|
+
# Configuration Loading
|
|
54
|
+
# =============================================================================
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(start_path: str | None = None) -> LinterConfig:
|
|
58
|
+
"""
|
|
59
|
+
Load configuration from defaults, pyproject.toml, and environment variables.
|
|
60
|
+
|
|
61
|
+
Priority: env vars > pyproject.toml > defaults
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
start_path: Starting directory for pyproject.toml search (defaults to cwd)
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Merged configuration
|
|
68
|
+
"""
|
|
69
|
+
# Start with defaults
|
|
70
|
+
config_dict = {}
|
|
71
|
+
|
|
72
|
+
# Load from pyproject.toml
|
|
73
|
+
start_dir = Path(start_path).resolve() if start_path else Path.cwd()
|
|
74
|
+
pyproject_config = _load_pyproject(start_dir)
|
|
75
|
+
config_dict.update(pyproject_config)
|
|
76
|
+
|
|
77
|
+
# Load from environment variables (highest priority)
|
|
78
|
+
env_config = _load_env()
|
|
79
|
+
config_dict.update(env_config)
|
|
80
|
+
|
|
81
|
+
# Build LinterConfig with merged values
|
|
82
|
+
return LinterConfig(**config_dict)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_pyproject(start_dir: Path) -> dict:
|
|
86
|
+
"""
|
|
87
|
+
Walk up directories to find pyproject.toml with [tool.scitex-linter].
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
start_dir: Starting directory for search
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Configuration dict from [tool.scitex-linter], or empty dict if not found
|
|
94
|
+
"""
|
|
95
|
+
if tomllib is None:
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
current = start_dir
|
|
99
|
+
while True:
|
|
100
|
+
pyproject_path = current / "pyproject.toml"
|
|
101
|
+
if pyproject_path.exists():
|
|
102
|
+
try:
|
|
103
|
+
with open(pyproject_path, "rb") as f:
|
|
104
|
+
data = tomllib.load(f)
|
|
105
|
+
tool_config = data.get("tool", {}).get("scitex-linter", {})
|
|
106
|
+
if tool_config:
|
|
107
|
+
# Flatten nested sections
|
|
108
|
+
config = {}
|
|
109
|
+
for key, value in tool_config.items():
|
|
110
|
+
if key == "per-rule-severity":
|
|
111
|
+
config["per_rule_severity"] = value
|
|
112
|
+
elif key == "session":
|
|
113
|
+
# Handle [tool.scitex-linter.session]
|
|
114
|
+
if "required_injected" in value:
|
|
115
|
+
config["required_injected"] = value[
|
|
116
|
+
"required_injected"
|
|
117
|
+
]
|
|
118
|
+
else:
|
|
119
|
+
# Convert kebab-case to snake_case
|
|
120
|
+
config[key.replace("-", "_")] = value
|
|
121
|
+
return config
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
# Move up one directory
|
|
126
|
+
parent = current.parent
|
|
127
|
+
if parent == current:
|
|
128
|
+
# Reached filesystem root
|
|
129
|
+
break
|
|
130
|
+
current = parent
|
|
131
|
+
|
|
132
|
+
return {}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _load_env() -> dict:
|
|
136
|
+
"""
|
|
137
|
+
Load configuration from environment variables with SCITEX_LINTER_ prefix.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Configuration dict with snake_case keys
|
|
141
|
+
"""
|
|
142
|
+
config = {}
|
|
143
|
+
|
|
144
|
+
# Simple string values
|
|
145
|
+
if "SCITEX_LINTER_SEVERITY" in os.environ:
|
|
146
|
+
config["severity"] = os.environ["SCITEX_LINTER_SEVERITY"]
|
|
147
|
+
|
|
148
|
+
# Comma-separated list values
|
|
149
|
+
if "SCITEX_LINTER_DISABLE" in os.environ:
|
|
150
|
+
config["disable"] = [
|
|
151
|
+
x.strip()
|
|
152
|
+
for x in os.environ["SCITEX_LINTER_DISABLE"].split(",")
|
|
153
|
+
if x.strip()
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
if "SCITEX_LINTER_EXCLUDE_DIRS" in os.environ:
|
|
157
|
+
config["exclude_dirs"] = [
|
|
158
|
+
x.strip()
|
|
159
|
+
for x in os.environ["SCITEX_LINTER_EXCLUDE_DIRS"].split(",")
|
|
160
|
+
if x.strip()
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
if "SCITEX_LINTER_LIBRARY_DIRS" in os.environ:
|
|
164
|
+
config["library_dirs"] = [
|
|
165
|
+
x.strip()
|
|
166
|
+
for x in os.environ["SCITEX_LINTER_LIBRARY_DIRS"].split(",")
|
|
167
|
+
if x.strip()
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
if "SCITEX_LINTER_LIBRARY_PATTERNS" in os.environ:
|
|
171
|
+
config["library_patterns"] = [
|
|
172
|
+
x.strip()
|
|
173
|
+
for x in os.environ["SCITEX_LINTER_LIBRARY_PATTERNS"].split(",")
|
|
174
|
+
if x.strip()
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
if "SCITEX_LINTER_REQUIRED_INJECTED" in os.environ:
|
|
178
|
+
config["required_injected"] = [
|
|
179
|
+
x.strip()
|
|
180
|
+
for x in os.environ["SCITEX_LINTER_REQUIRED_INJECTED"].split(",")
|
|
181
|
+
if x.strip()
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
return config
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# =============================================================================
|
|
188
|
+
# Utility Functions
|
|
189
|
+
# =============================================================================
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def matches_library_pattern(filename: str, config: LinterConfig) -> bool:
|
|
193
|
+
"""
|
|
194
|
+
Check if filename matches any library pattern in config.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
filename: Filename to check (e.g., "__init__.py", "test_foo.py")
|
|
198
|
+
config: Linter configuration
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
True if filename matches any pattern
|
|
202
|
+
"""
|
|
203
|
+
for pattern in config.library_patterns:
|
|
204
|
+
if fnmatch.fnmatch(filename, pattern):
|
|
205
|
+
return True
|
|
206
|
+
return False
|
scitex_linter/fixer.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Auto-fix SciTeX pattern issues in Python source code.
|
|
2
|
+
|
|
3
|
+
Currently handles:
|
|
4
|
+
- S006: Insert missing INJECTED parameters into @stx.session functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# The 5 required INJECTED parameters (in canonical order) — default fallback
|
|
12
|
+
REQUIRED_INJECTED = ["CONFIG", "plt", "COLORS", "rngg", "logger"]
|
|
13
|
+
|
|
14
|
+
# Canonical default value for injected params
|
|
15
|
+
_INJECTED_DEFAULT = "stx.session.INJECTED"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# =========================================================================
|
|
19
|
+
# AST helpers
|
|
20
|
+
# =========================================================================
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _has_session_decorator(node: ast.FunctionDef) -> bool:
|
|
24
|
+
"""Return True if function has @stx.session or @session decorator."""
|
|
25
|
+
for deco in node.decorator_list:
|
|
26
|
+
if isinstance(deco, ast.Attribute):
|
|
27
|
+
if (
|
|
28
|
+
isinstance(deco.value, ast.Name)
|
|
29
|
+
and deco.value.id == "stx"
|
|
30
|
+
and deco.attr == "session"
|
|
31
|
+
):
|
|
32
|
+
return True
|
|
33
|
+
if isinstance(deco, ast.Name) and deco.id == "session":
|
|
34
|
+
return True
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _declared_params(node: ast.FunctionDef) -> list:
|
|
39
|
+
"""Return list of parameter names declared in the function signature."""
|
|
40
|
+
return [arg.arg for arg in node.args.args]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _missing_injected(declared: list, required: list = None) -> list:
|
|
44
|
+
"""Return INJECTED param names not yet declared, preserving canonical order."""
|
|
45
|
+
required = required if required is not None else REQUIRED_INJECTED
|
|
46
|
+
declared_set = set(declared)
|
|
47
|
+
return [p for p in required if p not in declared_set]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_injected_value(default_node: ast.expr) -> bool:
|
|
51
|
+
"""Check if a default value is stx.session.INJECTED or stx.INJECTED."""
|
|
52
|
+
# stx.session.INJECTED
|
|
53
|
+
if isinstance(default_node, ast.Attribute) and default_node.attr == "INJECTED":
|
|
54
|
+
inner = default_node.value
|
|
55
|
+
if isinstance(inner, ast.Attribute):
|
|
56
|
+
if (
|
|
57
|
+
isinstance(inner.value, ast.Name)
|
|
58
|
+
and inner.value.id == "stx"
|
|
59
|
+
and inner.attr == "session"
|
|
60
|
+
):
|
|
61
|
+
return True
|
|
62
|
+
# stx.INJECTED
|
|
63
|
+
if isinstance(inner, ast.Name) and inner.id == "stx":
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_canonical_injected(default_node: ast.expr) -> bool:
|
|
69
|
+
"""Check if a default value is the canonical stx.session.INJECTED form."""
|
|
70
|
+
if isinstance(default_node, ast.Attribute) and default_node.attr == "INJECTED":
|
|
71
|
+
inner = default_node.value
|
|
72
|
+
if isinstance(inner, ast.Attribute):
|
|
73
|
+
if (
|
|
74
|
+
isinstance(inner.value, ast.Name)
|
|
75
|
+
and inner.value.id == "stx"
|
|
76
|
+
and inner.attr == "session"
|
|
77
|
+
):
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _has_non_canonical_injected(node: ast.FunctionDef, required: list = None) -> bool:
|
|
83
|
+
"""Check if any INJECTED param uses stx.INJECTED instead of stx.session.INJECTED."""
|
|
84
|
+
args = node.args.args
|
|
85
|
+
defaults = node.args.defaults
|
|
86
|
+
n_positional = len(args) - len(defaults)
|
|
87
|
+
injected_set = set(required if required is not None else REQUIRED_INJECTED)
|
|
88
|
+
|
|
89
|
+
for i, arg in enumerate(args):
|
|
90
|
+
if arg.arg not in injected_set:
|
|
91
|
+
continue
|
|
92
|
+
default_idx = i - n_positional
|
|
93
|
+
if default_idx >= 0:
|
|
94
|
+
default = defaults[default_idx]
|
|
95
|
+
if _is_injected_value(default) and not _is_canonical_injected(default):
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# =========================================================================
|
|
101
|
+
# Source-level fix for S006
|
|
102
|
+
# =========================================================================
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _find_def_line_range(lines: list, func_node: ast.FunctionDef) -> tuple:
|
|
106
|
+
"""Find the line range (0-indexed) of the 'def ...:' signature.
|
|
107
|
+
|
|
108
|
+
Returns (start_line_idx, colon_line_idx) where:
|
|
109
|
+
- start_line_idx is the line containing 'def '
|
|
110
|
+
- colon_line_idx is the line containing the closing '):'
|
|
111
|
+
"""
|
|
112
|
+
start = func_node.lineno - 1 # 0-indexed
|
|
113
|
+
|
|
114
|
+
# Walk forward from start to find the colon that opens the body
|
|
115
|
+
# The body starts at func_node.body[0].lineno
|
|
116
|
+
body_start = func_node.body[0].lineno - 1 if func_node.body else start + 1
|
|
117
|
+
|
|
118
|
+
# The colon line is somewhere between start and body_start
|
|
119
|
+
# Scan backwards from body_start to find the line with ':'
|
|
120
|
+
colon_line = start
|
|
121
|
+
for i in range(body_start - 1, start - 1, -1):
|
|
122
|
+
stripped = lines[i].rstrip()
|
|
123
|
+
if stripped.endswith(":"):
|
|
124
|
+
colon_line = i
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
return start, colon_line
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _fix_s006_in_source(source: str, filepath: str, config=None) -> str:
|
|
131
|
+
"""Fix S006 violations: add missing INJECTED params to @stx.session functions."""
|
|
132
|
+
try:
|
|
133
|
+
tree = ast.parse(source, filename=filepath)
|
|
134
|
+
except SyntaxError:
|
|
135
|
+
return source
|
|
136
|
+
|
|
137
|
+
required = config.required_injected if config else REQUIRED_INJECTED
|
|
138
|
+
|
|
139
|
+
lines = source.splitlines(keepends=True)
|
|
140
|
+
# Ensure the last line has a newline
|
|
141
|
+
if lines and not lines[-1].endswith("\n"):
|
|
142
|
+
lines[-1] += "\n"
|
|
143
|
+
|
|
144
|
+
# Collect all session functions that need fixing (process in reverse order
|
|
145
|
+
# so line indices remain valid after edits)
|
|
146
|
+
fixes = []
|
|
147
|
+
for node in ast.walk(tree):
|
|
148
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
149
|
+
if _has_session_decorator(node):
|
|
150
|
+
declared = _declared_params(node)
|
|
151
|
+
missing = _missing_injected(declared, required)
|
|
152
|
+
needs_normalize = _has_non_canonical_injected(node, required)
|
|
153
|
+
if missing or needs_normalize:
|
|
154
|
+
fixes.append((node, missing))
|
|
155
|
+
|
|
156
|
+
# Sort by line number descending so we edit from bottom to top
|
|
157
|
+
fixes.sort(key=lambda x: x[0].lineno, reverse=True)
|
|
158
|
+
|
|
159
|
+
for func_node, missing in fixes:
|
|
160
|
+
lines = _apply_s006_fix(lines, func_node, missing, required)
|
|
161
|
+
|
|
162
|
+
return "".join(lines)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _get_def_indent(line: str) -> str:
|
|
166
|
+
"""Extract the leading whitespace from a 'def' line."""
|
|
167
|
+
match = re.match(r"^(\s*)", line)
|
|
168
|
+
return match.group(1) if match else ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _apply_s006_fix(
|
|
172
|
+
lines: list,
|
|
173
|
+
func_node: ast.FunctionDef,
|
|
174
|
+
missing: list,
|
|
175
|
+
required_injected: list = None,
|
|
176
|
+
) -> list:
|
|
177
|
+
"""Apply S006 fix to a single function in the source lines.
|
|
178
|
+
|
|
179
|
+
Strategy:
|
|
180
|
+
1. Find the def line range (from 'def' to the closing ':')
|
|
181
|
+
2. Determine if it's single-line or multi-line
|
|
182
|
+
3. Extract user params and existing injected params from source text
|
|
183
|
+
4. Rebuild the signature with all params
|
|
184
|
+
"""
|
|
185
|
+
if required_injected is None:
|
|
186
|
+
required_injected = REQUIRED_INJECTED
|
|
187
|
+
start_idx, colon_idx = _find_def_line_range(lines, func_node)
|
|
188
|
+
def_indent = _get_def_indent(lines[start_idx])
|
|
189
|
+
param_indent = def_indent + " "
|
|
190
|
+
|
|
191
|
+
# Determine if async
|
|
192
|
+
is_async = isinstance(func_node, ast.AsyncFunctionDef)
|
|
193
|
+
keyword = "async def" if is_async else "def"
|
|
194
|
+
|
|
195
|
+
# Extract the function name
|
|
196
|
+
func_name = func_node.name
|
|
197
|
+
|
|
198
|
+
# Get all existing params with their source text
|
|
199
|
+
# Join the def signature lines
|
|
200
|
+
sig_text = "".join(lines[start_idx : colon_idx + 1])
|
|
201
|
+
|
|
202
|
+
# Extract content between parentheses
|
|
203
|
+
paren_open = sig_text.index("(")
|
|
204
|
+
# Find the matching close paren (searching backward from the colon)
|
|
205
|
+
paren_close = sig_text.rindex(")")
|
|
206
|
+
params_text = sig_text[paren_open + 1 : paren_close].strip()
|
|
207
|
+
|
|
208
|
+
# Parse individual parameter strings from the source text
|
|
209
|
+
existing_param_strings = _split_params(params_text)
|
|
210
|
+
|
|
211
|
+
# Classify params: user params vs injected params
|
|
212
|
+
injected_names = set(required_injected)
|
|
213
|
+
user_param_strings = []
|
|
214
|
+
existing_injected_strings = []
|
|
215
|
+
|
|
216
|
+
for ps in existing_param_strings:
|
|
217
|
+
pname = ps.strip().split("=")[0].split(":")[0].strip()
|
|
218
|
+
if pname in injected_names:
|
|
219
|
+
existing_injected_strings.append(ps)
|
|
220
|
+
elif pname:
|
|
221
|
+
user_param_strings.append(ps)
|
|
222
|
+
|
|
223
|
+
# Build new parameter lines
|
|
224
|
+
new_param_lines = []
|
|
225
|
+
|
|
226
|
+
# User params first (preserve original text)
|
|
227
|
+
for ps in user_param_strings:
|
|
228
|
+
new_param_lines.append(f"{param_indent}{ps.strip()},\n")
|
|
229
|
+
|
|
230
|
+
# All INJECTED params in canonical order (existing + missing)
|
|
231
|
+
existing_injected_names = set()
|
|
232
|
+
for ps in existing_injected_strings:
|
|
233
|
+
pname = ps.strip().split("=")[0].split(":")[0].strip()
|
|
234
|
+
existing_injected_names.add(pname)
|
|
235
|
+
|
|
236
|
+
for p in required_injected:
|
|
237
|
+
if p in existing_injected_names or p in missing:
|
|
238
|
+
new_param_lines.append(f"{param_indent}{p}={_INJECTED_DEFAULT},\n")
|
|
239
|
+
|
|
240
|
+
# Build the new def statement
|
|
241
|
+
new_lines = []
|
|
242
|
+
new_lines.append(f"{def_indent}{keyword} {func_name}(\n")
|
|
243
|
+
new_lines.extend(new_param_lines)
|
|
244
|
+
new_lines.append(f"{def_indent}):\n")
|
|
245
|
+
|
|
246
|
+
# Replace old def lines with new ones
|
|
247
|
+
lines[start_idx : colon_idx + 1] = new_lines
|
|
248
|
+
|
|
249
|
+
return lines
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _split_params(params_text: str) -> list:
|
|
253
|
+
"""Split a parameter string respecting nested parentheses and brackets.
|
|
254
|
+
|
|
255
|
+
Handles cases like:
|
|
256
|
+
'x=1, y="hello, world"'
|
|
257
|
+
'x=dict(a=1, b=2), y=3'
|
|
258
|
+
"""
|
|
259
|
+
if not params_text.strip():
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
params = []
|
|
263
|
+
depth = 0
|
|
264
|
+
current = []
|
|
265
|
+
in_string = None
|
|
266
|
+
|
|
267
|
+
for ch in params_text:
|
|
268
|
+
if in_string:
|
|
269
|
+
current.append(ch)
|
|
270
|
+
if ch == in_string:
|
|
271
|
+
in_string = None
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
if ch in ('"', "'"):
|
|
275
|
+
in_string = ch
|
|
276
|
+
current.append(ch)
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
if ch in ("(", "[", "{"):
|
|
280
|
+
depth += 1
|
|
281
|
+
current.append(ch)
|
|
282
|
+
elif ch in (")", "]", "}"):
|
|
283
|
+
depth -= 1
|
|
284
|
+
current.append(ch)
|
|
285
|
+
elif ch == "," and depth == 0:
|
|
286
|
+
param = "".join(current).strip()
|
|
287
|
+
if param:
|
|
288
|
+
params.append(param)
|
|
289
|
+
current = []
|
|
290
|
+
else:
|
|
291
|
+
current.append(ch)
|
|
292
|
+
|
|
293
|
+
# Last param
|
|
294
|
+
param = "".join(current).strip()
|
|
295
|
+
if param:
|
|
296
|
+
params.append(param)
|
|
297
|
+
|
|
298
|
+
return params
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# =========================================================================
|
|
302
|
+
# Public API
|
|
303
|
+
# =========================================================================
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def fix_source(source: str, filepath: str = "<stdin>", config=None) -> str:
|
|
307
|
+
"""Auto-fix SciTeX issues in source code. Returns fixed source."""
|
|
308
|
+
return _fix_s006_in_source(source, filepath, config=config)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def fix_file(filepath: str, write: bool = True, config=None) -> tuple:
|
|
312
|
+
"""Fix a file in place. Returns (fixed_source, changed).
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
filepath: Path to the Python file.
|
|
316
|
+
write: If True, write the fixed source back to the file.
|
|
317
|
+
config: Optional LinterConfig instance.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Tuple of (fixed_source, changed) where changed is a bool.
|
|
321
|
+
"""
|
|
322
|
+
path = Path(filepath)
|
|
323
|
+
if not path.exists() or not path.is_file():
|
|
324
|
+
return ("", False)
|
|
325
|
+
|
|
326
|
+
original = path.read_text(encoding="utf-8")
|
|
327
|
+
fixed = fix_source(original, filepath=str(path), config=config)
|
|
328
|
+
changed = fixed != original
|
|
329
|
+
|
|
330
|
+
if write and changed:
|
|
331
|
+
path.write_text(fixed, encoding="utf-8")
|
|
332
|
+
|
|
333
|
+
return (fixed, changed)
|
scitex_linter/formatter.py
CHANGED
scitex_linter/rules.py
CHANGED
|
@@ -67,6 +67,25 @@ S005 = Rule(
|
|
|
67
67
|
suggestion="Add `import scitex as stx` to use SciTeX modules.",
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
+
S006 = Rule(
|
|
71
|
+
id="STX-S006",
|
|
72
|
+
severity="warning",
|
|
73
|
+
category="structure",
|
|
74
|
+
message="@stx.session function missing explicit INJECTED parameters",
|
|
75
|
+
suggestion=(
|
|
76
|
+
"Declare auto-injected values explicitly in the function signature:\n"
|
|
77
|
+
" @stx.session\n"
|
|
78
|
+
" def main(\n"
|
|
79
|
+
" CONFIG=stx.session.INJECTED,\n"
|
|
80
|
+
" plt=stx.session.INJECTED,\n"
|
|
81
|
+
" COLORS=stx.session.INJECTED,\n"
|
|
82
|
+
" rngg=stx.session.INJECTED,\n"
|
|
83
|
+
" logger=stx.session.INJECTED,\n"
|
|
84
|
+
" ):\n"
|
|
85
|
+
" return 0"
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
70
89
|
|
|
71
90
|
# =============================================================================
|
|
72
91
|
# Category I: Imports
|
|
@@ -348,6 +367,7 @@ ALL_RULES = {
|
|
|
348
367
|
S003,
|
|
349
368
|
S004,
|
|
350
369
|
S005,
|
|
370
|
+
S006,
|
|
351
371
|
I001,
|
|
352
372
|
I002,
|
|
353
373
|
I003,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scitex-linter
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: AST-based linter enforcing SciTeX reproducible research patterns
|
|
5
5
|
Author: ywatanabe
|
|
6
6
|
License: AGPL-3.0
|
|
@@ -70,20 +70,21 @@ pip install scitex-linter
|
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
# Lint a file
|
|
73
|
-
scitex-linter
|
|
73
|
+
scitex-linter check script.py
|
|
74
74
|
|
|
75
75
|
# Lint then execute
|
|
76
76
|
scitex-linter python experiment.py --strict
|
|
77
77
|
|
|
78
78
|
# List all 35 rules
|
|
79
|
-
scitex-linter
|
|
79
|
+
scitex-linter rule
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
##
|
|
82
|
+
## Five Interfaces
|
|
83
83
|
|
|
84
84
|
| Interface | For | Description |
|
|
85
85
|
|-----------|-----|-------------|
|
|
86
|
-
| 🖥️ **CLI** | Terminal users | `scitex-linter
|
|
86
|
+
| 🖥️ **CLI** | Terminal users | `scitex-linter check`, `scitex-linter python` |
|
|
87
|
+
| ✨ **Format** | Auto-fix | `scitex-linter format` — auto-fix SciTeX issues |
|
|
87
88
|
| 🐍 **Python API** | Programmatic use | `from scitex_linter.checker import lint_file` |
|
|
88
89
|
| 🔌 **flake8 Plugin** | CI pipelines | `flake8 --select STX` |
|
|
89
90
|
| 🔧 **MCP Server** | AI agents | 3 tools for Claude/GPT integration |
|
|
@@ -97,12 +98,18 @@ scitex-linter list-rules
|
|
|
97
98
|
scitex-linter --help # Show all commands
|
|
98
99
|
scitex-linter --help-recursive # Show help for all subcommands
|
|
99
100
|
|
|
100
|
-
#
|
|
101
|
-
scitex-linter
|
|
102
|
-
scitex-linter
|
|
103
|
-
scitex-linter
|
|
104
|
-
scitex-linter
|
|
105
|
-
scitex-linter
|
|
101
|
+
# Check - Check for SciTeX pattern violations
|
|
102
|
+
scitex-linter check script.py # Check a file
|
|
103
|
+
scitex-linter check ./src/ # Check a directory
|
|
104
|
+
scitex-linter check script.py --severity error # Only errors
|
|
105
|
+
scitex-linter check script.py --category path # Only path rules
|
|
106
|
+
scitex-linter check script.py --json # JSON output for CI
|
|
107
|
+
|
|
108
|
+
# Format - Auto-fix SciTeX pattern issues
|
|
109
|
+
scitex-linter format script.py # Fix in place
|
|
110
|
+
scitex-linter format script.py --check # Dry run (exit 1 if changes needed)
|
|
111
|
+
scitex-linter format script.py --diff # Show diff of changes
|
|
112
|
+
scitex-linter format ./src/ # Format a directory
|
|
106
113
|
|
|
107
114
|
# Python - Lint then execute
|
|
108
115
|
scitex-linter python experiment.py # Lint and run
|
|
@@ -110,9 +117,9 @@ scitex-linter python experiment.py --strict # Abort on errors
|
|
|
110
117
|
scitex-linter python experiment.py -- --lr 0.001 # Pass script args
|
|
111
118
|
|
|
112
119
|
# Rules - Browse available rules
|
|
113
|
-
scitex-linter
|
|
114
|
-
scitex-linter
|
|
115
|
-
scitex-linter
|
|
120
|
+
scitex-linter rule # List all 35 rules
|
|
121
|
+
scitex-linter rule --category stats # Filter by category
|
|
122
|
+
scitex-linter rule --json # JSON output
|
|
116
123
|
|
|
117
124
|
# MCP - AI agent server
|
|
118
125
|
scitex-linter mcp start # Start MCP server (stdio)
|
|
@@ -165,7 +172,7 @@ Integrates with existing flake8 workflows, pre-commit hooks, and CI pipelines.
|
|
|
165
172
|
|
|
166
173
|
| Tool | Description |
|
|
167
174
|
|------|-------------|
|
|
168
|
-
| `
|
|
175
|
+
| `linter_check` | Check a Python file for SciTeX compliance |
|
|
169
176
|
| `linter_list_rules` | List all available rules |
|
|
170
177
|
| `linter_check_source` | Lint source code string |
|
|
171
178
|
|
|
@@ -238,6 +245,37 @@ SciTeX Linter works as a **post-tool-use hook** for Claude Code, automatically l
|
|
|
238
245
|
|
|
239
246
|
This ensures AI-generated code follows SciTeX patterns from the start.
|
|
240
247
|
|
|
248
|
+
## Configuration
|
|
249
|
+
|
|
250
|
+
<details>
|
|
251
|
+
<summary><strong>Configure via pyproject.toml or environment variables</strong></summary>
|
|
252
|
+
|
|
253
|
+
<br>
|
|
254
|
+
|
|
255
|
+
```toml
|
|
256
|
+
[tool.scitex-linter]
|
|
257
|
+
severity = "info" # Minimum severity: error, warning, info
|
|
258
|
+
disable = ["STX-P004", "STX-I003"] # Disable specific rules
|
|
259
|
+
exclude-dirs = ["venv", ".venv"] # Directories to skip
|
|
260
|
+
library-dirs = ["src"] # Exempt from script-only rules
|
|
261
|
+
|
|
262
|
+
[tool.scitex-linter.per-rule-severity]
|
|
263
|
+
STX-S003 = "warning" # Downgrade argparse rule
|
|
264
|
+
|
|
265
|
+
[tool.scitex-linter.session]
|
|
266
|
+
required-injected = ["CONFIG", "plt", "COLORS", "rngg", "logger"]
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Environment variables (highest priority):
|
|
270
|
+
```bash
|
|
271
|
+
SCITEX_LINTER_SEVERITY=error
|
|
272
|
+
SCITEX_LINTER_DISABLE=STX-P004,STX-I003
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Priority: CLI flags > env vars > pyproject.toml > defaults
|
|
276
|
+
|
|
277
|
+
</details>
|
|
278
|
+
|
|
241
279
|
## What a Clean Script Looks Like
|
|
242
280
|
|
|
243
281
|
```python
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
scitex_linter/__init__.py,sha256=le0V8F4gkjD3t20kzA4eF8z-HSkw1DL9Ry0X5pZc3XM,104
|
|
2
|
+
scitex_linter/_cmd_format.py,sha256=pQVVXTzJeBs6ailWfAc9xOl4Pt9IHKOThDFHDKLAWKM,2094
|
|
3
|
+
scitex_linter/_server.py,sha256=hwB1ghS2L9bHtFoPEK2M564Lm-bEAdsGR83DTDMES8o,546
|
|
4
|
+
scitex_linter/checker.py,sha256=jwKaodez8a1bv4qWNSwPm9SBVZ4DUeMwIEKFMprG3ko,18450
|
|
5
|
+
scitex_linter/cli.py,sha256=su5j_lS-RJH4Om0J22GK6UL2lLGuZqdP3MlHYoXOOzI,14143
|
|
6
|
+
scitex_linter/config.py,sha256=8mSdTHhmCGBaM_Xb7GQD8CGuY5divloGADmBLdFwu1s,6117
|
|
7
|
+
scitex_linter/fixer.py,sha256=OVX3TYHf_7FW6bX7zzEQa1_mnF-ykfKrfkYyMTi2pDg,11107
|
|
8
|
+
scitex_linter/flake8_plugin.py,sha256=2FfGpCy8lWNgdkk09jRTYOILMkGIP_EJS6x0l9q-Wvc,1024
|
|
9
|
+
scitex_linter/formatter.py,sha256=NvMIsC23m6zWO6HbVh5khum1dj7eb2nyhi0QyloSlhM,3074
|
|
10
|
+
scitex_linter/rules.py,sha256=ZR8vbElYexQtP5anewuMijrfWbwFhBC35T-WH_qBdHM,11971
|
|
11
|
+
scitex_linter/runner.py,sha256=JcSII85keuhOXxtCM8vS_HSH0FDUr6t48nqoSz8IGRU,2211
|
|
12
|
+
scitex_linter/_mcp/__init__.py,sha256=mrI1dFFxyTRq2F_yxoLYRnyYro2f8CrRNNk2NYLBpWM,41
|
|
13
|
+
scitex_linter/_mcp/tools/__init__.py,sha256=ZggLuukCaOZ2XhfkNQvYMuszY0BX0hDy9I50hUDJ_xo,208
|
|
14
|
+
scitex_linter/_mcp/tools/lint.py,sha256=97o9ypwcOQ_6xPJXScxP7pZ1iRDtLlrWS9XPKe0wbYE,2308
|
|
15
|
+
scitex_linter-0.1.2.dist-info/licenses/LICENSE,sha256=TfPDBt3ar0uv_f9cqCDMZ5rIzW3CY8anRRd4PkL6ejs,34522
|
|
16
|
+
scitex_linter-0.1.2.dist-info/METADATA,sha256=3Pn744Yg-NmzZstHWkm7quOvv5-Xn-4NREpZzuttcn0,10156
|
|
17
|
+
scitex_linter-0.1.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
18
|
+
scitex_linter-0.1.2.dist-info/entry_points.txt,sha256=a0bIGVJmB96OPDbcLMn_mO_ZCZpv8SKlHMIhpxjLt5M,131
|
|
19
|
+
scitex_linter-0.1.2.dist-info/top_level.txt,sha256=FVu-yranpm0Bt0QZA7ohavSovYgCAhhh5YqiPB2HTJQ,14
|
|
20
|
+
scitex_linter-0.1.2.dist-info/RECORD,,
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
scitex_linter/__init__.py,sha256=3Jt4Nr1NFo5kd8ctgaTWrQXCfiECWZu32HuNl_SXLIM,104
|
|
2
|
-
scitex_linter/_server.py,sha256=yzhsPDfNZVjDxc0KkrNYqZ9625ZUdPL0820Sk0zfb3Q,544
|
|
3
|
-
scitex_linter/checker.py,sha256=_qDFxPvwUMOhQSAUQv-jT0eApV8uXUSjbhFMGPFVas4,16789
|
|
4
|
-
scitex_linter/cli.py,sha256=1Aau478i6BIxr5q4A8Xbuda8PYZQVw-78U-KxBYhIPk,13888
|
|
5
|
-
scitex_linter/flake8_plugin.py,sha256=2FfGpCy8lWNgdkk09jRTYOILMkGIP_EJS6x0l9q-Wvc,1024
|
|
6
|
-
scitex_linter/formatter.py,sha256=WgFHJCl0rPxdm6e2t0htjFSI5uyPyGVxX4VFtPs6XEs,3075
|
|
7
|
-
scitex_linter/rules.py,sha256=kLxUhb3H-zjeR3M-FRBT5JONhyBoPORCPjEU8qYhT2w,11372
|
|
8
|
-
scitex_linter/runner.py,sha256=JcSII85keuhOXxtCM8vS_HSH0FDUr6t48nqoSz8IGRU,2211
|
|
9
|
-
scitex_linter/_mcp/__init__.py,sha256=mrI1dFFxyTRq2F_yxoLYRnyYro2f8CrRNNk2NYLBpWM,41
|
|
10
|
-
scitex_linter/_mcp/tools/__init__.py,sha256=ZggLuukCaOZ2XhfkNQvYMuszY0BX0hDy9I50hUDJ_xo,208
|
|
11
|
-
scitex_linter/_mcp/tools/lint.py,sha256=cDSzzoCP0XrzVJ72PaeGpE4opz7gmV9jrwwNV2jb8XM,2118
|
|
12
|
-
scitex_linter-0.1.0.dist-info/licenses/LICENSE,sha256=TfPDBt3ar0uv_f9cqCDMZ5rIzW3CY8anRRd4PkL6ejs,34522
|
|
13
|
-
scitex_linter-0.1.0.dist-info/METADATA,sha256=ANqfwltlnFTmqcMHljHoqe7dLwUnWoxjh8415dYjwtY,8921
|
|
14
|
-
scitex_linter-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
15
|
-
scitex_linter-0.1.0.dist-info/entry_points.txt,sha256=a0bIGVJmB96OPDbcLMn_mO_ZCZpv8SKlHMIhpxjLt5M,131
|
|
16
|
-
scitex_linter-0.1.0.dist-info/top_level.txt,sha256=FVu-yranpm0Bt0QZA7ohavSovYgCAhhh5YqiPB2HTJQ,14
|
|
17
|
-
scitex_linter-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|