Operations: what breaks, and why¶
Everything on this page was measured against Understand 6.5.1204 on Linux. Most of it is invisible until it costs you something, and several items are silent false negatives — a run that reports success over code it never read.
If you are wiring und into something yourself rather than using this tool, read the first
section at minimum.
Understand picks the Python dialect by running python¶
und decides which Python dialect to parse by executing a bare python it finds on
PATH. When there is none, it analyses under a Python 2 model.
Measured on identical sources, the same und, with only PATH differing:
python on PATH |
Result |
|---|---|
| present | Errors:0, routines ['after', 'before'] |
| absent | Errors:8, routines ['before'], and has_key / iteritems / raw_input in the database |
Look at the second row again. The routine after the parse failure is not an error. It is an absence. It has no metrics, so it breaks no threshold, so nothing is reported and the run exits 0.
That is the worst failure a gate can have, and it is the default behaviour of a machine that
has python3 but no python.
The name is the whole difference¶
A directory holding only python3 gives the Python 2 model. The same real interpreter
symlinked under the name python gives the Python 3 model. Nothing else changes.
What this tool does about it¶
It supplies its own. Every und invocation runs with a PATH whose first entry is a
0700 temporary directory containing one symlink named python, pointing at
sys.executable.
sys.executable rather than a search, and the reason is availability rather than taste:
"missing" is the one input that must be impossible, and the only interpreter guaranteed to
exist, to be executable and to be Python 3 is the one the process is already running.
The minor version does not matter. Understand's fallback is binary — a Python 2 model or a Python 3 model — and 3.12.3 and 3.14.4 were both measured producing the Python 3 model.
Three further details, each of which was measured because assuming otherwise was wrong:
Per invocation, not per process. A database analysed under the Python 3 model reverts to
the Python 2 model on the very next analyze run without a python — eight parse errors,
has_key back in the database, the routine after the failure gone again. Nothing is
remembered in the database or its settings, so nothing but pinning every call holds the
dialect.
Prepending, not replacing. Measured in both directions: a decoy python printing
Python 2.7.18 placed before the link gives the Python 2 model; the same two directories in
the other order give the Python 3 model.
PATH alone is not enough. Three environment variables were each measured defeating the
pin:
| Variable | What it did |
|---|---|
PYTHONHOME=/nonexistent |
The pinned link dies with a fatal error before printing anything, und reads that as "no python", and the database comes back Python 2. |
PYTHONPATH=<repo>/src |
On this repository's 172 files: 1272 intra-tree edges without it, 66 with it — the same 66 an unpinned run gives. That is the whole of structure.new_dependencies and structure.fan switched off by one variable, with nothing in the report naming it. |
PYTHONUSERBASE pointing at a site-packages with a .pth |
The same leak, which is why PYTHONNOUSERSITE is set rather than merely cleared. |
The pin therefore strips every PYTHON* variable — the environment spelling of python -E,
as a prefix rule rather than a list — and sets PYTHONNOUSERSITE=1, the spelling of
python -s. Everything non-Python reaches und untouched, because und reads its licence
from HOME and its configuration from the rest of the environment.
A pin that cannot be built is a refusal, not a fallback. An und given an uncontrolled
PATH may analyse Python 2 and report success over code it never read, and a failure an
operator can see is worth more than a green run nobody can question.
Why not und settings -PythonExe¶
It fixes the dialect too, and it writes ~/.config/SciTools/Und.conf, which becomes the
default for every database created afterwards on the machine and is rewritten on every
run.
Two consecutive runs of one identical command were measured producing 316 and 231 findings before that key was controlled.
What the pin costs you, deliberately¶
The pinned interpreter is a bare one, so Understand cannot see your third-party packages.
Measured: with the gate's own virtual environment python directly on PATH, a run
enrolled 365 files, 224 of them from site-packages, and a class deriving from
pydantic.BaseModel scored MaxInheritanceTree 5. Through the pinned link, the same
sources enrolled 2 files and the same class scored 1.
That is why class.MaxInheritanceTree ships as a warning. See
Rules and defaults.
doctor prints the interpreter on every run:
The whole defect that row exists for is that two machines analyse one commit to different depths and nothing in the output says so.
A file that does not parse is a blocking finding¶
Not a warning. A file the analyser could not read must never report as clean.
$ scitools-hook check --staged
parse errors: these files were NOT fully checked
Understand could not finish parsing them. Code after a parse error can be missing
from the analysis, so no rule ran on it: what follows covers only the code that parsed.
A file in this run's selection that failed to parse is also a blocking analysis.parse_error
finding below; one outside it -- the interpreter's own standard library, say -- is not.
pricing/generic.py
line 4: expected token '(' at token [
line 5: expected token ':' at token indent
line 9: expected identifier at token dedent
...
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.
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, 1 blocking | 1 file failed to parse, not fully checked | exit 1: blocking violations found
Measured on this repository: one PEP 695 type-parameter list took config/models.py from
15 classes to 3 in the database, hid 12 findings and fabricated 2, and the run exited
0.
Two boundaries keep this usable:
- Only the after side. A before-side error that this change fixed would otherwise block the very commit that fixed it.
- Only files in the selection. A clean run of this repository was measured producing four
parse errors inside the interpreter's own standard library. Blocking a commit over
typing.pywould get the gate switched off. Those are reported and do not block.
The constructs that fail, on 6.5.1204¶
Measured with a Python 3 interpreter on PATH. Four declarations of thirteen tried aborted
the parse:
def generic[T](x: T) -> T: expected token '(' at token [ line 1
class Box[T]: expected token ':' at token [ line 1
type Alias = int expected newline at token Alias line 1
except* ValueError: expected token ':' at token * line 4
The failure modes differ:
| Construct | The routine after it |
|---|---|
def f[T], class C[T], async def f[T], def f[**P] |
gone from the database — the error cascades to end of file |
type A = int, type A = int \| str, type A[T] = list[T] |
present — one error, that line only |
Detection uses an ast walk rather than a regular expression, because a regular expression
for def f[T]( matches this project's own hint catalogue. A file holding both kinds is
reported as the truncating one.
One entry on that list is a red herring worth knowing about: [first, *rest] does fail
under the Python 2 dialect, which is how it came to be recorded as a hazard. It is not a 3.12
problem at all.
Parse errors survive a warm run¶
und analyze is incremental, so a warm run re-parses nothing and reports nothing.
Measured on this repository: a cold staged run named 9 unparsed files, and three consecutive warm runs over the same two databases — still holding the same unparseable files — named none. A git hook is always warm.
So the sync state carries each side's errors between runs, and every analysis rewrites only
the part of that record it actually re-read. A full pass replaces the side's whole set; a
-files pass replaces the entries of the files it named, so fixing a file clears its errors,
and carries the rest forward untouched.
The licence¶
Offline, and bound to the hostname. Measured three ways:
| Test | Result |
|---|---|
unshare -rn und -isundlicensed |
1 — licensed with no network at all |
the same, with HOME relocated |
1 |
unshare -u, hostname changed |
0 |
| the hostname restored inside the same namespaces | 1 |
A container or a CI runner that wants the licensed path must pin --hostname to the licensed
machine's name. Moving HOME is fine.
Provenance
This is a measurement of one installed licence on one machine. It is recorded in this
repository's README.md, its Dagger module and its task log; it is not enforced or
checked anywhere in the shipped code, and it is not a statement about SciTools licensing
in general.
An earlier version of this claim said the licence was floating and needed a heartbeat.
That was inferred from the field names heartrate, canCheckout and
lastfailedheartbeat* in License.conf, was never measured, and was simply wrong.
A licence failure exits 4 and is never retried and never falls back, because a retry cannot produce a licence.
A separate licence line covers CodeCheck. On a licence that excludes it, und codecheck
answers Licensing Error: No license for CodeCheck. and writes nothing.
API modes¶
The Python API is reached in one of two ways, and --api-mode / understand.api_mode
selects which.
| Mode | What it does |
|---|---|
upython |
Runs upython worker.py <op> as a subprocess, request JSON in, answer JSON out. |
inprocess |
Imports the understand module into the host interpreter and calls the same functions. |
auto |
Tries upython first, falls back to inprocess. The default. |
auto prefers upython for a measured reason. The in-process import itself is not
broken: with a licence active, import understand, open(), entity iteration, metric()
and close() all succeed, on both /usr/bin/python3.12 and CPython 3.14.4.
Ent.draw is the exception. In-process it dies with:
symbol lookup error: <home>/bin/linux64/Perl/auto/Fcntl/Fcntl.so:
undefined symbol: Perl_xs_handshake
status 127. Drawing loads Understand's bundled Perl and Qt stack, which resolves only under
upython. In-process that abort kills the gate, not one operation, so the graphs
operation is routed to upython whatever the mode is.
A forced mode runs only its own probe. The in-process probe is the dangerous one, and an
operator who forced upython must never have it run behind their back.
There is one more oddity, at the other end of a drawing run. After Ent.draw has rendered a
dependency graph, the bundled interpreter aborts at shutdown with Fatal Python error:
PyInterpreterState_Delete: remaining subinterpreters and dies of SIGABRT. The answer is
complete and correct on standard output by then; only the exit status is destroyed. Drawing a
butterfly graph first happens to avoid it and drawing one afterwards does not, so there is
no ordering a caller could rely on. The worker therefore flushes and calls os._exit(status).
Never pip install understand. The PyPI package of that name is unrelated to SciTools
Understand.
Databases¶
Two per repository — before and after — kept outside the working tree, under the per-user
cache directory by default:
/home/you/.cache/scitools-hook/7bb1a9ede8d2b6d9/after.und
/home/you/.cache/scitools-hook/7bb1a9ede8d2b6d9/before.und
/home/you/.cache/scitools-hook/7bb1a9ede8d2b6d9/state.json
understand.db_location = "gitdir" puts them in .git/scitools-hook/ instead. Neither is
committed.
The cache root is created 0700 before anything is written into it, and the mode is set
explicitly rather than left to the umask, because the databases and shadow trees are copies
of your source. A database created into a world-readable directory is readable for as long as
it takes to fix.
Both databases are discarded and rebuilt when the enabled language set or the Understand
version changes. Without that, the first run after an Understand upgrade meets a database the
new build refuses to open, and the operator has to work out that db rebuild is the answer.
You will see the language-set case in ordinary use: adding one .html file to a Python
repository takes the run from created the after analysis database with Python enabled to
with Python, Web enabled, which is a full rebuild.
und version does not print a version¶
No product version. The Python API, asked separately, reports 6.5.1204. und -version and
und --version are not switches this build knows.
So if you are parsing that output, do not expect a 6.5.x string. doctor shows both, which
is why its und version row and its probe rows disagree.
Understand's own quirks that the extension map exists for¶
.sql enters under Pascal. .m and .mm are C++. The map is case-sensitive: a.C
is C++ and a.PY is nothing at all. .txt, .pl, .bat and .cbl are absent although
Understand's FileTypes table names them, and .bas is absent while .vb is present.
None of that is derivable from the table. It was measured by building a database per language
over a tree holding one file per extension and reading und list files back, and a contract
test re-measures it against the installed build in both directions.
c++ is also not a kind-string language inside Understand: Metric.list("c++ file …")
answers nothing while Metric.list("c file …") answers 42, and the kind long names read
C Class Type. Exactly one alias closes that gap; without it a C++ repository has no
available metric and every threshold it configures is rejected.
See Languages.
When something is wrong¶
scitools-hook doctor # always exits 0; read the Problems section
scitools-hook --verbose ... # external commands, timings, tracebacks
scitools-hook db rebuild # discard both databases and analyse again
scitools-hook db path # works even when Understand does not
doctor uses a 60-second timeout on its und calls rather than the wrapper's 900, because
it is the command an operator runs when things are already broken and a wedged und would
otherwise delay the report by up to half an hour.