Configuration¶
You may not need any¶
The built-in defaults are a complete configuration. scitools-hook check --staged works on a
repository with no configuration file at all.
Write one when you have a decision to record. This project's own configuration file carries only the keys that deviate from the defaults, with the measurement behind each deviation in a comment, so the file reads as a list of decisions rather than a copy of the defaults.
Where settings come from¶
Lowest to highest precedence:
built-in defaults
< ~/.config/scitools-hook/config.toml (user)
< ./scitools-hook.toml (repository)
< SCITOOLS_HOOK_* environment variables
< command-line options
< [scope.*] path scopes
Each layer overrides only the keys it defines. config shows the result and where each
setting came from:
$ scitools-hook config
baseline.adaptive = false # default
baseline.file = "scitools-hook.baseline.json" # default
project.exclude = [".git/**", "node_modules/**", ...] # repo:/path/to/scitools-hook.toml
project.include = ["**"] # default
ratchet.strict = false # default
structure.architecture = "Directory Structure" # default
...
Write a starting file¶
That writes scitools-hook.toml containing every value at its default, with a comment on
each section. Delete a key to go back to its default; change it to override.
scitools-hook init --print writes it to standard output instead.
# Thresholds: `Metric = 10` means at most 10; `Metric = { min = 0.1 }` means at least 0.1;
# add `severity = "warning"` (does not block) or `ratchet = false` (no worse-than-before
# check) inside the table.
[thresholds.routine]
CyclomaticStrict = 10
CyclomaticModified = 8
Essential = { max = 4, severity = "warning" }
MaxNesting = 3
CountLineCode = 60
CountStmt = 40
CountParams = 5
CountPath = 100
CountLineComment = { max = 20, severity = "warning" }
LinesPerStatement = { max = 4, severity = "warning" }
[thresholds.class]
CountDeclMethod = { max = 20, ratchet = false }
CountDeclMethodNonStub = { max = 15, ratchet = false }
CountDeclInstanceVariable = { max = 10, ratchet = false }
MaxInheritanceTree = { max = 4, severity = "warning" }
CountClassDerived = { max = 8, ratchet = false }
CountClassCoupled = { max = 12, ratchet = false }
PercentLackOfCohesion = { max = 70, severity = "warning" }
[thresholds.file]
CountLineCode = { max = 500, ratchet = false }
CountDeclFunction = { max = 25, ratchet = false }
CountDeclClass = { max = 3, ratchet = false }
MaxCyclomaticStrict = 10
RatioCommentToCode = { min = 0.1, severity = "warning" }
[thresholds.project]
"AVG:CyclomaticStrict" = 3
MaxCyclomaticStrict = 15
"AVG:CountLineCode" = 30
MaxNesting = 5
Metric names are Understand identifiers, plus the two the gate computes itself
(CountParams, CountDeclMethodNonStub). A metric that exists for none of the enabled
languages is a configuration error and exits 2.
A stats prefix at project scope reduces over the population of the scope:
AVG, MEDIAN, MEDIANHIGH, MEDIANLOW, MEDIANGROUPED, MODE, STDEV, VARIANCE.
Choose what is analysed¶
[project]
include = ["**"]
exclude = [
".git/**", "node_modules/**", ".venv/**", "venv/**",
"build/**", "dist/**", "target/**", "__pycache__/**",
"*.min.js", "*.generated.*", "*.lock", "uv.lock", "package-lock.json",
]
Globs are relative to the repository root. Lists replace rather than merge, so if you set
exclude you are replacing the whole default list and should repeat the entries you want to
keep.
Two exclusions worth adding deliberately:
- Test fixtures that are analysis input rather than source. This repository excludes
tests/fixtures/**because one fixture was built to contain a dependency cycle, a layer violation and a fan-out increase, and the test suite asserts the gate reports them. Gating on it would be gating on the test data. - Web assets that are not code. Understand treats
.css,.htmland.xmlasWebsource and will judge them by file-scope rules. See Languages.
Python import paths¶
The directories, relative to the repository root, that Understand resolves Python imports
from. Without them Understand resolves an import only against the importing file's own
directory, so a src layout and every workspace member under packages/ are unresolved;
a routine called under a name that is defined in two places is then bound to the wrong one,
and two directories that never import each other can read as a dependency cycle.
Measured on a 1,457-file repository with a src layout and six workspace packages: 23% of
files analysed cleanly without the paths and 51% with them, and 1,034 references the
analysis had drawn between two packages that share no import were gone. The paths are set on
every database the gate builds, under whatever tree that database analyses (a shadow tree,
the repository at a commit, a worktree), and they are part of the analysis fingerprint, so
adding them rebuilds the cached before side. Empty by default, which leaves Understand's own
resolution alone.
Different limits for different directories¶
A path scope changes the numbers a file is judged by. It never removes a file from the analysis.
[scope.legacy]
paths = ["legacy/**"]
[scope.legacy.thresholds.routine]
CyclomaticStrict = 20
MaxNesting = false # switch the rule off for this region
Measured on a real tree with that file in place — a routine at CyclomaticStrict 11 and
MaxNesting 4, which would normally raise both:
$ scitools-hook config --why legacy/report.py
path: legacy/report.py
role: product
no region covers this path; it is product code by default
scopes: legacy
[scope.legacy] matched by 'legacy/**'
routine.CyclomaticStrict = max=20
routine.MaxNesting = false (the rule does not apply here)
parse: not acknowledged; an unreadable file here blocks the commit
$ scitools-hook check --staged
error routine.CyclomaticModified report.render line 4 1.1x limit
warning file.RatioCommentToCode 0.4x limit
summary: 1 error, 1 warning, 0 pre-existing, 1 blocking | exit 1: blocking violations found
CyclomaticStrict and MaxNesting are gone. CyclomaticModified, which the scope did not
mention, still applies at its default of 8.
Rules:
- A rule the scope names takes the scope's limit, severity and ratchet setting, falling back to the global spec for anything it does not say.
Metric = falseremoves the rule for this path. It is not evaluated, and no finding can come from it.- A rule no global threshold defines is added if the scope gives it a limit.
- A rule the scope does not mention keeps its global value.
- Two scopes matching one file both apply, in declaration order, and the later one wins per rule.
config --why PATH is the command to run when you are not sure which of those happened.
Ignore entities entirely¶
These are regular expressions matched against the entity's qualified long name, plus the repository-relative path for the file scope. A matching entity skips every rule and is counted in the run's ignore total, so the report still says how much was skipped.
Prefer a path scope over an ignore where you can. A scope says "these numbers are different here"; an ignore says "do not look".
Structural rules¶
[structure]
architecture = "Directory Structure"
depth = 2
file_cycles = "error"
arch_cycles = "error"
max_new_dependencies_per_file = 5
new_dependencies_severity = "error"
fan_severity = "warning"
[structure.fan]
file_fan_in = { max = 50 }
file_fan_out = { max = 20 }
class_fan_in = { max = 30 }
class_fan_out = { max = 12 }
Architecture nodes come from Understand. Directory Structure at depth = 2 means the
first two levels of your directory tree become the nodes.
Layer rules and coupling limits are declared as arrays of tables:
[[structure.layers]]
name = "cli must not reach the adapters"
node = "Directory Structure/src/cli"
may_depend_on = ["Directory Structure/src/runner"]
severity = "error"
[[structure.coupling]]
from_node = "Directory Structure/src"
to_node = "Directory Structure/lib"
max_refs = 50
severity = "error"
A layer rule reports a new edge that the rule does not allow. An edge that was already there before the change is not a new edge, and a growing reference count on an existing edge is not one either — newness is a property of the pair.
Scattered definitions¶
Off by default. It reports a module-level name bound to the same value in more files than the limit — a constant that was copied instead of shared.
[structure]
duplicate_definitions = 3
duplicate_definitions_ignore = ["log", "logger", "pytestmark"]
Turn it on when a project has grown by copying: type aliases, tolerance constants, test fixtures and project-root computations are where it earns its keep. The ignore list is for the per-module idiom, which is written out in every file on purpose. See Scattered definitions.
What Understand 8.0 adds¶
Every key here is off by default. A repository that changes none of them behaves on 8.0 exactly as it did on 6.5, which is the point: none of these is a feature you get by upgrading your analyser.
[understand]
sarif = false # also write Understand's own SARIF beside --sarif PATH
before_side = "shadow" # "shadow", "commit", or "auto" (commit where the build offers it)
snapshot_cache = true # reuse the before side's extraction between runs
[analysis]
accuracy_floor = 0.0 # unset by default; a floor raises a warning, never blocks
[structure]
unused_routines = "warning" # unset by default
unused_ignore = ['\.__\w+__$', '(^|\.)test_', '(^|\.)(setup|teardown)(_\w+)?$', '(^|\.)main$']
architecture_options = {} # passed to `und arch -generate -options`
Every one of them is refused unless doctor has measured that your build offers it. Run
scitools-hook doctor once after an upgrade; it records what the installation can do beside
the analysis databases, and a check reads that record rather than probing. A key the build
cannot honour stops the run with the key, the feature and the build named, rather than being
read, ignored and silently measuring something else.
understand.sarif¶
Writes Understand's own SARIF documents beside the one check --sarif PATH asks for, so one
upload carries the gate's findings, Understand's parse diagnostics and, where CodeCheck is
licensed, its inspection results. The three are never merged.
The analysis companion comes from a whole-project analysis pass only. A warm run analyses little or nothing, and Understand's SARIF describes the pass rather than the database, so a warm run writes no companion and says so. See Understand 8.0.
understand.before_side¶
"shadow" exports the base commit into a tree and analyses it, which is what every release
before this one did. "commit" builds the before database from the commit directly.
"auto" asks for the commit route where the build offers it and falls back silently
otherwise, which is what makes it safe to set on a mixed fleet.
It buys reproducibility rather than speed — measured, a warm run's before side already costs
0.0 s — and it changes the before side's file set to the repository under und -exclude
rather than the shadow under project.include and project.exclude. Where those disagree,
the two sides see different projects.
analysis.accuracy_floor¶
und analyze -accuracy reports the share of files that parsed with neither an error nor a
warning. Below the floor, the run says so as a warning that never blocks: a poor figure is
usually a third-party package or a language feature Understand has not caught up with, and
neither is fixable by the commit in front of you.
structure.unused_routines¶
Reports each affected routine that nothing in the project calls or uses. It is a warning by
default because reference-based dead-code detection cannot see an entry point named in
packaging metadata, a handler a decorator registers, a dunder the interpreter calls or a test
pytest collects — the four shapes unused_ignore excuses out of the box. Add your own
patterns rather than adding a caller.
The lean-code family: [lean]¶
Every rule in this table is off by default, and the table is the one place the family is configured: code an agent left behind, layers that forward and nothing else, abstractions with one implementation, files that export one name, copies and near-copies, and the length of the change itself. What each rule reports and how to read a finding is on Lean code; empirical calibration of duplication thresholds and safety floors is on Tuning duplicate and dead code; the measurement behind each default is in the rules reference. This section is the keys.
This is the excerpt scitools-hook init writes. Each commented line is both the switch and
the documentation of one rule: uncomment it to enable the rule, with "warning" as the value
to start from. Every set line belongs to one rule and does nothing until that rule is on, with
three exceptions the comment names: the two floors, which are read on every run, and
verbosity_min_statements, which routine.LinesPerStatement reads and which ships on.
# The lean-code family: code an agent left behind, layers that forward and nothing else,
# abstractions with one implementation, files that export one name, copies and near-copies.
# Every rule below SHIPS OFF, and each commented line is both the switch and the
# documentation: uncomment it to enable the rule, and "warning" -- report it, do not refuse
# the commit -- is the value to start from. Findings carry the `structure.` category, so
# [ignore], the scope overrides, the severity map and the ratchet reach them like any other
# structural rule. The *_ignore lists are regular expressions over entity names, except
# over_export_ignore, duplicates_ignore and similar_ignore, which are path globs.
# The two *_floor keys are the only set lines here that no rule owns, which is why they ship
# SET while every rule in this block ships off: runner.lean.evaluate reads them on every run,
# whatever these switches say. Every other key that ships set here belongs to one rule -- it
# is that rule's limit, its exception list or its severity -- and does nothing until that rule
# is on, with one exception: verbosity_min_statements is read by [thresholds.routine]
# LinesPerStatement, which does ship on. The floors say how much of the analysis has to have
# worked before a rule may claim a name is unused: below either one the dead-code and
# pass_through rules evaluate nothing and say which floor stopped them at what measured
# value. [lean] accuracy_floor REFUSES to judge below it; the separate [analysis]
# accuracy_floor only REPORTS a poorly resolved run and silences nothing.
# Measured on two repositories (research.md, task 6.4): similar_routines and duplicates
# report copies at these numbers; unused_parameters and pass_through mostly report test
# fixtures, overload stubs and one-line comprehensions -- enable those two last, and read
# their first ten findings before trusting their count.
[lean]
# unused_parameters = "warning" # unset: off. Reports parameters a routine never reads
unused_parameters_ignore = ["^(self|cls|this)$", "^_", "^(args|kwargs)$"]
# unused_classes = "warning" # unset: off. Reports classes nothing in the project references
unused_classes_ignore = ["(^|\\.)Test", "Error$", "Exception$"]
# unused_variables = "warning" # unset: off. Reports module-level names nothing references
unused_variables_ignore = ["^__\\w+__$", "^(log|logger|pytestmark)$", "^_$"]
resolution_floor = 0.75 # below this share of resolved calls the rules above say nothing
accuracy_floor = 0.75 # the parse floor; NOT [analysis] accuracy_floor, which only reports
# pass_through = "warning" # unset: off. Reports a routine with one caller that only forwards
pass_through_max_statements = 2
pass_through_ignore = ["\\.__\\w+__$", "(^|\\.)test_", "(^|\\.)(setup|teardown)(_\\w+)?$", "(^|\\.)main$"]
# single_implementation = "warning" # unset: off. Reports a base class only one subclass uses
single_implementation_ignore = ["Error$", "Exception$"]
# over_export = "warning" # unset: off. Reports files defining one name for one importer
over_export_ignore = ["**/__init__.py", "**/index.*", "**/mod.rs", "**/__main__.py"]
# duplicates = "warning" # unset: off. Reports copies of duplicates_min_lines lines or more
duplicates_min_lines = 12
duplicates_ignore = []
# similar_routines = "warning" # unset: off. Reports routines whose tokens twin another's
similar_min_statements = 6
similar_threshold = 0.9 # 1.0 is an identical token run
similar_min_family = 2 # 2 keeps a plain twin
similar_ignore = []
similar_name_ignore = ["(^|[.:])__\\w+__$", "(^|[.:])(setUp|tearDown|setup|teardown)(_\\w+)?$"]
verbosity_min_statements = 5 # below this a routine is not judged by LinesPerStatement
# max_net_growth = 50 # unset: off. Reports a change adding more than N net lloc
net_growth_severity = "warning" # how a max_net_growth finding is reported
The keys, in the five groups the excerpt is written in. A switch takes "warning" or
"error"; leaving it unset is off. The switch and the rule it enables are not spelled the
same: the finding is named after what it reports, in the singular, and the switch after the
table it configures.
| Key | Enables or bounds | Notes |
|---|---|---|
unused_parameters |
structure.unused_parameter |
A parameter the routine never reads, sets or modifies, and that no signature it overrides asks for. |
unused_parameters_ignore |
its exceptions | Regular expressions over parameter names. Ships excusing self, cls, this, a leading underscore, args and kwargs. |
unused_classes |
structure.unused_class |
A class nothing in the project references. |
unused_classes_ignore |
its exceptions | Regular expressions over class names. Ships excusing Test*, *Error and *Exception. |
unused_variables |
structure.unused_variable |
A module-level name nothing in the project reads. |
unused_variables_ignore |
its exceptions | Regular expressions over binding names. Ships excusing dunders, log, logger, pytestmark and _. |
resolution_floor |
the four rules above and pass_through |
The share of a language's call sites that resolved to a project routine, below which those rules evaluate nothing and say so once per run. Read on every run, whatever the switches say. |
accuracy_floor |
the same five rules | The share of the analysis Understand parsed without an error, below which those rules refuse. Not analysis.accuracy_floor, which reports and silences nothing. |
pass_through |
structure.pass_through |
A routine with one project caller and one project callee that only forwards. |
pass_through_max_statements |
its bound | Understand's CountStmt counts the def line, so the shipped 2 means a body of one statement; a guard clause would need 3 (measured, task 6.4). |
pass_through_ignore |
its exceptions | Regular expressions over routine long names. Ships as structure.unused_ignore does: dunders, test_*, setup/teardown, main. |
single_implementation |
structure.single_implementation |
A base class exactly one subclass derives from and nothing else names. |
single_implementation_ignore |
its exceptions | Regular expressions over class names. Ships excusing *Error and *Exception. |
over_export |
structure.over_export |
A file defining one name that one other file imports. |
over_export_ignore |
its exceptions | Path globs, relative to the repository root. Ships excusing __init__.py, index.*, mod.rs and __main__.py. |
duplicates |
structure.duplicate_block |
A run of lines the project holds elsewhere, whitespace and comments absent. |
duplicates_min_lines |
its window | The shortest run reported, in code lines. Measured on two repositories (task 6.4): a window of 15 would have cost 26 of 42 and 63 of 122 code findings. |
duplicates_ignore |
its exceptions | Path globs, for a fixture or generated tree that is copies by design. |
similar_routines |
structure.similar_routine |
A family of routines whose token streams match above the threshold, identifiers and literals interchangeable; one finding per family, never one per member. |
similar_min_statements |
its population | A routine below this many statements is in no family. |
similar_threshold |
its bound | Token similarity holding a family together; 1.0 is an identical token run. |
similar_min_family |
its size | 2 keeps a plain twin; a family of one is every routine in the project. |
similar_ignore |
its exceptions | Path globs, as duplicates_ignore. |
similar_name_ignore |
its exceptions | Regular expressions over routine long names, for the shapes a family is idiomatic rather than duplicated. Ships excusing dunders and setUp/tearDown/setup/teardown. |
verbosity_min_statements |
routine.LinesPerStatement |
Below this many statements the ratio is not judged: a two-statement routine over six lines scores 3.0 by arithmetic. The metric itself is a threshold under [thresholds.routine] and ships on. |
max_net_growth |
structure.net_growth |
Unset, the net line never blocks. Set, a change adding more net logical lines than this is a finding; 0 is legal and means "may not make the project longer". |
net_growth_severity |
its severity | "warning" by default. |
Four things that are easy to get wrong:
- Lists replace, they do not merge. Setting
unused_parameters_ignorereplaces the shipped three patterns; repeat the ones you want to keep, as with[project] exclude. - Six lists are regular expressions and three are globs.
over_export_ignore,duplicates_ignoreandsimilar_ignorename files, in the glob language[project]speaks; writing.*\.py$there is a literal that matches nothing, and the loader says so. The other six match entity names. - A path scope does not reach this table.
[scope.*]holds thresholds, so it can raise, demote or switch offLinesPerStatementandCountLineCommentfor a region and nothing under[lean]; the structural rules and their lists are repository-wide. The tests policy that follows from that is on Lean code. - The floors are not a rung of adaptation. Both measured repositories sit below them (19.1% and 25.9% accuracy, task 6.4), and with the floors forced to zero the rules they guard were right in 0 to 8 of their first ten. A floor moves when a repository above it is measured, not to make a rule speak.
The family reads Understand's references and its lexer, and doctor prints a row for each:
the reference walk the dead-code and layering rules need, the lexer the two duplication rules
need, and the optional duplicate-lines metric. As with the Understand 8.0 keys above, a switch
the installed build cannot honour stops the run rather than measuring something else, with
the key, the capability and the build named:
lean.duplicates needs lean tokens, which <build> does not offer.
What enabling the family costs, measured with everything on (task 6.3): a warm check gained
4.2 s on a 14.7 s run, 3.8 s of it the after-side snapshot, and a whole-project check --all
with similar_routines on compares every routine with every other, 43.7 s on a 319-file
repository and 85.7 s on a 945-file one. Off, the family extracts nothing and a warm check
costs what it costs today.
Severities¶
Every rule has a severity. error can block; warning never does, in any mode.
There is no central severity map. A severity is set where the rule is declared. A
top-level [severity] table is rejected at load:
$ scitools-hook config
error: severity: Extra inputs are not permitted
file: /path/to/scitools-hook.toml
| Rule family | Where its severity lives |
|---|---|
| thresholds | inline in the table: CountPath = { max = 100, severity = "warning" } |
| file and architecture cycles | structure.file_cycles, structure.arch_cycles |
| new dependencies | structure.new_dependencies_severity |
| fan-in and fan-out | structure.fan_severity |
| layer and coupling rules | severity inside each [[structure.layers]] / [[structure.coupling]] |
| CodeCheck | codecheck.severity |
| ratchet findings inside a limit | ratchet.below_limit_severity |
analysis.parse_error |
not configurable — use [[parse.acknowledged]], below |
[thresholds.routine]
CountPath = { max = 100, severity = "warning" }
[structure]
fan_severity = "error"
new_dependencies_severity = "warning"
A threshold table accepts exactly four keys and rejects anything else by name:
error: thresholds.file.CountDeclFunction: unknown keys enabled;
allowed: max, min, ratchet, severity
A global rule cannot be switched off. Metric = false is a [scope.*] construct only;
at the top level it is a type error:
To stop a rule blocking, demote it. That keeps the finding, its entity, its line and its hint in the report and removes only its ability to refuse a commit.
ratchet.below_limit_severity¶
The severity ceiling a ratchet finding gets while the entity is still inside its own limit
after the change. Shipping it as warning is what stops the gate freezing every file it is
pointed at:
warning routine.CountLineCode pkg.big.grow line 4 worse than before, was 23
routine pkg.big.grow CountLineCode rose from 23 to 24, still within the maximum 60
Set it to error to refuse any movement at all. It is a ceiling, never a promotion: a
rule you demoted to warning yourself stays a warning under either value. Full detail on
the ratchet.
Acknowledging a file that does not parse¶
A file in the selection that Understand could not read is a blocking analysis.parse_error,
because a file the analyser could not read must never report as clean. If you genuinely have
to ship past one, the acknowledgement requires a written reason:
[[parse.acknowledged]]
paths = ["pricing/generic.py"]
reason = "PEP 695 type parameters; Understand 6.5 stops at the declaration."
What that does, and only what it does: it clears blocking on that finding. The finding
keeps its error severity, keeps its place in the report, and gains a sentence saying
the file is measured only up to the construct that stopped the parse. Measured, with and
without that entry, on the same file:
$ scitools-hook check --staged
pricing/generic.py
error analysis.parse_error line 4
Understand could not read pricing/generic.py: 6 parse errors, the first at line 4:
expected token '(' at token [. The analysis stops where the parse stops, so the code
after it is absent from the database and no rule ran on it -- this file cannot be
reported as checked. -- acknowledged: PEP 695 type parameters; Understand 6.5 stops
at the declaration.; the file is measured only up to the construct that stopped the
parse; nothing after it was read
hint: PEP 695 type parameters: Understand 6.5 cannot parse a type-parameter list, and
one of them costs the rest of the file. Declare the variable explicitly instead --
`T = TypeVar("T")` at module level, then `def generic(x: T) -> T:` and
`class Box(Generic[T]):` -- which is the same type with a spelling the analysis reads
summary: 1 error, 0 warnings, 0 pre-existing, 0 blocking | 1 file failed to parse, not fully checked | exit 0: no blocking violations
1 error ... 0 blocking, and the run still says 1 file failed to parse, not fully checked.
An acknowledged file never reads as a clean one. Strict mode does not override it, because an
acknowledgement is a statement about the analyser, not about whether a violation is old.
An acknowledgement covers analysis.parse_error and nothing else — one that reached a
threshold finding would be an ignore list wearing another name.
scitools-hook init --detect will propose acknowledgement entries for the files it finds,
commented out, because uncommenting one is the operator's decision.
The adaptive baseline¶
With adaptive = true the effective limit becomes min(configured, baseline) for a maximum
and max(configured, baseline) for a minimum. A baseline can only narrow the configured
limit, never widen it. Tightening only ever lowers a recorded value, and only runs on a
whole-project analysis.
Do not commit a baseline you captured by accident. This repository gitignores the file precisely because two were once captured from whatever the tree happened to be at the time, which is not a decision.
Remediation hints¶
Every finding carries a hint. Override one:
Environment variables¶
| Variable | Effect |
|---|---|
SCITOOLS_HOME |
The Understand installation to use. |
SCITOOLS_HOOK_* |
Overrides for configuration keys, above the repository file. |
SCITOOLS_HOOK_SKIP |
Set non-empty to skip the gate for one commit. A chained hook still runs. |
SCITOOLS_HOOK_SOFT_FAIL |
Set non-empty to warn instead of blocking when the gate could not run (exit 2 and above). Findings, exit 1, block regardless. |
git commit --no-verify skips every hook, not just this one.