Skip to content

Scripts Reference

Language-agnostic: the scanning workflow applies to any project. The scripts support Python (via its ast module) and PHP (via tree-sitter-php). For other languages, apply the principles manually by reading the code — the scripts are a triage accelerator, not a requirement.

All scripts live in per-skill subfolders under src/zolletta_metaskill/ (patterns/python/, patterns/php/, patterns/general/, code_style/general/, code_style/python/, code_style/php/, testing_style/general/, testing_style/python/). Language-agnostic scanners consume a ModuleInfo data model produced by a LanguageEngine — no code execution required.

Standard API: every scanner takes [--json] only. All configuration — source/test roots, thresholds, check toggles — comes from .zolletta-metaskill/settings.json (generated by the setup skill). Each check is gated by a <lang>.<area>.check_* toggle; when the toggle is off for every configured language the run reports SKIPPED. Violations are report-only — scanners exit 0 whether or not findings exist, and exit 1 only for usage errors (e.g. no configured roots exist on disk). File enumeration is git-aware: anything git ignores never appears in reports.

Triage Scripts

class_metrics_scanner.py

Scans source roots and reports every class sorted by line count, with method count, public method count, and self.* attribute count.

python3 src/zolletta_metaskill/patterns/general/class_metrics_scanner.py [--json]
Setting Default Description
python.paths.source [src] Source roots to scan
python.patterns.class_metrics_top 30 Show only the top N classes
python.patterns.class_metrics_min_lines 50 Skip classes shorter than N lines

Output: a table with columns LINES, ALL (methods), PUB (public methods), ATTRS (self.* attributes), CLASS, and file:start-end.

Use the output to identify candidates, then read the code to apply the "reason to change" test.

test_god_classes_scanner.py

Scans test roots and reports test classes sorted by size, with method count and method names. Detects test classes that test multiple unrelated SUTs.

python3 src/zolletta_metaskill/patterns/general/test_god_classes_scanner.py [--json] [--show-methods]
Option / Setting Default Description
--show-methods off List all method names per class (helps spot mixed SUTs)
python.paths.tests [tests] Test roots to scan
python.patterns.test_god_classes_top 30 Show only the top N classes
python.patterns.check_test_god_classes true Check toggle

Structural Convention Scripts

one_class_per_file_scanner.py

Checks the "1 class 1 file, 1 file 1 class" convention. Reports files with 2+ classes, files with 0 classes (non-__init__.py, source roots only), and class names that don't match the filename. Name matching is case-insensitive, so acronym-cased classes (ADRCache, PHPEngine) match their lowercase filenames — letter casing is the acronym-casing check's domain.

Test roots are scanned too: a test file must hold exactly one test class named after its stem (test_user.py → class TestUser), so one source class maps to one merged test class per file. conftest.py is skipped, and function-style test files (no classes) are not flagged — zero-class findings are suppressed for test roots.

python3 src/zolletta_metaskill/code_style/general/one_class_per_file_scanner.py [--json]
Setting Default Description
<lang>.code_style.check_one_class_per_file true Per-language toggle
<lang>.code_style.check_one_class_per_test_file true Also scan test roots (one test class per test file)
python.code_style.check_zero_class_files false Hide 0-class (utility module) findings

Exceptions: __init__.py and conftest.py are always skipped. Files with 0 classes are reported as low severity.

file_length_scanner.py

Flags files that exceed a configurable maximum line count. Language-agnostic — it does not parse code, it counts lines. Implements the "file length" sensor from Martin Fowler's Maintainability sensors for coding agents.

What is scanned and with what limit is driven entirely by .zolletta-metaskill/settings.json: the scanner reads the project's language (and any populated <language> sections) and scans those extensions, honours each language's code_style.check_file_length toggle (a disabled language is not scanned; if it is off for every configured language the run reports SKIPPED), and reads each enabled language's code_style.max_file_length (the smallest wins, default 800). It also skips anything git ignores (.gitignore, .git/info/exclude, core.excludesFile) — so vendor/, node_modules/, and build output never appear in the report. Outside a git repository every matching file is scanned.

python3 src/zolletta_metaskill/code_style/general/file_length_scanner.py [--json]
Option Default Description
--json off Output as JSON instead of text

Violations are report-only — the scanner exits 0 whether or not any are found.

Git-ignored files are never scanned; files with extensions not belonging to the scanned language(s) are skipped as well.

function_length_scanner.py

Flags functions and methods whose line span exceeds a configurable maximum — the "function length" sensor from Martin Fowler's Maintainability sensors for coding agents. The scanner parses each file via its language engine and measures end_lineno - lineno + 1 per function/method: signature and body (docstring included) count, decorators and docblocks do not. Lines are not statements — comments and blank lines inflate the count versus ruff PLR0915 (max-statements), so the default is intentionally lenient.

What is scanned and with what limit is driven entirely by .zolletta-metaskill/settings.json: the scanner reads the project's language (and any populated <language> sections) and scans those extensions, honours each language's code_style.check_function_length toggle (a disabled language is not scanned; if it is off for every configured language the run reports SKIPPED), and reads each enabled language's code_style.max_function_length (the smallest wins, default 100). It skips anything git ignores and any file that fails to parse (a Could not parse warning; PHP files on hosts without the optional tree-sitter-php dependency degrade the same way).

python3 src/zolletta_metaskill/code_style/general/function_length_scanner.py [--json]
Option Default Description
--json off Output as JSON instead of text

Violations are report-only — the scanner exits 0 whether or not any are found; 1 only when no configured source directory exists on disk.

suppression_reason_scanner.py

Flags type-checker suppressions that lack an error code or a reason — the deterministic half of the "suppress-with-reason" convention (Fowler — Maintainability sensors for coding agents, "Guidance for self-correction"). A suppressed diagnostic on a deliberately broad type is an acceptable judgment call only when the suppression carries the specific checker code/identifier and a reason explaining why the type is broad.

Per-language checks:

  • Python — # type: ignore comments (real comments only, via tokenize): missing error code when no [<code>] list follows (ruff PGH003 equivalent); missing reason when no free text follows on the same line and the line above is not a # comment.
  • PHP — @phpstan-ignore, @phpstan-ignore-line, @phpstan-ignore-next-line, @psalm-suppress inside //, /* */, /** */ comments: missing identifier where the slot exists but is empty (@phpstan-ignore-next-line has none — reason only); missing reason when no free text follows (docblock * continuation lines count).

Languages and extensions resolve from .zolletta-metaskill/settings.json via the engine registry — only .py/.php are scanned; with no usable language the run reports SKIPPED. Scan roots come from python.paths.source / php.autoload.psr-4 (src fallback); git-ignored files are skipped.

python3 src/zolletta_metaskill/code_style/general/suppression_reason_scanner.py [--json]
Option Default Description
--json off Output as JSON instead of text

Violations are report-only — the scanner exits 0 whether or not any are found; 1 only when no configured source directory exists on disk.

max_arguments_scanner.py

Flags functions and methods declaring more parameters than the configured threshold — the "max number of arguments" sensor from Martin Fowler's Maintainability sensors for coding agents. Long parameter lists are a classic AI-generation smell; the fix is usually a parameter object or a small collaborator.

Parameter counting matches ruff PLR0913 (max-args) semantics: positional-only, positional-or-keyword, and keyword-only parameters count; self/cls, *args/**kwargs, and PHP variadics (...$args) do not.

Languages and extensions resolve from .zolletta-metaskill/settings.json via the engine registry — only configured languages are scanned. Each language's code_style.check_max_arguments toggles the check (if it is off for every configured language the run reports SKIPPED), and code_style.max_arguments sets the threshold (smallest across enabled languages wins, default 5 — seeded from python.tools.ruff.max_args when configured). Scan roots come from python.paths.source / php.autoload.psr-4 (src fallback); git-ignored files are skipped. Unparseable files warn and are skipped.

python3 src/zolletta_metaskill/code_style/general/max_arguments_scanner.py [--json]
Option Default Description
--json off Output as JSON instead of text

Violations are report-only — the scanner exits 0 whether or not any are found; 1 only when no configured source directory exists on disk.

cyclomatic_complexity_scanner.py

Flags functions and methods whose cyclomatic complexity exceeds the configured threshold — the "cyclomatic complexity" sensor from Martin Fowler's Maintainability sensors for coding agents. Deeply branched functions are hard to review and test; a complex function is a strong "extract collaborator" signal.

Counting matches ruff C901 ([tool.ruff.lint.mccabe] max-complexity) for Python — each if/elif, for/async for, while, except handler, non-catch-all match case, try else clause, and nested def adds one decision point; boolean operators, ternaries, and comprehensions add none, and nested functions are reported independently while folding into the enclosing count. For PHP (via tree-sitter) each if/elseif, for, foreach, while, do, case, match arm, catch, ternary, and &&/||/?? operator adds one; closures count independently. PHPStan and Psalm have no built-in McCabe rule, so the scanner is the deterministic check for .php files.

Languages and extensions resolve from .zolletta-metaskill/settings.json via the engine registry — only configured languages are scanned. Each language's code_style.check_cyclomatic_complexity toggles the check (if it is off for every configured language the run reports SKIPPED), and code_style.max_cyclomatic_complexity sets the threshold (smallest across enabled languages wins, default 10 — seeded from python.tools.ruff.max_complexity when configured). Scan roots come from python.paths.source / php.autoload.psr-4 (src fallback); git-ignored files are skipped. Unparseable files warn and are skipped (PHP files on hosts without the optional tree-sitter-php dependency degrade the same way).

python3 src/zolletta_metaskill/code_style/general/cyclomatic_complexity_scanner.py [--json]
Option Default Description
--json off Output as JSON instead of text

Violations are report-only — the scanner exits 0 whether or not any are found; 1 only when no configured source directory exists on disk.

integration_graph_scanner.py

Emits the project's internal import graph — the triage input for the Balanced Coupling assessment (balanced-coupling.md). Each ImportInfo from the language engines is resolved to a file inside the scanned tree: Python dotted modules map to pkg/mod.py or pkg/mod/__init__.py under a scan root (relative imports walk up the importer's package directories), PHP use clauses map to Ns/Sub/Cls.php (PSR-4 approximation). Imports that resolve to nothing internal (stdlib, third-party, vendor) are counted as external_imports, never turned into edges.

Each edge carries {from, to, line, names: [{name, usage}]} where usage is the deterministic signal for strength classification — Python via ast (call, extends, type, attribute, import-only), PHP via tree-sitter (new, extends, type, import-only). A per-module fan_in/fan_out summary accompanies the edges. This is a triage artifact, not a Finding producer — the judgment pass applies the balance rule.

Languages and scan roots come from .zolletta-metaskill/settings.json via the engine registry (python.paths.source / php.autoload.psr-4, src fallback); patterns scanners run unconditionally — no check_* toggle. Git-ignored files are skipped; unparseable files warn and are skipped.

python3 src/zolletta_metaskill/patterns/general/integration_graph_scanner.py [--json]
Option Default Description
--json off Output as JSON instead of text

Report-only — the scanner exits 0 whether or not the graph has edges (an empty graph is an empty edge list, not an error); 1 only when no configured source directory exists on disk.

test_structure_scanner.py

Checks that the test directory structure mirrors the source directory structure. Outputs a markdown report with five tables:

  1. Misnamed tests — test files whose name doesn't match the source stem or class name of the source they test. Action: rename.
  2. Misplaced tests — test files with a name matching a source file but located in the wrong directory. Action: move.
  3. Orphaned tests — test files or directories that don't match any source file or directory. Action: delete or investigate.
  4. Missing tests — source files with classes that have no direct test file and no indirect class reference in any test file. Action: write new tests.
  5. Indirect references — test files that reference classes from source files without a direct test. Informative only: shows which test files provide indirect coverage for otherwise untested source files.
python3 src/zolletta_metaskill/testing_style/general/test_structure_scanner.py [--json]
Setting Default Description
python.paths.source/tests src/tests Roots to compare
python.paths.package auto-detect Package path within the source root
python.patterns.check_test_structure true Check toggle

File matching convention: Test files match source files by stem prefix: test_cache_* matches cache.py.

naming_conventions_scanner.py

Checks two naming conventions in a single pass:

  1. Source file name == class name — each source file with exactly one class should have a filename matching the class name (snake_case file → PascalCase class). Files with 0 or 2+ classes are skipped (handled by one_class_per_file_scanner.py).
  2. Test file naming — every test_*.py file must follow test_<source_stem><eventual_suffix>.py, where <source_stem> is the stem of a source file (or the snake_case form of a source class name) in the mirrored source directory. Test files that don't match any source file or class are reported as orphan/misnamed.
python3 src/zolletta_metaskill/code_style/general/naming_conventions_scanner.py [--json]
Setting Default Description
python.paths.source/tests src/tests Roots to compare
python.paths.package auto-detect Package path within the source root
python.code_style.check_filename_matches_class true Check toggle

Matching logic: Longest stem-prefix match wins; class-name prefixes also checked.

SOLID Validator Scripts

dependency_inversion_scanner.py (DIP, Python)

Detects classes that instantiate their dependencies internally (self.x = SomeClass(...)) instead of receiving them as constructor parameters. Excludes entry points, dataclasses, factories, and stdlib types.

python3 src/zolletta_metaskill/patterns/python/dependency_inversion_scanner.py [--json]
Setting Default Description
python.patterns.check_dip true Check toggle
python.patterns.dip_entry_points main,cli,app,__main__,myproject,manage,wsgi,asgi,conftest Filename patterns to exclude

Exclusions: Excludes entry points, DI containers, dataclasses, factories, and stdlib types.

interface_segregation_scanner.py (ISP, Python)

Detects fat interfaces — Protocols/ABCs with many methods where implementers stub or raise NotImplementedError for methods they don't need.

python3 src/zolletta_metaskill/patterns/python/interface_segregation_scanner.py [--json]
Setting Default Description
python.patterns.check_isp true Check toggle
python.patterns.isp_min_methods 5 Minimum abstract method count to flag as fat

Checks: Protocol/ABC classes with N+ methods, implementers that raise NotImplementedError or have stub bodies (pass/return None) for interface methods.

open_closed_scanner.py (OCP, Python)

Detects type-based branching (if/elif isinstance ladders, match/case on type, getattr string dispatch) that should be replaced with polymorphism.

python3 src/zolletta_metaskill/patterns/python/open_closed_scanner.py [--json]
Setting Default Description
python.patterns.check_ocp true Check toggle
python.patterns.ocp_min_branches 3 Minimum type-check branches to flag

liskov_substitution_scanner.py (LSP)

Detects subclass methods that break substitutability: incompatible signatures, new exception types, empty-body overrides.

python3 src/zolletta_metaskill/patterns/general/liskov_substitution_scanner.py [--json]
Setting Default Description
python.patterns.check_lsp true Check toggle

Checks: overridden methods with extra required params, fewer params than parent, new exception types, stub overrides (pass/return None when parent has a real body).

PHP SOLID Validator Scripts

These scanners live in src/zolletta_metaskill/patterns/php/ and target PHP codebases. They use the PHPEngine (tree-sitter-php) to parse .php files. Install the optional dependency with uv pip install zolletta-metaskill[php]. PHP source roots come from the Composer PSR-4 autoload mappings in settings.json.

dependency_inversion_scanner.py (DIP, PHP)

Detects classes that instantiate their dependencies internally (new ConcreteClass() in constructors or methods) instead of receiving them via dependency injection. Excludes factories, builders, and PHP built-in types.

python3 src/zolletta_metaskill/patterns/php/dependency_inversion_scanner.py [--json]
Setting Default Description
php.patterns.check_dip true Check toggle

How it works: since ModuleInfo does not capture new expressions, this scanner calls PHPEngine.parse_raw() to access the tree-sitter AST directly and walks it for new_expression nodes inside class methods.

Exclusions: classes whose name contains Factory or Builder are treated as composition roots where object creation is expected. PHP built-in types (stdClass, DateTime, Exception, etc.) are excluded from dependency detection.

interface_segregation_scanner.py (ISP, PHP)

Detects fat interfaces — PHP interfaces with many methods where implementers are forced to depend on methods they do not use.

python3 src/zolletta_metaskill/patterns/php/interface_segregation_scanner.py [--json]
Setting Default Description
php.patterns.check_isp true Check toggle
php.patterns.isp_min_methods 7 Minimum method count to flag as fat

How it works: uses ModuleInfo directly (no raw AST needed). PHP interfaces are mapped to ClassInfo with is_abstract=True and no attributes. Interfaces with more than isp_min_methods methods are flagged as fat.

open_closed_scanner.py (OCP, PHP)

Detects if/elseif chains that use instanceof to branch on subtypes — an OCP violation. Adding a new subtype requires modifying the ladder instead of simply adding a new implementation.

python3 src/zolletta_metaskill/patterns/php/open_closed_scanner.py [--json]
Setting Default Description
php.patterns.check_ocp true Check toggle
php.patterns.ocp_min_branches 3 Minimum instanceof branches to flag

How it works: since ModuleInfo does not capture instanceof expressions, this scanner calls PHPEngine.parse_raw() to access the tree-sitter AST directly and counts instanceof branches in if_statement nodes.

LanguageEngine Protocol

The LanguageEngine protocol is the seam between language-agnostic scanners and language-specific parsers. Scanners depend only on this protocol and the ModuleInfo data model — they never import ast or tree-sitter directly.

core/language_engine.py

Defines the LanguageEngine protocol (@runtime_checkable). Every engine must implement:

Method / property Description
language Language identifier (e.g. "python", "php")
parse_module(path) Parse a source file and return a ModuleInfo
is_test_file(path) Return True if the path is a test file for this language
is_source_file(path) Return True if the path is a source file for this language
file_extensions() Return the list of extensions handled (e.g. [".py"], [".php"])
test_file_glob() Return the glob pattern for test files (e.g. "test_*.py", "*Test.php")

core/registry.py

Provides the engine registry — maps language names and file extensions to engines:

Function Description
register(engine) Register an engine under its language identifier (raises ValueError on duplicate)
get(language) Return the registered engine for a language (raises KeyError if not found)
get_for_file(path) Return the engine that handles a file path based on its extension, or None
available_languages() Return a sorted list of registered language identifiers

engines/python_engine.py & engines/php_engine.py

Two implementations of the LanguageEngine protocol:

  • PythonEngine — wraps Python's ast module to parse .py files into ModuleInfo.
  • PHPEngine — wraps tree-sitter with the tree-sitter-php grammar to parse .php files into ModuleInfo. The tree-sitter-php package is an optional dependency (uv pip install zolletta-metaskill[php]); if not installed, the engine still instantiates but parse_module() raises a clear ImportError.

PHPEngine.parse_raw()

def parse_raw(self, path: Path) -> tuple[Tree, bytes]

Returns the raw tree-sitter Tree and source bytes for a .php file. Used by PHP-specific scanners (dependency_inversion_scanner.py, open_closed_scanner.py) that need direct AST access for constructs not captured in ModuleInfo (e.g. new expressions, instanceof chains). The source bytes are needed to extract text from individual nodes via source[node.start_byte:node.end_byte].

Parameters:

  • path — Path to the .php file to parse.

Dead Code Script

unused_all_exports_scanner.py

Finds names listed in __all__ that are never imported by any other module in the source tree. Complements vulture, which treats __all__ entries as "used" (public API exports) and therefore never flags them as dead code — even when no module ever imports them.

python3 src/zolletta_metaskill/code_style/python/unused_all_exports_scanner.py [--json]
Setting Default Description
python.code_style.check_unused_all_exports true Check toggle

test_naming_scanner.py

Checks test function names against the test_<unit>_<scenario>_<expected> convention. Flags functions with fewer than min_segments underscore-separated segments after the test_ prefix. This is a deterministic replacement for manual review of test function names.

python3 src/zolletta_metaskill/testing_style/python/test_naming_scanner.py [--json]
Setting Default Description
python.paths.tests [tests] Test roots to scan
python.testing.check_test_naming true Check toggle
python.testing.test_naming_min_segments 3 Minimum segments after test_ prefix

acronym_casing_scanner.py

Checks that acronyms in PascalCase class names stay fully uppercase (e.g. HTTPClientFactory, not HttpClientFactory). The scanner splits each PascalCase class name into words, checks each word against the configured acronym list, and flags any word that case-insensitively matches an acronym but isn't all-uppercase. Python and PHP variants exist (code_style/python/, code_style/php/).

python3 src/zolletta_metaskill/code_style/python/acronym_casing_scanner.py [--json]
Setting Default Description
<lang>.code_style.check_acronym_casing true Check toggle

The acronym list is built additively:

  1. Shipped base: python-code-style/assets/acronyms.json (common SE acronyms: CI, CD, CICD, HTTP, HTTPS, JSON, SQL, URL, etc.) — always loaded
  2. Project-specific: the top-level acronyms array in settings.json — merged with the shipped list (additive, not replacing)

Mutation Testing

mutmut_survived_reporter.py

Reports survived mutants from a completed mutmut run — the deterministic extraction step of the conditional mutation-testing sensor (issue #47). Wraps mutmut results (per-mutant status lines) and mutmut show <name> (per-mutant diff), merging them into a per-mutant file:line + diff table with status counts and a Mutation score: X% (threshold: Y%) — PASS/FAIL line.

Run it from the project root — the same directory where mutmut run was executed (mutmut's mutants/ result cache resolves relative to the cwd). There is no positional directory argument.

python3 src/zolletta_metaskill/testing_style/python/mutmut_survived_reporter.py [--json]
Setting Default Description
python.tools.mutmut.available — Not true → Result: SKIPPED, exit 0
python.testing.check_mutation_testing true false → Result: SKIPPED, exit 0
python.testing.mutation_score_threshold 80 Mutation score below this → FAIL line + high finding
python.testing.mutation_max_mutants 50 Cap on survived mutants detailed with diffs
python.paths.source [src] Filters reported survivors to configured source roots

Survivors are mutants with status survived or no tests — both mean the suite did not kill the mutant. Timeout mutants appear in the counts table only. The score is killed / evaluated where evaluated excludes not checked/skipped/interrupted mutants.

Report-only — exits 0 whether or not mutants survive; exits 1 only when the mutmut invocation itself fails.

test_splitter.py

Automates splitting a test God class that tests multiple SUTs into per-SUT test files. Unlike the scanners this is a transformer, so it keeps its operational arguments.

python3 src/zolletta_metaskill/patterns/python/test_splitter.py <test_file> [--mapping <m>] [--class <name>] [--dry-run] [--json]
Option Default Description
<test_file> (req) Path to the test file to split
--mapping auto-derived from method names JSON/explicit SUT mapping
--class (all test classes) Split only this class
--dry-run off Show what would be split without writing files
--json off Output the split plan as JSON

Split files are written under <runs_dir>/test_split/<test_file_stem>/ (default .zolletta-metaskill/test_split/).

What the splitter handles automatically:

  • Copies all imports to each split file
  • Copies pytestmark to each split file
  • Copies shared methods (fixtures, helpers) to each split file
  • Generates proper class names (Test<SutName>)
  • Indents methods correctly inside the class
  • Reports unmatched methods for mapping review

What the splitter does NOT do:

  • Remove unused imports from split files (review manually)
  • Move the files to the final test directory (human reviews first)
  • Delete the original file (human confirms the split is correct)
  • Run the tests (human verifies the split files pass)

Complete Workflow

Run scripts in order: triage (class_metrics_scanner, test_god_classes_scanner) → structural (one_class_per_file_scanner, test_structure_scanner, naming_conventions_scanner) → SOLID (dependency_inversion_scanner, interface_segregation_scanner, open_closed_scanner, liskov_substitution_scanner) → PHP SOLID (PHP projects) → dead code (unused_all_exports_scanner) → naming (test_naming_scanner, acronym_casing_scanner) → split (test_splitter). Apply the "reason to change" test to top candidates.

Repository Scripts

These are project-management scripts at the repository root, separate from the scanning scripts above.

install.sh

Clones the latest git tag, copies the files listed in install-manifest.txt into ~/.agents/skills/, and symlinks the result into every detected AI agent tool's skills directory.

./install.sh                  # clone latest tag, install/refresh
./install.sh --force          # replace real dirs with symlinks
./install.sh --source <dir>   # install from a local directory instead of cloning

dev-install.sh

Shortcut for install.sh --source "$PWD" — installs the working tree instead of cloning the latest tag. Forwards extra arguments (--force, ...).

./dev-install.sh           # install the working tree
./dev-install.sh --force   # + replace real dirs with symlinks

install-manifest.txt

The copy list shared by install.sh and dev-install.sh — one path per line, # comments, missing entries skipped. When a source predates the file (old tags), the installer falls back to a built-in default list.

See Install Zolletta-MetaSkill for details.