“Send in your skeletons Sing as their bones go marchin’ in again The need you buried deep The secrets that you keep are ever ready Are you ready? I’m finished makin’ sense Done pleading ignorance, that whole defense Spinning infinity, boy The wheel is spinnin’ me, it’s never ending, never ending Same old story” — Foo Fighters
Getting Started
This guide walks you from a fresh install to a passing pretender check in
a real repository. It covers the six steps most teams need on day one.
Prerequisites
- Rust toolchain (stable) — install via rustup.rs
- Git ≥ 2.x — required for
--diff-onlyand hook installation - Internet access for the initial
cargo install
1. Install
cargo install pretender
Verify the install:
pretender --version
2. Initialise your repository
Run the interactive wizard from the root of your repository:
pretender init
The wizard will:
- Ask which languages your project uses
- Suggest threshold values based on project size
- Write
pretender.tomlto the repo root - Offer to install the pre-commit hook
- Offer to generate a CI workflow file
A minimal generated pretender.toml looks like:
[pretender]
mode = "tiered"
languages = ["auto"]
[thresholds]
cyclomatic_max = 10
cognitive_max = 15
See the Configuration reference for every available key.
3. Run your first check
pretender check .
pretender check . scans all files in the given paths. To check only files
changed relative to origin/main:
pretender check . --diff-only
Reading the output
⚠ src/parser.py — parse_token(): cyclomatic 14 > 10
role: test
parse_token: cyclomatic=14, cognitive=8, assertions=0, function_lines=15, params=2, nesting_max=2, abc=3.46
ADVISORY cyclomatic 14 > 10
| Symbol | Meaning |
|---|---|
⚠ | Advisory — metric exceeds the configured threshold (tiered mode) |
✗ | Violation — metric exceeds the threshold in gate mode |
In gate mode every violation exits non-zero. In the default tiered mode
advisories are printed but exit is still zero.
4. Fix a violation
Suppose parse_token() scores cyclomatic=14 against a threshold of 10.
Extract the per-branch logic into helpers to bring each function under the
threshold:
Before — one function with 14 branches:
def parse_token(token, context, strict):
if token.type == "string":
if strict and not token.value.startswith('"'):
raise ValueError("unquoted string")
return token.value.strip('"')
elif token.type == "number":
try:
return int(token.value)
except ValueError:
return float(token.value)
# … six more branches omitted for brevity
After — each helper is cyclomatic ≤ 3:
def _parse_string(token, strict):
if strict and not token.value.startswith('"'):
raise ValueError("unquoted string")
return token.value.strip('"')
def _parse_number(token):
try:
return int(token.value)
except ValueError:
return float(token.value)
def parse_token(token, context, strict):
if token.type == "string":
return _parse_string(token, strict)
elif token.type == "number":
return _parse_number(token)
# …
Recheck after refactoring:
pretender check src/parser.py
Adopting a gate baseline
Some violations are legitimate but hard to fix right now, and mechanical
changes (a tree-wide reformat, a generated file) shouldn’t read as “new”
violations forever. check --adopt writes the current scan’s violations into
a gate baseline — pretender-baseline.json, next to pretender.toml:
pretender check --adopt
# ✓ adopted 3 findings into pretender-baseline.json (file_lines 1, function_lines 2)
Commit that file. From then on, pretender check --mode gate fails only
on genuinely new violations — findings whose stable ID (path, function
name, rule — deliberately not line numbers) is not in the baseline. Known
violations pass with a summary line:
→ gate: 3 known (baselined), 0 new
Without a baseline file, gate mode keeps its fail-closed behavior: any
violation fails. A corrupt or unreadable baseline also fails the check
rather than silently degrading. Adopted findings still appear in reports
and resolution tracking — the baseline changes gate semantics, not
visibility. Refresh the baseline by re-running with --adopt.
5. Install the pre-commit hook
Keep violations from reaching the repository by running pretender before each commit:
pretender hooks install
This writes a .git/hooks/pre-commit script that runs pretender check --staged on the files you are about to commit. To remove it:
pretender hooks uninstall
6. Add CI integration
Generate a GitHub Actions workflow:
pretender ci generate github
This writes .github/workflows/pretender.yml. The workflow runs
pretender check on every pull request and, if mutation testing is
configured, gates merges on the mutation score defined by
thresholds.mutation_min in your pretender.toml.
For mutation testing details, see Mutation testing. To gate
on mutation score in CI, set thresholds.mutation_min in pretender.toml
— the generated workflow picks it up automatically.
Next steps
| Topic | Document |
|---|---|
All pretender.toml keys and defaults | Configuration reference |
| Mutation testing flags and JSON output | Mutation testing |
| Writing custom metric plugins | Writing plugins |
| Language-specific tracked nodes | languages/ |
Ecosystem
pretender is part of the charly-vibes Tool Ecosystem — the map shows how it relates to its sibling tools.
Release Status
Generated artifact (docs.yml, from Cargo.toml + git ref). Do not hand-edit — the repo source files remain authoritative.
- Version: v0.10.0
- Built from: main @ 1fcf431
Configuration Reference
pretender.toml is placed at the root of your repository. All sections and
keys are optional — omitted keys fall back to the defaults shown here.
Recommended baseline for Rust CLI repos
For charly-vibes suite repos (Rust CLI tools like pretender, wai, dont, espectacular, vampiro, testaruda, crua, livin), we recommend pinning roles and overriding the default thresholds to match a typical Rust project layout:
| Role | Paths | Notes |
|---|---|---|
test | tests/**, **/*_test.*, spec/** | Default — already matches |
unit-test | tests/unit/** | Explicit sub-role for fast unit tests |
integration-test | tests/integration/** | Explicit sub-role for slower integration |
script | scripts/**, examples/** | Default — already matches |
generated | **/*_generated.* | Default — pb.go, protobuf, etc. |
Why pin roles explicitly
Without a pretender.toml, pretender uses library defaults: all files get the
app role, tests are detected via heuristics only, and every role’s paths use
convention over configuration. This works, but has two downsides:
- No sub-role awareness —
tests/unit/andtests/integration/are not differentiated, so duration thresholds (duration_max_ms) never activate. - No version-controlled baseline — new team members or CI environments must guess which role layout your project uses.
Pinning roles in pretender.toml resolves both issues and makes the config
part of the reviewed, committed project state.
Baseline threshold values
These values are copied from templates/pretender.toml.example and represent
the recommended starting point for Rust CLI repos:
[thresholds]
cyclomatic_max = 10
cognitive_max = 15
function_lines_max = 40
file_lines_max = 400
nesting_max = 3
params_max = 4
abc_max = 30
duplication_pct_max = 5
[thresholds.test]
cyclomatic_max = 3
cognitive_max = 5
function_lines_max = 80
nesting_max = 2
params_max = 2
duplication_pct_max = 30
min_assertions = 1
[thresholds.library]
exported_params_max = 3
exported_cyclomatic_max = 8
exported_lines_max = 30
require_docstring = true
[thresholds.unit-test]
duration_max_ms = 100
[thresholds.integration-test]
duration_max_ms = 2000
that may not match the project’s quality targets.
Doctor check for effective hooks path
pretender doctor also includes a Hook location check: it resolves
core.hooksPath across all config scopes (local, global, system) using
genesis::git_hooks::effective_hooks_dir and warns when git will invoke
hooks from a directory other than where the pretender hook was installed
— the case where a global core.hooksPath shim (e.g. lefthook) silently
makes the installed gate inert. An empty-string core.hooksPath (git
disables hooks entirely) is reported as an error.
Deploying across the suite
Each suite repo should commit its own pretender.toml (start from the
template at templates/pretender.toml.example) and adjust thresholds as
needed. The canonical template lives in the pretender repo and serves as the
upstream source of truth for all suite repos.
[pretender]
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | "gate" | Check behaviour: "gate" (any violation fails — the fail-closed default, softened only by an adopted gate baseline), "tiered" (failures scale with severity; advisory), "guidance" (hints only; advisory) |
advisory_until | ISO date string | — (required for tiered/guidance) | Advisory lease expiry (pretender-1te). Advisory modes are dated downgrades from the gate default: an expired, missing, or unparseable lease makes check FAIL, forcing a renewed, dated, reviewable decision. Renewal = push the date forward in this file (a visible diff). Gate mode never consults the lease. |
languages | array of strings | ["auto"] | Languages to analyse. "auto" detects from file extensions. Explicit values: "python", "rust", "go", "javascript", "typescript", "java", "ruby", "c", "cpp" |
exclude | array of glob strings | ["vendor/**", "node_modules/**", "**/*_generated.*"] | Path globs to skip during analysis |
respect_gitignore | boolean | true | When walking directories, skip anything ignored by git (repo + nested .gitignore, global core.excludesFile, .git/info/exclude) so target/, node_modules/, and vendored code aren’t scanned without per-repo exclude lists. Outside a git repo the walk is unfiltered. Explicitly passed file paths and --staged are never filtered. Pass --scan-ignored to check for a one-off exhaustive walk (pretender-094). |
[thresholds]
App-level metric limits applied to files assigned the app role (the default
for files not matched by any other role).
| Key | Type | Default | Description |
|---|---|---|---|
cyclomatic_max | integer | 10 | Maximum cyclomatic complexity per function |
cognitive_max | integer | 15 | Maximum cognitive complexity per function |
function_lines_max | integer | 40 | Maximum lines per function |
file_lines_max | integer | 400 | Maximum lines per file |
nesting_max | integer | 3 | Maximum control-flow nesting depth |
params_max | integer | 4 | Maximum formal parameters per function |
abc_max | integer | 30 | Maximum ABC score (√(A²+B²+C²)) per function |
duplication_pct_max | integer | 5 | Maximum structural duplication percentage (0–100) |
Planned (not yet enforced): Maintainability Index (
mi_min), coverage (coverage_line_min/coverage_branch_min), and mutation score (mutation_min) thresholds are not currently configurable. They were removed from the schema in v0.5.0 because they were never enforced — passing a config that sets these keys silently ignored them. Mutation score is already gateable viapretender mutation --score-min N; coverage and MI enforcement are future work.
[thresholds.unit-test]
Overrides for files assigned the unit-test sub-role. Inherits all [thresholds.test]
values and overlays the duration threshold.
| Key | Type | Default | Description |
|---|---|---|---|
duration_max_ms | integer | 0 | Maximum allowed test duration in milliseconds. 0 disables the check. |
[thresholds.integration-test]
Overrides for files assigned the integration-test sub-role. Inherits all [thresholds.test]
values and overlays the duration threshold.
| Key | Type | Default | Description |
|---|---|---|---|
duration_max_ms | integer | 0 | Maximum allowed test duration in milliseconds. 0 disables the check. |
[thresholds.test]
Overrides for files assigned the test role.
| Key | Type | Default | Description |
|---|---|---|---|
cyclomatic_max | integer | 3 | Cyclomatic limit for test functions |
cognitive_max | integer | 5 | Cognitive limit for test functions |
function_lines_max | integer | 80 | Line limit for test functions |
nesting_max | integer | 2 | Nesting limit for test functions |
params_max | integer | 2 | Parameter limit for test functions |
duplication_pct_max | integer | 30 | Duplication tolerance in test files |
min_assertions | integer or null | 1 | Minimum assertion calls per test function; null disables |
[thresholds.library]
Overrides for files assigned the library role.
| Key | Type | Default | Description |
|---|---|---|---|
exported_params_max | integer | 3 | Parameter limit on exported/public functions |
exported_cyclomatic_max | integer | 8 | Cyclomatic limit on exported/public functions |
exported_lines_max | integer | 30 | Line limit on exported/public functions |
require_docstring | boolean | true | Require a docstring on every exported/public function |
[thresholds.script]
Overrides for files assigned the script role.
| Key | Type | Default | Description |
|---|---|---|---|
function_lines_max | integer | 100 | Line limit per function |
file_lines_max | integer | 300 | Line limit per file |
[bands]
Colour bands used when mode = "tiered" to map raw metric values to severity
levels. Each band specifies thresholds for green (acceptable), yellow (warning),
and red (violation). Values must satisfy green <= yellow <= red.
Each band (cyclomatic, cognitive) is optional. When the [bands]
section is entirely absent from pretender.toml, both bands are active with
the defaults shown below. However, once you add a [bands] section, any band
key you omit is disabled (set to None) — it does not fall back to its
default. Always specify both bands together when overriding either one.
# Inline form
[bands]
cyclomatic = { green = 10, yellow = 15, red = 20 }
cognitive = { green = 15, yellow = 25, red = 40 }
# Block form (equivalent)
[bands.cyclomatic]
green = 10
yellow = 15
red = 20
[bands.cognitive]
green = 15
yellow = 25
red = 40
[bands.cyclomatic]
Default (when [bands] is absent): { green = 10, yellow = 15, red = 20 }
| Key | Type | Description |
|---|---|---|
green | integer | Cyclomatic complexity at or below this is green |
yellow | integer | At or below this is yellow |
red | integer | Above this is red |
[bands.cognitive]
Default (when [bands] is absent): { green = 15, yellow = 25, red = 40 }
| Key | Type | Description |
|---|---|---|
green | integer | Cognitive complexity at or below this is green |
yellow | integer | At or below this is yellow |
red | integer | Above this is red |
[scope]
Controls which files are analysed during pretender check.
| Key | Type | Default | Description |
|---|---|---|---|
diff_only | boolean | true | Written by pretender init to record your project preference. Setting this to true does not automatically enable diff filtering — you must pass --diff-only on the CLI (or CI step) to activate it. When active, only files changed relative to diff_base are checked |
diff_base | string | "origin/main" | Git ref used as the comparison base for --diff-only |
[execute]
Optional shell commands pretender can run to collect coverage and mutation data.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Run external commands (test, coverage, mutation) during pretender check |
coverage_cmd | string or null | null | Planned: Shell command that produces a coverage report. Not yet executed by pretender check |
mutation_cmd | string or null | null | Planned: Shell command that runs mutation testing. Mutation score is already gateable via pretender mutation --score-min N |
test_cmd | string or null | null | Shell command that runs the test suite and produces a JUnit XML report (used with --execute) |
test_report_path | string or null | null | Path to the JUnit XML report produced by test_cmd (relative to repo root) |
test_timeout_s | integer | 600 | Timeout in seconds for test_cmd; on timeout pretender emits a test-cmd-timeout error |
test_time_unit | string | "seconds" | Unit of the JUnit time attribute: "seconds" (XSD-conformant) or "milliseconds" (non-conformant runners) |
[plugins]
Controls which built-in language and metric plugins are active.
| Key | Type | Default | Description |
|---|---|---|---|
languages | array of strings | ["python","javascript","typescript","go","rust"] | Language plugins to load |
metrics | array of strings | ["eslint","ruff","clippy"] | Built-in metric tool plugins to load |
External metric plugins are always loaded in addition to this list from the first directory found in this order:
$PRETENDER_METRICS_DIR(if set)$XDG_CONFIG_HOME/pretender/metrics/(ifXDG_CONFIG_HOMEis set)~/.config/pretender/metrics/(fallback)
See Writing plugins.
[output]
| Key | Type | Default | Description |
|---|---|---|---|
formats | array of strings | ["human","sarif"] | Output formats to emit. Valid values: "human", "json", "sarif", "junit", "markdown". At least one value is required |
sarif_path | string | "pretender.sarif" | File path for SARIF output when "sarif" is in formats |
[roles.*]
Role matchers assign a role to files based on path globs. The resolved role
determines which [thresholds.*] block applies.
Each role section has a single key:
| Key | Type | Description |
|---|---|---|
paths | array of glob strings | Files matching any glob are assigned this role |
classname_root | string | For [roles.test] only: directory to search when resolving JUnit classname to a source file (default "tests") |
Available roles and their defaults:
| Role | Default paths |
|---|---|
test | ["tests/**", "**/*_test.*", "spec/**"] |
unit-test | ["tests/unit/**"] |
integration-test | ["tests/integration/**"] |
library | ["pkg/**", "lib/**"] |
script | ["scripts/**", "examples/**"] |
generated | ["**/*.pb.go", "**/*_generated.*"] |
vendor | ["vendor/**", "node_modules/**"] |
Example override:
[roles.test]
paths = ["tests/**", "**/*.spec.*", "**/__tests__/**"]
Role detection order
pretender assigns the first role that matches, checked in this priority order:
- Pragma — a comment on any line of the file:
# pretender: role=<name>or// pretender: role=<name> - Configured glob — the first
[roles.*]section whose glob matches the file path - File-name heuristic — built-in patterns (e.g.
_test.suffix → test,_generated.→ generated) - Default —
app
Test-duration check
pretender can analyse per-test timing data from a JUnit XML report and flag tests that exceed role-appropriate duration budgets. This is the first dynamic check in pretender — it consumes a runtime artifact rather than source text.
Activation
The check activates when you pass --test-report <path> to pretender check.
You can also configure [execute] test_cmd and use --execute to have
pretender run the test suite for you:
# Analyse an existing report
pretender check --test-report target/junit-report.xml
# Run tests first, then analyse
pretender check --execute
Thresholds
Set duration limits per test sub-role:
[thresholds.unit-test]
duration_max_ms = 100 # unit tests should be fast
[thresholds.integration-test]
duration_max_ms = 2000 # integration tests can be slower
A finding is emitted when observed_ms > duration_max_ms (strict comparison,
so a test exactly at the threshold does not fail). A threshold of 0
(the default) disables the check.
Sub-role detection
pretender detects unit-test and integration-test sub-roles using the same
resolution order as other roles:
- Pragma —
# pretender: role=unit-testor// pretender: role=unit - Configured glob —
[roles.unit-test]with custompaths - Heuristic —
tests/unit/**andtest/unit/**→unit-test;tests/integration/**andtest/integration/**→integration-test - Default —
test(no duration threshold)
Sub-roles inherit all [thresholds.test] values and overlay only
duration_max_ms.
How timing data is read
pretender parses standard JUnit XML (<testsuite> / <testcase time="...">).
The time attribute is interpreted as seconds by default (XSD-conformant);
set [execute] test_time_unit = "milliseconds" for non-conformant runners.
Rounding uses round-half-up to the nearest millisecond.
JUnit emission incantations per runner
| Runner | Command |
|---|---|
| pytest | pytest --junitxml=report.xml |
| cargo test | cargo2junit > report.xml (requires cargo2junit crate) |
| Maven | mvn surefire-report:report (produces target/surefire-reports/*.xml) |
| Gradle | Built-in: build/reports/tests/test/*.xml |
| Jest | jest --junitOutput=report.xml (requires jest-junit reporter) |
CLI flags
| Flag | Description |
|---|---|
--test-report <path> | Path to a JUnit XML report for duration analysis |
--execute | Run [execute] test_cmd before analysing durations |
Output
Findings appear in all output formats:
- Human:
unit-test test_addition tests/unit/test_math.py: 100ms > 50ms - JSON:
test_findingstop-level array withtest_name,file,role,observed_ms,threshold_ms - SARIF:
pretender/test-durationrule id,Warninglevel
In gate mode, any duration finding causes a non-zero exit code.
pretender maintains a local event log that surfaces recurring problem areas across runs — useful for calibrating thresholds and identifying structural debt before it compounds.
pretender tracks every violation it reports in .pretender/history/events.jsonl
at the root of your repository. Each line is a JSON object (a ViolationEvent)
recording the file fingerprint, rule key, role, area, run ID, and Unix timestamp.
Events older than 90 days are pruned automatically on each run.
From the event log pretender computes two summaries shown at the end of
pretender check:
- Hotspots — the 10 files with the highest total violation count across at least two distinct days. A file that repeatedly triggers is a structural problem, not a fluke.
- Patterns — the 10 (rule, role, area) combinations that recur most often across the most files. Use these to calibrate thresholds or identify conventions your team hasn’t codified yet.
Resolution tracking — every check run (including clean runs) also
persists the current set of finding IDs to
.pretender/history/last-findings.json. IDs are stable across line-shift
edits (path::unit_name::rule_key). The next run reports a delta:
→ resolution: 3 fixed, 5 still open, 2 new (38%)
The rate is fixed / (fixed + still_open) — null on the first tracked run.
The same delta is available to machine consumers under
data.history.resolution in JSON output and in history/summaries.json, so
CI dashboards can track the fixed-vs-flagged ratio over time.
The .pretender/ directory should be committed so the feedback loop survives
across machines and CI runs. Note: events.jsonl records file paths and
fingerprints — review the file before committing if your repository contains
sensitive filenames.
Mutation Testing
Mutation testing measures test-suite effectiveness by injecting small code changes (“mutants”) and checking whether the tests catch them. A high mutation score means your tests fail when the code is wrong — a low score means gaps exist even when line coverage looks healthy.
pretender mutation <paths...> wraps language-specific mutation testing tools
and reports results in a unified format. Each tool must be installed separately
— pretender invokes them as subprocesses.
Quick reference
| Flag | Purpose | Example |
|---|---|---|
| (none) | Run mutation testing and print results | pretender mutation src/ |
--dry-run | List mutation sites without running tests | pretender mutation src/ --dry-run |
--score-min N | Exit non-zero if score < N% | pretender mutation src/ --score-min 80 |
--format json | Emit structured JSON report | pretender mutation src/ --format json |
Tool selection
pretender detects the dominant language from the target paths and selects the appropriate tool automatically:
| Language | Tool | Install |
|---|---|---|
| Python | mutmut | pip install mutmut |
| Rust | cargo-mutants | cargo install cargo-mutants |
| JavaScript / TypeScript | Stryker | npm install --save-dev @stryker-mutator/core |
If a project contains multiple languages, pretender uses the language of the majority of files in the given paths.
Java (PIT) — not yet automated
pretender mutation does not yet invoke PIT directly. Run mutation coverage
manually with Maven or Gradle:
mvn org.pitest:pitest-maven:mutationCoverage
# or
gradle pitest
--dry-run still works for Java files — pretender uses tree-sitter to list
candidate mutation sites without invoking PIT.
Basic usage
# Run mutation testing against a package
pretender mutation src/
# Preview mutation sites without running tests
pretender mutation src/ --dry-run
# Fail if mutation score falls below a threshold
pretender mutation src/ --score-min 80
# Emit results as JSON
pretender mutation src/ --format json
--dry-run: preview mutation sites
Lists every planned mutation without executing the test suite. Useful for understanding coverage before committing to a full run.
pretender mutation src/ --dry-run
Example output:
src/engine.rs:42 replace operator `>`
src/engine.rs:67 replace return value
src/config.rs:15 replace operator `+`
Note: For Rust,
--dry-runqueriescargo mutants --listand reflects the real mutation plan. For all other languages (Python, JavaScript, TypeScript, Java), it uses a built-in tree-sitter analysis that enumerates operator and return-value sites — the count and descriptions are approximate and may differ from what the underlying tool would produce.
--score-min: CI gate
Exits non-zero if the achieved mutation score is below the given percentage. Use this in CI to enforce a minimum quality bar.
pretender mutation src/ --score-min 80
The exit code is 0 when the score meets or exceeds the threshold, 1
otherwise.
--format json: machine-readable output
Emits a MutationReport JSON object to stdout.
pretender mutation src/ --format json
MutationReport fields
| Field | Type | Description |
|---|---|---|
tool | string | Name of the mutation tool used (e.g. "cargo-mutants") |
total | integer | Total number of mutants generated |
killed | integer | Mutants caught by the test suite |
survived | integer | Mutants not caught (test gaps) |
score_pct | float | Mutation score: (killed / total) * 100. Returns 100.0 when no mutants are generated |
survivors | array | List of surviving mutant descriptors (see below) |
Surviving mutant fields
| Field | Type | Description |
|---|---|---|
file | string | Source file containing the mutant |
line | integer | Line number of the mutation |
description | string | Human-readable description of the applied mutation |
missed_tests | array of strings | Test names that should have caught this mutant but did not. Populated by mutmut only; always [] for cargo-mutants and Stryker, which do not report per-mutant test attribution |
Example (mutmut, which populates missed_tests):
{
"tool": "mutmut",
"total": 120,
"killed": 102,
"survived": 18,
"score_pct": 85.0,
"survivors": [
{
"file": "src/engine.py",
"line": 42,
"description": "replace `>` with `>=`",
"missed_tests": ["test_threshold_boundary"]
}
]
}
CI integration
The recommended way to run mutation testing in CI is through the workflow
generated by pretender ci generate github:
pretender ci generate github
This writes .github/workflows/pretender.yml. The generated file includes a
check step and a mutation gate step:
- name: pretender check
run: pretender check .
- name: Mutation score gate
run: pretender mutation . --score-min 60
The --score-min value defaults to 60 and can be set per invocation or via
pretender mutation . --score-min <N> in your CI workflow.
Writing Plugins
pretender supports external metric plugins: small programs that analyse source
files and emit findings in JSON. pretender runs each plugin as a subprocess,
collects its output, and merges the findings into pretender check results
alongside the built-in metrics.
Install location
Place plugin manifests (.toml files) in the metrics directory. pretender
searches the first directory found in this order:
$PRETENDER_METRICS_DIR(if set)$XDG_CONFIG_HOME/pretender/metrics/(ifXDG_CONFIG_HOMEis set)~/.config/pretender/metrics/(fallback)
Each .toml file in that directory is loaded as one plugin. Non-.toml files
and files with parse errors are skipped — a warning is printed to stderr for
each invalid file.
Plugin manifest format
A plugin manifest is a TOML file with the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Plugin name shown in output (e.g. "ruff") |
extensions | array of strings | yes | File extensions this plugin applies to (e.g. [".py"]) — must include the leading dot |
command | array of strings | yes | Command and arguments to run. The token {files} is expanded to the list of matching file paths |
parser | string | yes | Output format: "json" (a JSON array) or "json-lines" (one JSON object per line) |
[mapping] | table | yes | Field mapping from the plugin’s JSON output to pretender’s finding schema (see below) |
[mapping] sub-table
Each mapping value is a dot-separated path into the JSON object emitted by
the plugin. For example, "location.row" navigates {"location": {"row": 5}}.
| Field | Type | Required | Description |
|---|---|---|---|
path | string | yes | JSON path to the source file name |
line | string | yes | JSON path to the line number (integer) |
message | string | yes | JSON path to the human-readable finding message |
code | string | no | JSON path to an optional finding code (e.g. "E501") |
Finding schema
Each finding pretender emits from a plugin has these fields:
| Field | Type | Description |
|---|---|---|
source | string | The plugin name |
line | integer | Line number of the finding |
message | string | Human-readable description |
code | string or null | Optional finding code |
Worked example
The metrics/example/ directory in this repository contains a minimal,
runnable plugin — a shell script that emits one JSON finding per violation
and a TOML manifest wiring it to pretender. Copy the files and edit the
path to install it locally.
Manifest — metrics/example/long-lines.toml
name = "long-lines"
extensions = [".py", ".js", ".ts", ".rs"]
command = ["metrics/example/long-lines.sh", "{files}"]
parser = "json-lines"
[mapping]
path = "file"
line = "line"
message = "message"
code = "code"
Script — metrics/example/long-lines.sh
The script checks for lines exceeding 100 characters and emits one JSON object per violation:
#!/usr/bin/env sh
for file in "$@"; do
awk -v file="$file" 'length > 100 {
printf "{\"file\":\"%s\",\"line\":%d,\"message\":\"line exceeds 100 chars (%d)\",\"code\":\"LL001\"}\n",
file, NR, length
}' "$file"
done
Installing the example
Use absolute paths in command — relative paths are resolved from wherever
pretender check is run, which can vary. Copy both files to the metrics
directory:
mkdir -p ~/.config/pretender/metrics
cp metrics/example/long-lines.sh ~/.config/pretender/metrics/
chmod +x ~/.config/pretender/metrics/long-lines.sh
# Update the command path in the manifest before copying
sed 's|metrics/example/long-lines.sh|/home/you/.config/pretender/metrics/long-lines.sh|' \
metrics/example/long-lines.toml \
> ~/.config/pretender/metrics/long-lines.toml
# Verify it appears in check output
pretender check src/
The finding will appear in human output as:
src/lib.rs:87 [long-lines] line exceeds 100 chars (112) LL001
How findings are merged
After each pretender check run, pretender runs every applicable plugin
against the files being checked. Findings from plugins are merged with built-in
metric findings and displayed in the same output stream. The source field
identifies which plugin produced each finding.
Plugins are always loaded from the metrics directory in addition to any
built-in metric tools listed in [plugins].metrics in pretender.toml.
See the Configuration reference for the
[plugins] section.
Real-world examples
ruff (Python linter)
name = "ruff"
extensions = [".py"]
command = ["ruff", "check", "--output-format=json", "--select=E501", "{files}"]
parser = "json"
[mapping]
path = "filename"
line = "location.row"
message = "message"
code = "code"
ESLint (JavaScript / TypeScript)
ESLint’s JSON output nests messages inside per-file objects — one array entry per file, not per finding. pretender’s dot-path mapping does not support array indexing, so a thin wrapper script is required to flatten the output.
Requires: jq — install with your system
package manager (brew install jq, apt install jq, etc.).
#!/usr/bin/env sh
# eslint-wrapper.sh
# Requires jq. Flattens ESLint JSON output to one JSON object per finding.
npx eslint --format=json "$@" | jq -c '
.[] | .filePath as $f | .messages[] |
{file: $f, line: .line, message: .message, code: .ruleId}
'
name = "eslint"
extensions = [".js", ".jsx", ".ts", ".tsx"]
command = ["/path/to/eslint-wrapper.sh", "{files}"]
parser = "json-lines"
[mapping]
path = "file"
line = "line"
message = "message"
code = "code"
Replace /path/to/eslint-wrapper.sh with the absolute path where you install
the wrapper script.
Implementation Status
Current build status for every command and feature. Stubbed items exit with
code 2 and a “not yet implemented” message. Partial items are functional
but have noted gaps.
Commands
| Command | Status | Notes |
|---|---|---|
pretender init | ✅ Implemented | Interactive and --non-interactive modes |
pretender check | ✅ Implemented | human, json, sarif output; --staged and --diff-only filters |
pretender complexity | ✅ Implemented | Single-file, worst-first sort |
pretender report | ✅ Implemented | human, markdown, html |
pretender duplication | ✅ Implemented | Cross-file and single-file |
pretender mutation | ✅ Implemented | Python, Rust, JS, TS — see caveats |
pretender hooks install [hook] | ✅ Implemented | Writes git hook shim (pre-commit, pre-push) |
pretender hooks uninstall | ✅ Implemented | Removes Pretender-managed shim |
pretender ci generate github | ✅ Implemented | Writes .github/workflows/pretender.yml |
pretender explain | ✅ Implemented | All built-in metrics |
pretender plugins | ❌ Stub | Tracked: pretender-07m |
Partial features
| Feature | Status | Detail |
|---|---|---|
check --format junit | ❌ Stub | Tracked: pretender-t2m |
check --format markdown | ❌ Stub | Tracked: pretender-t2m |
ci generate <non-github> | ❌ Stub | Only github is implemented |
| Mutation testing — Java | ⚠️ Partial | --dry-run works via tree-sitter; PIT is not invoked automatically |
Language support matrix
| Language | Complexity | Mutation |
|---|---|---|
| Python | ✅ | ✅ (mutmut) |
| Rust | ✅ | ✅ (cargo-mutants) |
| JavaScript | ✅ | ✅ (Stryker) |
| TypeScript | ✅ | ✅ (Stryker) |
| Go | ✅ | — |
| Java | ✅ | ⚠️ dry-run only |
| Ruby | ✅ | — |
| C | ✅ | — |
| C++ | ✅ | — |
Status
beta — core works and is dogfooded in anger; surface may shift before stable.
Implemented
| Command | Status | Notes |
|---|---|---|
pretender init | stable | project config bootstrap |
pretender report | stable | complexity report (cyclomatic, cognitive, abc, params) |
pretender ci generate github | stable | emit CI quality-check steps |
pretender lint | beta | complexity/lint gate over changed code |
In progress
- Theme/docs conformance round (DDL-u8x epic); docs/src restructure.
Mapped to specs
- Complexity model definitions in
docs/(mutation.md, plugins.md); openspec proposals in-repo.
Dogfooding
- wai, testaruda, dont (planned pins) install pretender per
versions.ddl.toml; testaruda carries apretender.toml - pretender’s own CI runs
just ci
C
Reference for which tree-sitter nodes pretender tracks for C files, how
each contributes to pretender check metrics, and known measurement gaps.
Consult this page when tuning thresholds or diagnosing unexpected scores.
Overview
Analyses .c and .h files using a tree-sitter grammar. Tracks standard
C function definitions (not declarations). Header files are included so
complexity in inline or static functions defined in headers is measured.
Tracked nodes
| Tree-sitter node | Capture | Metric impact |
|---|---|---|
function_definition | @function.definition | Defines a metric scope |
if_statement | @branch.if | +1 cyclomatic, +1 cognitive |
for_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
while_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
do_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
case_statement | @branch.case | +1 cyclomatic, +1 cognitive |
binary_expression "&&" | @branch.logical.and | +1 cyclomatic, +1 cognitive |
binary_expression `“ | “` | |
call_expression | @call | +1 ABC C-count |
assignment_expression | @assign | +1 ABC A-count |
init_declarator (with value) | @assign | +1 ABC A-count |
Assertion patterns
| Pattern | Matches |
|---|---|
^assert | assert(...) from <assert.h> |
^ASSERT | Custom assertion macros |
^TEST_ASSERT | Unity / CUnit style macros |
Quirks and limitations
- Function pointers and macro-defined functions are not tracked as function scopes.
gotois not captured as a branch — complexity ingoto-heavy code is understated. The smell-weight system flagsgotoseparately.default:in switch is captured as acase_statementand counted as one branch.- Header files (
.h) are analysed like.cfiles. Forward declarations (without a body) are not captured; only full definitions are tracked. - Preprocessor conditionals (
#ifdef,#if) are not parsed by the tree-sitter grammar and do not contribute branch counts.
Example output
src/parser.c cyclomatic=11 cognitive=14 function_lines=52 params=3
↳ parse_token() cyclomatic=11 [threshold: 10]
include/utils.h cyclomatic=2 cognitive=2 function_lines=8 params=1
C++
Reference for which tree-sitter nodes pretender tracks for C++ files, how
each contributes to pretender check metrics, and known measurement gaps.
Consult this page when tuning thresholds or diagnosing unexpected scores.
Overview
Analyses .cpp, .cc, .cxx, .hpp, and .hxx files using a tree-sitter
grammar. Tracks both free functions and out-of-class method definitions
(qualified identifiers). Range-based for loops and catch clauses each
count as branches.
Tracked nodes
| Tree-sitter node | Capture | Metric impact |
|---|---|---|
function_definition (simple identifier) | @function.definition | Defines a metric scope |
function_definition (qualified identifier) | @function.definition | Defines a metric scope |
if_statement | @branch.if | +1 cyclomatic, +1 cognitive |
for_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
for_range_loop | @branch.loop | +1 cyclomatic, +1 cognitive |
while_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
do_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
catch_clause | @branch.catch | +1 cyclomatic, +1 cognitive |
case_statement | @branch.case | +1 cyclomatic, +1 cognitive |
binary_expression "&&" | @branch.logical.and | +1 cyclomatic, +1 cognitive |
binary_expression `“ | “` | |
call_expression | @call | +1 ABC C-count |
assignment_expression | @assign | +1 ABC A-count |
init_declarator (with value) | @assign | +1 ABC A-count |
Assertion patterns
| Pattern | Matches |
|---|---|
^assert | assert(...) from <cassert> |
^ASSERT | Custom assertion macros |
^EXPECT | Google Test EXPECT_EQ(...), etc. |
^CHECK | CHECK(...) (Abseil, glog) |
Quirks and limitations
- Template functions produce one definition entry per instantiation in some grammar revisions; complexity may be overcounted in heavily templated code.
- Lambda expressions are not tracked as function scopes — their branches count toward the enclosing function’s complexity.
- Operator overloads (
operator+,operator==) are captured as function definitions and metriced like regular functions. ternary_expression(condition ? a : b) is not currently tracked and does not contribute cyclomatic complexity.- In-class method definitions (defined inside a class body in a header) use
the simple-identifier form and are tracked correctly. Out-of-class definitions
(e.g.,
Foo::bar()in a.cppfile) use the qualified-identifier form and are also tracked. - Preprocessor conditionals (
#ifdef,#if) are not parsed by the tree-sitter grammar and do not contribute branch counts.
Example output
src/engine.cpp cyclomatic=10 cognitive=13 function_lines=49 params=3
include/parser.hpp cyclomatic=5 cognitive=6 function_lines=24 params=2
Go
Reference for which tree-sitter nodes pretender tracks for Go files, how
each contributes to pretender check metrics, and known measurement gaps.
Consult this page when tuning thresholds or diagnosing unexpected scores.
Overview
Analyses .go files using a tree-sitter grammar. Tracks both top-level
functions and method declarations. Go’s select/case and type-switch
cases each count as a branch, matching Go’s concurrency and type-dispatch
idioms.
Tracked nodes
| Tree-sitter node | Capture | Metric impact |
|---|---|---|
function_declaration | @function.definition | Defines a metric scope |
method_declaration | @function.definition | Defines a metric scope |
if_statement | @branch.if | +1 cyclomatic, +1 cognitive |
for_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
expression_case (in switch) | @branch.case | +1 cyclomatic, +1 cognitive |
type_case (in type switch) | @branch.case | +1 cyclomatic, +1 cognitive |
communication_case (in select) | @branch.case | +1 cyclomatic, +1 cognitive |
binary_expression "&&" | @branch.logical.and | +1 cyclomatic, +1 cognitive |
binary_expression `“ | “` | |
call_expression | @call | +1 ABC C-count |
assignment_statement | @assign | +1 ABC A-count |
short_var_declaration | @assign | +1 ABC A-count |
var_declaration | @assign | +1 ABC A-count |
Assertion patterns
| Pattern | Matches |
|---|---|
^assert | assert* (testify-style) |
^require | require.* (testify require package) |
^t\.Error | t.Error(...), t.Errorf(...) |
^t\.Fatal | t.Fatal(...), t.Fatalf(...) |
^t\.Fail | t.Fail(), t.FailNow() |
Quirks and limitations
- Go has no
else ifnode —else ifchains are parsed as nestedif_statementnodes, so each branch is counted independently (which is the intended behaviour). - No ternary operator — Go does not have
?:, so no ternary branches are ever counted. defaultcase in a switch is not captured as a branch — only explicitcaselabels contribute.selectblocks used for concurrency can produce high branch counts in functions that multiplex many channels.
Example output
pkg/handler.go cyclomatic=7 cognitive=9 function_lines=42 params=2
internal/router.go cyclomatic=11 cognitive=15 function_lines=48 params=3
↳ Route() cyclomatic=11 [threshold: 10]
Java
Reference for which tree-sitter nodes pretender tracks for Java files, how
each contributes to pretender check metrics, and known measurement gaps.
Consult this page when tuning thresholds or diagnosing unexpected scores.
Overview
Analyses .java files using a tree-sitter grammar. Tracks both method
declarations and constructors as function scopes. Enhanced for loops and
switch statement groups each count as separate branches.
Tracked nodes
| Tree-sitter node | Capture | Metric impact |
|---|---|---|
method_declaration | @function.definition | Defines a metric scope |
constructor_declaration | @function.definition | Defines a metric scope |
if_statement | @branch.if | +1 cyclomatic, +1 cognitive |
for_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
enhanced_for_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
while_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
do_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
catch_clause | @branch.catch | +1 cyclomatic, +1 cognitive |
ternary_expression | @branch.ternary | +1 cyclomatic, +1 cognitive |
switch_block_statement_group / switch_label | @branch.case | +1 cyclomatic, +1 cognitive |
binary_expression "&&" | @branch.logical.and | +1 cyclomatic, +1 cognitive |
binary_expression `“ | “` | |
method_invocation | @call | +1 ABC C-count |
object_creation_expression | @call | +1 ABC C-count |
assignment_expression | @assign | +1 ABC A-count |
variable_declarator (with value) | @assign | +1 ABC A-count |
Assertion patterns
| Pattern | Matches |
|---|---|
^assert | JUnit assertNotNull(...), etc. |
^Assert | Assert.assertEquals(...) (static import) |
^verify | Mockito verify(...) |
^Verify | Verify.that(...) |
^expect | expectedException.expect(...) |
^fail | fail("message") |
Quirks and limitations
instanceofchecks insideifconditions are counted as regularif_statementbranches — no special handling for pattern-matchinginstanceof(Java 16+).- Lambda expressions are not tracked as function scopes; their branches count toward the enclosing method’s complexity.
switchexpressions (Java 14+) may not be fully parsed by the current grammar revision — verify with a test file if you rely on switch-expression branch counts.object_creation_expression(new Foo(...)) counts as a call for ABC C-count, which may inflate scores in builder-heavy code.- Mutation testing (
pretender mutation) does not yet run PIT automatically. See Mutation testing for the manual workflow.
Example output
src/main/java/Service.java cyclomatic=9 cognitive=13 function_lines=47 params=3
src/main/java/Parser.java cyclomatic=15 cognitive=21 function_lines=68 params=4
↳ parse() cyclomatic=15 [threshold: 10]
JavaScript
Reference for which tree-sitter nodes pretender tracks for JavaScript files,
how each contributes to pretender check metrics, and known measurement gaps.
Consult this page when tuning thresholds or diagnosing unexpected scores.
Overview
Analyses .js, .jsx, .mjs, and .cjs files using a tree-sitter grammar.
Tracks named functions, arrow functions assigned to variables, and class
methods. switch_case nodes each count as a branch.
Tracked nodes
| Tree-sitter node | Capture | Metric impact |
|---|---|---|
function_declaration | @function.definition | Defines a metric scope |
variable_declarator with arrow_function | @function.definition | Defines a metric scope |
method_definition | @function.definition | Defines a metric scope |
if_statement | @branch.if | +1 cyclomatic, +1 cognitive |
switch_case | @branch.if | +1 cyclomatic, +1 cognitive |
for_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
for_in_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
while_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
do_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
catch_clause | @branch.catch | +1 cyclomatic, +1 cognitive |
ternary_expression | @branch.ternary | +1 cyclomatic, +1 cognitive |
binary_expression "&&" | @branch.logical.and | +1 cyclomatic, +1 cognitive |
binary_expression `“ | “` | |
call_expression | @call | +1 ABC C-count |
assignment_expression | @assign | +1 ABC A-count |
augmented_assignment_expression | @assign | +1 ABC A-count |
variable_declarator (with value) | @assign | +1 ABC A-count |
Assertion patterns
| Pattern | Matches |
|---|---|
^assert$ | Node.js assert(...) |
^assert[A-Z] | assertEqual(...), etc. |
^expect$ | Jest / Vitest expect(...) |
^it$ | it('...', ...) test blocks |
^test$ | test('...', ...) blocks |
^describe$ | describe('...', ...) blocks |
Quirks and limitations
- Anonymous arrow functions not assigned to a named variable are not tracked as function scopes; their branches count toward the enclosing scope.
??(nullish coalescing) is not currently captured as a branch and does not contribute to cyclomatic complexity.- Optional chaining (
?.) is not tracked. switch_casenodes include bothcase X:anddefault:labels —defaultis counted as one branch.- Shebangs (
#!/usr/bin/env node) are stripped by the grammar and do not affect parsing.
Example output
src/router.js cyclomatic=8 cognitive=12 function_lines=44 params=3
src/utils.js cyclomatic=3 cognitive=4 function_lines=18 params=1
Python
Reference for which tree-sitter nodes pretender tracks for Python files, how
each contributes to pretender check metrics, and known measurement gaps.
Consult this page when tuning thresholds or diagnosing unexpected scores.
Overview
Analyses .py files using a tree-sitter grammar. Tracks all function forms
(top-level, methods, nested, decorated) and Python-specific branch constructs
including elif, except, and ternary expressions.
Tracked nodes
| Tree-sitter node | Capture | Metric impact |
|---|---|---|
function_definition | @function.definition | Defines a metric scope |
if_statement | @branch.if | +1 cyclomatic, +1 cognitive |
elif_clause | @branch.elif | +1 cyclomatic, +1 cognitive |
for_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
while_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
except_clause | @branch.catch | +1 cyclomatic, +1 cognitive |
except_group_clause | @branch.catch | +1 cyclomatic, +1 cognitive |
conditional_expression | @branch.ternary | +1 cyclomatic, +1 cognitive |
boolean_operator "and" | @branch.logical.and | +1 cyclomatic, +1 cognitive |
boolean_operator "or" | @branch.logical.or | +1 cyclomatic, +1 cognitive |
call | @call | +1 ABC C-count |
assignment | @assign | +1 ABC A-count |
augmented_assignment | @assign | +1 ABC A-count |
Assertion patterns
The following call patterns are recognised as assertions for the min_assertions
metric (test role):
| Pattern | Matches |
|---|---|
^assert$ | assert(expr) call form; the keyword statement assert expr is captured separately by the assert_statement tree-sitter node |
^assert[A-Z] | assertEqual, assertRaises, assertIn, etc. |
^self\.assert | self.assertEqual(...), etc. |
^pytest\.raises$ | pytest.raises(...) |
^pytest\.warns$ | pytest.warns(...) |
Quirks and limitations
- Decorators do not contribute to complexity — only the function body does.
- Comprehensions (
[x for x in y if cond]) contain implicit branches but are not currently tracked; complexity may be understated for comprehension-heavy code. match/case(Python 3.10+) structural pattern matching arms are not yet captured; each arm contributes no cyclomatic count.except*(except_group_clause) is captured and contributes the same as a plainexcept.
Example output
src/parser.py cyclomatic=12 cognitive=18 function_lines=55 params=3
↳ parse_token() cyclomatic=12 [threshold: 10]
src/parser.py:42 min_assertions=0 [threshold: 1] (test role)
Ruby
Reference for which tree-sitter nodes pretender tracks for Ruby files, how
each contributes to pretender check metrics, and known measurement gaps.
Consult this page when tuning thresholds or diagnosing unexpected scores.
Overview
Analyses .rb files using a tree-sitter grammar. Tracks both instance methods
and singleton methods (class-level self. methods). Ruby’s unless, until,
and rescue each count as branches alongside the more common if and while.
Tracked nodes
| Tree-sitter node | Capture | Metric impact |
|---|---|---|
method | @function.definition | Defines a metric scope |
singleton_method | @function.definition | Defines a metric scope |
if | @branch.if | +1 cyclomatic, +1 cognitive |
unless | @branch.if | +1 cyclomatic, +1 cognitive |
elsif | @branch.if | +1 cyclomatic, +1 cognitive |
for | @branch.loop | +1 cyclomatic, +1 cognitive |
while | @branch.loop | +1 cyclomatic, +1 cognitive |
until | @branch.loop | +1 cyclomatic, +1 cognitive |
rescue | @branch.rescue | +1 cyclomatic, +1 cognitive |
when (in case) | @branch.case | +1 cyclomatic, +1 cognitive |
binary "&&" | @branch.logical.and | +1 cyclomatic, +1 cognitive |
binary `“ | “` | |
binary "and" | @branch.logical.and | +1 cyclomatic, +1 cognitive |
binary "or" | @branch.logical.or | +1 cyclomatic, +1 cognitive |
call | @call | +1 ABC C-count |
assignment | @assign | +1 ABC A-count |
operator_assignment | @assign | +1 ABC A-count |
Assertion patterns
| Pattern | Matches |
|---|---|
^assert | Minitest assert(...), assert_equal(...), etc. |
^expect | RSpec expect(...) |
^should | RSpec legacy should syntax |
^raise_error | RSpec raise_error matcher |
Quirks and limitations
- Modifier forms (
do_something if condition,value unless flag) are parsed asif/unlessnodes and count as branches. - Both
&&/||andand/orare tracked — each contributes one cyclomatic point. Code mixing both keyword and operator forms may count more than expected. - Blocks (
do...end,{...}) are not tracked as function scopes. procandlambdaexpressions are not tracked as function scopes.case/inpattern matching (Ruby 3.0+) may not be fully parsed by the current grammar revision.
Example output
lib/parser.rb cyclomatic=8 cognitive=11 function_lines=39 params=2
spec/parser_spec.rb min_assertions=0 [threshold: 1] (test role)
Rust
Reference for which tree-sitter nodes pretender tracks for Rust files, how
each contributes to pretender check metrics, and known measurement gaps.
Consult this page when tuning thresholds or diagnosing unexpected scores.
Overview
Analyses .rs files using a tree-sitter grammar. Tracks fn items (free
functions and methods). match arms each count as one branch, reflecting
Rust’s exhaustive pattern matching.
Tracked nodes
| Tree-sitter node | Capture | Metric impact |
|---|---|---|
function_item | @function.definition | Defines a metric scope |
if_expression | @branch.if | +1 cyclomatic, +1 cognitive |
for_expression | @branch.loop | +1 cyclomatic, +1 cognitive |
while_expression | @branch.loop | +1 cyclomatic, +1 cognitive |
loop_expression | @branch.loop | +1 cyclomatic, +1 cognitive |
match_arm | @branch.match_arm | +1 cyclomatic, +1 cognitive |
call_expression | @call | +1 ABC C-count |
assignment_expression | @assign | +1 ABC A-count |
compound_assignment_expr | @assign | +1 ABC A-count |
let_declaration (with value) | @assign | +1 ABC A-count |
Assertion patterns
| Pattern | Matches |
|---|---|
^assert$ | assert!(...) |
^assert_eq$ | assert_eq!(...) |
^assert_ne$ | assert_ne!(...) |
^panic$ | panic!(...) |
Quirks and limitations
&&and||operators are not tracked — logical operators do not contribute to cyclomatic or cognitive complexity for Rust. This reflects Rust’s idiom of chaining results and options rather than using boolean guards.- Closures are not tracked as separate function scopes; their branches count toward the enclosing function’s complexity.
if letandwhile letare captured asif_expressionandwhile_expressionrespectively, contributing one branch each.- Wildcard
_arms inmatchare counted as branches like any other arm.
Example output
src/engine.rs cyclomatic=9 cognitive=11 function_lines=38 params=2
src/config.rs cyclomatic=14 cognitive=22 function_lines=61 params=4
↳ parse_config() cyclomatic=14 [threshold: 10]
TypeScript
Reference for which tree-sitter nodes pretender tracks for TypeScript files,
how each contributes to pretender check metrics, and known measurement gaps.
Consult this page when tuning thresholds or diagnosing unexpected scores.
Overview
Analyses .ts, .tsx, .mts, and .cts files using a tree-sitter grammar.
Identical branch and call tracking to the JavaScript adapter; type annotations
and generic type arguments are captured but carry no metric weight.
Tracked nodes
| Tree-sitter node | Capture | Metric impact |
|---|---|---|
function_declaration | @function.definition | Defines a metric scope |
variable_declarator with arrow_function | @function.definition | Defines a metric scope |
method_definition | @function.definition | Defines a metric scope |
if_statement | @branch.if | +1 cyclomatic, +1 cognitive |
switch_case | @branch.if | +1 cyclomatic, +1 cognitive |
for_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
for_in_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
while_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
do_statement | @branch.loop | +1 cyclomatic, +1 cognitive |
catch_clause | @branch.catch | +1 cyclomatic, +1 cognitive |
ternary_expression | @branch.ternary | +1 cyclomatic, +1 cognitive |
binary_expression "&&" | @branch.logical.and | +1 cyclomatic, +1 cognitive |
binary_expression `“ | “` | |
call_expression | @call | +1 ABC C-count |
assignment_expression | @assign | +1 ABC A-count |
augmented_assignment_expression | @assign | +1 ABC A-count |
variable_declarator (with value) | @assign | +1 ABC A-count |
type_annotation | @type.annotation | None |
type_arguments | @type.generic | None |
Assertion patterns
Same as JavaScript:
| Pattern | Matches |
|---|---|
^assert$ | assert(...) |
^assert[A-Z] | assertEqual(...), etc. |
^expect$ | Jest / Vitest expect(...) |
^it$ | it('...', ...) test blocks |
^test$ | test('...', ...) blocks |
^describe$ | describe('...', ...) blocks |
Quirks and limitations
- Type-only constructs (
interface,typealiases,enumdeclarations) are not tracked as function scopes. - Type guards (
if (x instanceof Y)) are counted as regularif_statementbranches — no special handling. ??(nullish coalescing) and?.(optional chaining) are not tracked as branches.- Decorators on classes and methods do not contribute to complexity.
- TypeScript-specific syntax uses the same tree-sitter grammar revision as
JavaScript (
.tsxfiles are parsed as TSX).
Example output
src/api.ts cyclomatic=6 cognitive=8 function_lines=31 params=2
src/components/Form.tsx cyclomatic=4 cognitive=5 function_lines=22 params=1
CLI and Configuration
Purpose
Defines Pretender’s command-line interface, configuration file schema, output formats, role detection, and plugin manifest contracts.
Requirements
Requirement: Init Command
The system SHALL provide pretender init to create pretender.toml, optionally install a pre-commit hook, and optionally generate GitHub Actions configuration. The command SHALL support --non-interactive and --mode <mode>.
In the current MVP, hook installation writes a repository-wide fallback shim that runs pretender check . and documents that --staged --diff-only will replace it when diff filtering is implemented.
Scenario: Non-interactive creates config
- WHEN
pretender init --non-interactiveis run in a repository withoutpretender.toml - THEN the system writes a default
pretender.tomland exits with code 0
Scenario: Interactive init can install hook and workflow
- WHEN
pretender initis run interactively and the user opts into hook and CI setup - THEN the system writes
pretender.toml,.git/hooks/pre-commit, and.github/workflows/pretender.yml
Requirement: Check Command
The system SHALL provide pretender check [paths...] as the implemented MVP scan command for explicit file and directory inputs. The command SHALL require at least one path, SHALL recursively scan directories, and SHALL support --format human|json|sarif, --output <path>, and --mode guidance|tiered|gate.
For the current MVP, --staged, --diff-only, and --diff-base <ref> are reserved CLI flags and SHALL exit with code 2 and a not yet implemented message when used.
Mode behavior in the current MVP is:
guidance: always exits0tiered: always exits0after annotating yellow/red findingsgate: exits non-zero when any file or unit threshold is violated
Every successful pretender check run SHALL persist the report cache used by pretender report, regardless of output format.
Scenario: Human check succeeds on clean file
- WHEN
pretender check path/to/file.pyruns on a file with no threshold violations - THEN the command exits with code
0
Scenario: Guidance mode does not fail violating file
- WHEN
pretender check path/to/file.py --mode guidanceruns on a file with threshold violations - THEN the command exits with code
0after reporting the violations
Scenario: Reserved staged flag is rejected
- WHEN
pretender check path/to/file.py --stagedis run - THEN the command exits with code
2and reports that the feature is not yet implemented
Scenario: SARIF output is emitted
- WHEN
pretender check path/to/file.py --format sarifis run - THEN stdout contains valid SARIF 2.1.0 JSON for the analysed findings
Requirement: Complexity Command
The system SHALL provide pretender complexity <path> for the current MVP. The command SHALL accept exactly one path and SHALL report cyclomatic complexity per discovered code unit, sorted worst-first.
The current MVP does NOT implement --top <n> or --threshold <n>.
Scenario: Complexity reports worst units first
- WHEN multiple code units have different cyclomatic scores
- THEN
pretender complexity <path>lists the highest scoring units before lower scoring units
Requirement: Report Command
The system SHALL provide pretender report to render cached results from the last successful pretender check. The command SHALL support --format human|markdown|html and --output <path>.
Scenario: Report reads cached human check results
- WHEN
pretender report --format markdownis run after a successful human-formatpretender check - THEN the command renders a Markdown report from the cached results
Requirement: Hooks Commands
The system SHALL provide pretender hooks install to install a native pre-commit shim and pretender hooks uninstall to remove a shim previously installed by Pretender.
In the current MVP, the installed shim SHALL run pretender check . and include a comment that diff-only filtering is deferred to future work.
Scenario: Hook install writes fallback shim
- WHEN
pretender hooks installis run - THEN
.git/hooks/pre-commitcontains a Pretender-managed shim that executespretender check .
Scenario: Hook uninstall removes Pretender shim
- WHEN
pretender hooks uninstallis run after Pretender installed the hook - THEN the Pretender-managed
.git/hooks/pre-commitfile is removed
Requirement: CI Generate Command
The system SHALL provide pretender ci generate github in the current MVP. The generated workflow SHALL install Pretender, run pretender check . --format=sarif --output=pretender.sarif, upload SARIF, append a Markdown report on failure, and fail the job when findings are present.
Providers other than github remain reserved and SHALL exit with code 2 and a not yet implemented message.
Scenario: GitHub workflow includes SARIF upload
- WHEN
pretender ci generate githubis run - THEN the generated workflow uploads Pretender SARIF through GitHub Code Scanning
Requirement: Configuration Schema
The system SHALL read pretender.toml with tables for [pretender], [thresholds], role-specific threshold tables such as [thresholds.test], [bands], [scope], [execute], [plugins], [output], and [roles]. The mode value SHALL be one of guidance, tiered, or gate. The implicit default role SHALL be app. Unknown config keys SHALL be ignored.
Scenario: Role-specific thresholds override app defaults
- WHEN a file is assigned role
test - THEN values under
[thresholds.test]override app threshold defaults for that file
Requirement: Role Detection
The system SHALL assign each file a role from app, library, test, script, generated, or vendor using the current MVP resolution order:
- explicit pragma found in the first 8 lines
- configured
[roles]path globs, with the most specific matching glob winning - built-in path heuristics
- default
app
The current pragma scanner SHALL accept line comments beginning with # or //, and block-comment openings beginning with /*.
Scenario: Pragma wins over path glob
- WHEN a file declares an explicit Pretender role pragma and also matches a configured role glob
- THEN the pragma role is assigned
Scenario: Most specific glob wins
- WHEN a file matches both
tests/**andtests/manual/** - THEN the more specific glob is assigned
Scenario: Heuristic role is used when no pragma or glob applies
- WHEN a file path contains
/vendor/and no pragma or configured glob applies - THEN the file is assigned role
vendor
Requirement: Schema Versioning
The system SHALL not require a config version key. Unknown config keys SHALL be ignored for forward compatibility.
Scenario: Unknown key is ignored
- WHEN
pretender.tomlcontains an unknown key - THEN parsing succeeds and the unknown key has no effect
Requirement: Output Formats
The current MVP SHALL emit human, json, and sarif output for pretender check, and human, markdown, and html output for pretender report. The junit and markdown formats for check are not implemented and are not accepted by the CLI (tracked under pretender-t2m).
Scenario: JSON report is emitted
- WHEN
pretender check path/to/file.py --format jsonis run - THEN stdout contains valid JSON describing file reports and unit metrics
Scenario: SARIF validates
- WHEN Pretender emits SARIF output from
pretender check - THEN the output conforms to the SARIF 2.1.0 schema
Requirement: Plugin Manifests
The system SHALL parse data-only plugin manifests made of .scm query files and plugin.toml metadata. In the current MVP, manifest parsing is implemented as a library capability and test fixture, but plugin installation, discovery, and runtime loading are not yet implemented by the CLI.
Scenario: Python plugin manifest parses
- WHEN the built-in Python
plugin.tomlis loaded by the manifest parser - THEN the manifest is parsed successfully and exposes its query and assertion metadata
Requirement: Reserved Commands
The current MVP CLI SHALL expose the following reserved commands, which currently exit with code 2 and a not yet implemented message: duplication, mutation, plugins, and explain.
Scenario: Reserved command reports not implemented
- WHEN
pretender explain cyclomaticis run - THEN the command exits with code
2and reports that the feature is not yet implemented
Requirement: Language Support
The current MVP SHALL register built-in parsers for C, C++, Go, Java, JavaScript, Python, Ruby, Rust, and TypeScript-family source files. Files with unsupported extensions SHALL be skipped by pretender check, and pretender complexity <path> on an unsupported extension SHALL fail.
Scenario: Rust file is analysed
- WHEN
pretender check example.rsis run - THEN Pretender analyses the file with the Rust parser
Scenario: Unsupported complexity input fails
- WHEN
pretender complexity example.txtis run - THEN the command exits non-zero because no parser is registered for
.txt
cli-core Specification
Purpose
TBD - created by archiving change upgrade-genesis. Update Purpose after archive.
Requirements
Requirement: CLI scaffold uses Guide
The tool’s main.rs CLI setup SHALL use genesis::guide::Guide::builder()
when the tool adopts genesis::guide. Adoption is determined by the tool’s
maintainer — this spec defines the contract for how to do it.
Scenario: Guide builder replaces ad-hoc setup
- GIVEN the tool adopts
genesis::guide - WHEN
main.rsis updated to useGuide::builder(...) - THEN command handlers SHOULD return
Output<T>or useErrorSink - AND
cargo testSHALL pass
Scenario: ErrorSink for self-healing errors
- GIVEN the tool adopts
genesis::guide - WHEN a command returns an error
- THEN
ErrorSinkSHOULD print the error with a suggestion footer - AND it SHOULD write to the error scratch (for
--from-last-error)
config Specification
Purpose
TBD - created by archiving change upgrade-genesis. Update Purpose after archive.
Requirements
Requirement: Shared config management
The tool SHALL adopt genesis::config for shared config management.
Scenario: config struct implements ConfigFile
- GIVEN the tool needs config management
- WHEN the tool adopts
genesis::config - THEN the tool’s config SHALL implement
genesis::config::ConfigFile - AND all config file I/O (read, write, parse) SHALL delegate to genesis
- AND
cargo testSHALL pass
Scenario: config registered at startup
- GIVEN the tool starts up
- WHEN it initializes
- THEN it SHALL register its config struct with
ConfigRegistry - AND it SHOULD use
ConfigStorefor config discovery and validation
Scenario: dead config code removed
- GIVEN the tool has adopted genesis::config
- WHEN the old config parsing code is removed
- THEN
cargo clippySHALL introduce no new warnings - AND
cargo testSHALL pass
Universal Code Model
Purpose
Defines the language-neutral model that all Pretender metrics operate on, plus the tree-sitter query contract, model invariants, metric formulas, and compatibility rules.
Requirements
Requirement: Supporting Types
The system SHALL represent source spans, parameters, call sites, and languages in a language-neutral form. Span.lines() SHALL be inclusive and SHALL require end_line >= start_line. CallSite SHALL include a callee name, span, and default ABC smell weight of 1.0.
Scenario: Span line count is inclusive
- WHEN a span has
start_line = 3andend_line = 5 - THEN
Span.lines()returns 3
Requirement: Module Model
The system SHALL represent each successfully parsed source file as one Module containing path, language, whole-file span, total/code/comment line counts, code units, and imports. In the current MVP, imports are emitted as an empty list by the built-in query engine adapters.
Scenario: One file maps to one module
- WHEN a supported source file is parsed successfully
- THEN the engine emits one
Modulefor that file
Requirement: Code Unit Model
The system SHALL represent each function, method, lambda with block body, constructor, or initializer as a CodeUnit with name, kind, span, parameters, body block, and exported flag.
Scenario: Function maps to code unit
- WHEN a language adapter captures a function definition
- THEN the universal model contains a
CodeUnitfor that function
Requirement: Block and Node Model
The system SHALL represent nested scopes as Block values with zero-based nesting depth from the enclosing CodeUnit body root. Node SHALL represent statements, branches, nested blocks, and calls.
Scenario: Root body nesting is zero
- WHEN a
CodeUnitbody is created - THEN its root block has
nesting = 0
Requirement: Branch Model
The system SHALL represent control-flow points as Branch values with kind, span, nesting depth at capture time, and optional logical sequence ID. Branch kinds SHALL include if, else-if, switch case, loop, catch, ternary, logical-and, and logical-or.
Scenario: Logical sequence IDs are scoped to code unit
- WHEN logical operators are captured in two different code units
- THEN their sequence IDs are unique only within each enclosing code unit
Requirement: Tree-Sitter Query Contract
Each implemented language adapter SHALL provide a .scm query file whose captures populate the universal model. The current MVP query engine requires captures for @function.definition, @function.name, @function.parameters, and @function.body; it also consumes optional branch, @call, @call.callee, @assign, and @assert.* captures.
Assertion captures SHALL contribute to the per-code-unit assertion count used by role-specific threshold checks.
Scenario: Function captures populate code unit
- WHEN a query captures
@function.definition,@function.name,@function.parameters, and@function.body - THEN the adapter creates the corresponding
CodeUnit
Requirement: Model Invariants
The system SHALL preserve these invariants: code-unit body nesting starts at 0; nested blocks increment nesting by 1; branches carry nesting depth at capture time; files with tree-sitter parse errors emit a warning diagnostic and return an otherwise-empty Module; identical logical operator sequences at the same nesting level count once for cognitive complexity.
In the current MVP query-engine-backed adapters, code units whose names begin with _ are treated as not exported and other named units are treated as exported.
Scenario: Parse error returns empty module with warning
- WHEN tree-sitter reports a parse error for a source file
- THEN the system emits a warning diagnostic and returns a
Modulewith noCodeUnitvalues
Requirement: Metric Functions
The system SHALL compute metrics as pure functions over the universal model. Cyclomatic complexity SHALL be 1 + branch count. Cognitive complexity SHALL sum branch cognitive weight multiplied by 1 + nesting_at. Function lines SHALL use inclusive span lines. Parameter count SHALL be parameter length. Maximum nesting SHALL be the maximum block nesting value. ABC SHALL compute sqrt(A^2 + B^2 + C^2) using counted assignments, weighted branches, and weighted calls.
In the current MVP, parsed CallSite values are assigned a default smell_weight of 1.0 by the parser, with smell-specific weighting applied later during metric evaluation.
Scenario: Cyclomatic adds one to branches
- WHEN a code unit has 4 branch nodes
- THEN cyclomatic complexity is 5
Requirement: Current Adapter Surface
The current MVP SHALL register built-in adapters for C, C++, Go, Java, JavaScript, Python, Ruby, Rust, and TypeScript-family source files.
Scenario: TypeScript adapter is available
- WHEN a
.tsfile is analysed - THEN the parser registry resolves the TypeScript adapter for that file extension