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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """SciTeX Linter — enforce reproducible research patterns via AST analysis."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -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
@@ -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 linter_lint(
10
+ def linter_check(
11
11
  path: str, severity: str = "info", category: Optional[str] = None
12
12
  ) -> dict:
13
- """[linter] Lint a Python file for SciTeX pattern compliance."""
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
- issues = lint_file(path)
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
- issues = lint_source(source, filepath=filepath)
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
- - linter_lint: Lint a Python file
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, S004, S005 = (
12
- rules.S001,
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
- name = Path(filepath).name
73
- if name == "__init__.py":
74
- return False
75
- if name.startswith("test_") or name == "conftest.py":
76
- return False
77
- if name in ("setup.py", "manage.py"):
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 lint <path> [--json] [--severity] [--category] [--no-color]
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 list-rules [--json] [--category] [--severity]
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 = {"__pycache__", ".git", "node_modules", ".tox", "venv", ".venv"}
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: lint
48
+ # Subcommand: check
42
49
  # =========================================================================
43
50
 
44
51
 
45
- def _register_lint(subparsers) -> None:
52
+ def _register_check(subparsers) -> None:
46
53
  p = subparsers.add_parser(
47
- "lint",
48
- help="Lint Python files for SciTeX pattern compliance",
49
- description="Lint Python files for SciTeX pattern compliance.",
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 lint")
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=_cmd_lint)
71
+ p.set_defaults(func=_cmd_check)
65
72
 
66
73
 
67
- def _cmd_lint(args) -> int:
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: list-rules
162
+ # Subcommand: rule
155
163
  # =========================================================================
156
164
 
157
165
 
158
- def _register_list_rules(subparsers) -> None:
166
+ def _register_rule(subparsers) -> None:
159
167
  p = subparsers.add_parser(
160
- "list-rules",
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=_cmd_list_rules)
182
+ p.set_defaults(func=_cmd_rule)
175
183
 
176
184
 
177
- def _cmd_list_rules(args) -> int:
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
- ("linter_lint", "Lint a Python file for SciTeX pattern compliance"),
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
- _register_lint(subparsers)
419
+ _register_check(subparsers)
420
+ _register_format(subparsers)
413
421
  _register_python(subparsers)
414
- _register_list_rules(subparsers)
422
+ _register_rule(subparsers)
415
423
  _register_mcp(subparsers)
416
424
 
417
425
  # Split on -- to capture script args for the 'python' subcommand
@@ -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)
@@ -1,6 +1,5 @@
1
1
  """Output formatting for terminal and JSON."""
2
2
 
3
-
4
3
  from .checker import Issue
5
4
 
6
5
  # ANSI colors
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.0
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 lint script.py
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 list-rules
79
+ scitex-linter rule
80
80
  ```
81
81
 
82
- ## Four Interfaces
82
+ ## Five Interfaces
83
83
 
84
84
  | Interface | For | Description |
85
85
  |-----------|-----|-------------|
86
- | 🖥️ **CLI** | Terminal users | `scitex-linter lint`, `scitex-linter python` |
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
- # Lint - Check for SciTeX pattern violations
101
- scitex-linter lint script.py # Lint a file
102
- scitex-linter lint ./src/ # Lint a directory
103
- scitex-linter lint script.py --severity error # Only errors
104
- scitex-linter lint script.py --category path # Only path rules
105
- scitex-linter lint script.py --json # JSON output for CI
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 list-rules # List all 35 rules
114
- scitex-linter list-rules --category stats # Filter by category
115
- scitex-linter list-rules --json # JSON output
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
- | `linter_lint` | Lint a Python file for SciTeX compliance |
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,,