krazybean / untangled
Dependency health your whole team can understand: cycles and coupling in PHP projects, in a report anyone can read.
Requires
- php: >=8.2
- nikic/php-parser: ^5.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The PHP library and CLI of Untangled. It scans a Composer project into the pairs document defined by the shared spec, analyzes it for cycles and coupling (metrics, clustering), and writes a report anyone on the team can read.
Untangled is a re-imagining of snakefood by Martin Blais; the scan → pairs → cluster → render pipeline is his design. See Credit.
- Install
- Quick start
- Commands:
scan,analyze,report - Options
- Recipes
- What it reads
- Exit codes and warnings
- Diagnostics for programs
- Development
Install
Requires PHP 8.2+. The only runtime dependency is nikic/PHP-Parser.
Install it from Packagist, as a dev dependency of the project you want to look at, or globally:
composer require --dev krazybean/untangled
vendor/bin/untangled --version
composer global require krazybean/untangled # puts `untangled` on your PATH
utg is a short alias for untangled; every example below works with
either. The examples use vendor/bin/untangled; with a global install it is
just untangled. Untangled reads the project you point it at and never runs its
code, so it does not need to be installed inside that project.
Quick start
# A report to share: one HTML file, open it in any browser vendor/bin/untangled report /path/to/project --cluster=namespace:2 --output=report.html # The numbers behind it, as JSON vendor/bin/untangled analyze /path/to/project --cluster=namespace:2 # The raw dependency list vendor/bin/untangled scan /path/to/project --output=pairs.json
The path is the folder that holds the project's composer.json. It defaults
to the current folder.
Commands
untangled scan [<path>] [scan options] [--format=json|snakefood] [--output=<file>]
untangled analyze [<path>|<pairs.json>] [scan options] [filter options] [--output=<file>]
untangled report [<path>|<pairs.json>] [scan options] [filter options]
[--format=html|dot] [--style=classic] [--title=<text>] [--output=<file>]
untangled dependents [<path>|<pairs.json>] <file-or-symbol>... [scan options] [--include/--exclude ...]
[--transitive] [--max-depth=<n>] [--through=<glob>]...
[--format=text|json] [--output=<file>]
Each command runs the steps before it: analyze scans first, and report
scans and analyzes first. analyze and report also accept a pairs document
saved by scan, so a large project can be scanned once and analyzed many
ways. untangled --help prints the same summary.
scan
Writes every file-to-file dependency of the project as a list of
(from, to) pairs.
vendor/bin/untangled scan /path/to/project # pairs document (JSON) to stdout vendor/bin/untangled scan /path/to/project --output=pairs.json # ... to a file vendor/bin/untangled scan /path/to/project --dev # include autoload-dev (tests) vendor/bin/untangled scan /path/to/project --format=snakefood # snakefood's format, for sfood-* tools
The default output is the pairs document: one node
per file (with its namespace and the classes it declares) and per package,
builtin or unresolved name it uses, and one edge per dependency with how it
was made (use, extends, new, include and so on) and how often. For
example:
{
"format": "untangled-pairs",
"language": "php",
"nodes": [
{ "id": "src/Cart.php", "kind": "internal", "module": "Shop", "symbols": ["Shop\\Cart"] },
...
],
"edges": [ ... ]
}
--format=snakefood writes the same dependencies the way snakefood's
sfood did, one Python tuple per line, so existing sfood-* scripts can
read them:
(('.', 'src/Cart.php'), ('.', 'src/Order.php'))
(('.', 'src/Catalog.php'), (None, None))
analyze
Writes cycles and coupling metrics as JSON (the analysis document).
vendor/bin/untangled analyze /path/to/project # per file vendor/bin/untangled analyze /path/to/project --cluster=namespace:2 # per namespace, e.g. App\Billing vendor/bin/untangled analyze /path/to/project --cluster=folder:2 --exclude='src/Legacy/**' vendor/bin/untangled analyze pairs.json --cluster=namespace:2 # from a saved scan
It holds:
- summary: number of units and dependencies, number of cycles, the largest one, how many units sit in a cycle, mean fan-out, and how many external packages and unresolved references were found;
- units: per file (or per cluster with
--cluster): fan-in, fan-out, instabilityI = Ce / (Ca + Ce), external and unresolved counts, andca/cefor clusters; - cycles: every dependency cycle, its members and one example loop, such
as
src/Cart.php → src/Order.php → src/Cart.php.
What each number means, and why it matters, is in metrics.
dependents
Lists the files that depend on one or more files, before you change them or
when you choose which tests to run. A target is a node id (a file path) or,
where the scanner records them, a declared symbol (App\\Billing\\Tax).
vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php vendor/bin/untangled dependents /path/to/project App\\Billing\\Tax --transitive vendor/bin/untangled dependents pairs.json src/Billing/Tax.php --transitive --format=json vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php --transitive --exclude='**' --include='tests/**'
By default it lists the direct dependents. --transitive adds the files that
depend on those, and so on, each with its depth (the shortest distance from a
target). --include and --exclude filter the list, not the walk, so a test
that reaches a target only through application code is still found (scan with
--dev to have tests in the graph). The text form groups files by folder;
--format=json writes the document of the
spec.
The first argument is the source when it is a folder or a .json file; any
other argument is a target, and the source is the current folder. A target
that matches nothing exits with code 1.
To choose the tests to run after a change, limit the walk instead of the list.
--max-depth=N (with --transitive) stops after N steps from the targets, and
--through=<glob> (repeatable, any match counts) only walks into files that
match, so nothing is found by way of other files:
# tests that use the file, or reach it only through other tests (helpers, builders, base classes) vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php --transitive --through='tests/**' # application code that reaches it, not through tests vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php --transitive --through='src/**' # tests of the files that use it directly vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php --transitive --max-depth=2 --exclude='**' --include='tests/**'
A file that --through leaves out is never listed, even when it uses a target
directly, and it is not walked through. --include and --exclude still only
filter the list afterwards, and --max-depth without --transitive is a usage
error (exit code 2). A target can also be an absolute path inside the project,
or a path with ./ or \ separators. A class name may start with a
backslash (\App\Billing\Tax).
report
Writes one self-contained HTML page meant for people who do not read code. It needs no network access to open and can be emailed or attached to a ticket as is.
vendor/bin/untangled report /path/to/project --output=report.html
vendor/bin/untangled report /path/to/project --cluster=namespace:2 --title="Billing" --output=report.html
vendor/bin/untangled report pairs.json --cluster=folder:2 --output=report.html
The page has a summary in plain words, why coupling and cycles matter, an
interactive map of the dependencies (capped at the 300 most connected units;
cluster to see the whole project), each cycle written out as a loop, the
parts to look at first, and every metric with a short explanation. The map
can be downloaded from the page as a Graphviz .dot file.
--format=dot writes that Graphviz file directly, for rendering with dot
or pasting into another tool. Units in a cycle are drawn in red.
--style=classic draws the black and white look of snakefood's
sfood-graph instead (grey ellipses, packages and unresolved names in white,
PHP builtins left out):
vendor/bin/untangled report /path/to/project --format=dot | dot -Tsvg > graph.svg vendor/bin/untangled report /path/to/project --format=dot --style=classic --cluster=namespace:2 | dot -Tpng > graph.png
The file asks for Graphviz's dot layout itself, so other Graphviz programs
and viewers lay it out the same way. On a large project, render a clustered
graph (--cluster=namespace:2, or folder:2) rather than one node per file:
thousands of files take dot minutes and come out too small to read.
Options
Options are written --name=value; a value with spaces needs quotes
(--title="Billing team"). Options marked repeatable can be given more than
once.
| Option | Commands | What it does |
|---|---|---|
--dev |
all | Also scan the autoload-dev rules of composer.json, usually the tests. |
--source=<path> |
all | Also scan the .php files under <path> (relative to the project) that composer.json does not autoload, such as a legacy lib/ or a Laravel app's routes/ and config/. The folder is also used to resolve include / require paths, like PHP's include_path. Repeatable. |
--aliases=<preset|file> |
all | Resolve global class aliases a framework registers at runtime (class_alias). laravel is the built-in preset; a JSON file declares your own. Repeatable, a later one wins. See Class aliases. |
--format=<format> |
scan, report |
scan: json (default, the pairs document) or snakefood. report: html (default) or dot. |
--style=<style> |
report --format=dot |
default, or classic for snakefood's black and white sfood-graph look. |
--title=<text> |
report |
The report's title. Default: the Composer package name, else the folder or file name. |
--cluster=<strategy> |
analyze, report |
Group files before measuring: namespace:N, folder:N or rules:<file>. See Clustering. |
--transitive |
dependents |
Also list the files that depend on a dependent, with their depth. |
--max-depth=<n> |
dependents |
With --transitive, stop after n steps from the targets (a positive integer). |
--through=<glob> |
dependents |
Only walk into files matching <glob>; nothing is found by way of other files. Repeatable, any match counts. |
--strict |
analyze, report |
With --cluster=rules:<file>, exit 1 instead of warning when a file matches no rule. See Clustering. |
--include=<glob> |
analyze, report |
Keep files whose path matches <glob>. Repeatable. |
--exclude=<glob> |
analyze, report |
Drop files whose path matches <glob>. Repeatable. See Include and exclude. |
--output=<file> |
all | Write to <file> instead of standard output. |
--diagnostics=<format> |
all | How warnings and errors are written to standard error: text (default), or json, one object per line. See Diagnostics for programs. |
-h, --help |
Print the usage summary. | |
-V, --version |
Print the version. |
An option a command does not take (scan --cluster=…) is an error, not
silently ignored.
Clustering
Without --cluster, every file is its own unit. With it, files are grouped
and the metrics describe the groups, which is usually what a team wants to
look at.
| Strategy | Groups files by | Example |
|---|---|---|
namespace:N |
The first N segments of the file's namespace. Files with no namespace fall back to folder:N. |
namespace:2: App\Billing\Tax → App\Billing |
folder:N |
The first N folders of the file's path. |
folder:2: src/Billing/Tax.php → src/Billing |
rules:<file> |
Your own prefix rules, read from <file>. |
see below |
A rules file has one rule per line, a path prefix and a cluster name
separated by spaces; # starts a comment. The first prefix that matches a
file wins, and a file no rule matches stays on its own. This is the format of
snakefood's sfood-cluster. So that a new folder does not drop out of the
grouped view unnoticed, analyze and report print a warning on standard
error that names the files no rule matches; add --strict to make it an error
(exit code 1, no output), for CI.
# prefix cluster
src/Billing/ Billing
src/Invoice Billing
src/ Core
vendor/bin/untangled report /path/to/project --cluster=rules:clusters.rules --output=report.html
Packages, builtins and unresolved names are never clustered. The details are in clustering.
Include and exclude
--include and --exclude take globs matched against the whole file path,
relative to the project (src/Billing/Tax.php). * matches within one
folder, ? one character, and ** any number of folders (**/ also matches
none, so **/*Test.php matches FooTest.php at the top level too).
The patterns are checked in the order given and the last one that matches wins; a file no pattern matches is kept. So:
# Everything except the legacy code --exclude='src/Legacy/**' # Only Billing: drop everything, then add Billing back --exclude='**' --include='src/Billing/**' # Billing, but not its tests --exclude='**' --include='src/Billing/**' --exclude='**/*Test.php'
Quote globs so the shell does not expand them. Removing a file removes its dependencies too, and a package or unresolved name nothing remaining uses disappears from the report.
Class aliases
Frameworks can register short global names for classes at runtime: in
Laravel, \DB means Illuminate\Support\Facades\DB. A scanner cannot see
that, so by default such names are reported as unresolved. Declare them with
--aliases:
vendor/bin/untangled scan /path/to/project --aliases=laravel vendor/bin/untangled scan /path/to/project --aliases=laravel --aliases=my-aliases.json
The laravel preset holds Laravel's default facade aliases, plus the
aliases installed packages declare under extra.laravel.aliases in their
composer.json. Your own file looks like this:
{
"aliases": {
"DB": "Illuminate\\Support\\Facades\\DB",
"Money": "App\\Support\\Money"
}
}
When two declarations name the same alias, the later --aliases wins.
Aliases nobody declared stay unresolved.
Recipes
Scan once, analyze many ways. Scanning is the slow step on a large project:
vendor/bin/untangled scan /path/to/project --source=lib --output=pairs.json vendor/bin/untangled report pairs.json --cluster=namespace:2 --output=by-namespace.html vendor/bin/untangled report pairs.json --cluster=folder:1 --output=by-folder.html
Scan options (--dev, --source, --aliases) only apply when scanning, so
pass them to scan in this case.
A Laravel application. Routes and config are not autoloaded, and facades are aliases:
vendor/bin/untangled report /path/to/app --source=routes --source=config --aliases=laravel \ --cluster=namespace:2 --output=report.html
A legacy application. Add the folders its own autoloader or
include_path uses; the scanner's warnings name folders with .php files it
left out:
vendor/bin/untangled report /path/to/app --source=lib --source=includes --cluster=folder:2 --output=report.html
A picture for a slide. Render the classic graph per namespace:
vendor/bin/untangled report /path/to/project --format=dot --style=classic --cluster=namespace:2 \ | dot -Tsvg > dependencies.svg
Feed snakefood tools.
vendor/bin/untangled scan /path/to/project --format=snakefood | sfood-graph | dot -Tpng > graph.png
What it reads
The full rules are in the PHP profile. In short:
- The project must have a
composer.json. Itsautoloadrules (psr-4,psr-0,classmap,files) decide which files are scanned;vendor/never is. - Code that Composer does not autoload is added with
--source=<folder>. - Global aliases a framework registers at runtime are resolved only when
declared with
--aliases. include/requirewith a literal path (string literals,__DIR__,dirname()) are followed, and the included file is scanned too. Paths built at runtime are reported as unresolved.- Every class reference resolves to a project file, a Composer package (from
vendor/composer/installed.jsonorcomposer.lock), a PHP builtin, or an unresolved node. Builtins are left out ofanalyzeandreport; packages and unresolved names are listed but do not count toward the coupling metrics.
Exit codes and warnings
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
The input could not be read: no composer.json, an unreadable pairs, rules or aliases file, or an output file that cannot be written. |
2 |
Usage error: unknown command, option, format, style or clustering strategy. |
Warnings go to standard error and do not change the exit code, so output
piped to a file stays clean. The scanner warns when .php files were left
out (naming the folders that hold them, so you can decide what to pass to
--source), when there is no vendor/ or composer.lock to trace
packages with or the one it finds is not valid JSON, when it finds no files
to scan at all, and for each file that does not parse. Such a file stays in
the pairs document marked "error": "parse", and the report lists it under
"Files we could not read".
Diagnostics for programs
Everything the commands have to say about a project goes to standard error;
standard output holds only the result. By default that is text, one
warning: ... or error: ... line each. --diagnostics=json writes one JSON
object per line instead, with a stable code to branch on, so a CI job or an
agent never has to read the wording of a message:
vendor/bin/untangled scan /path/to/project --diagnostics=json --output=pairs.json 2> diagnostics.jsonl jq -r 'select(.code == "uncovered-files") | .files[]' diagnostics.jsonl
{"level":"warning","code":"file-unparseable","message":"src/Broken.php: parse error: ...","files":["src/Broken.php"]}
level is warning or error (the reason a command stopped, with exit code
1), code names the condition, message is for people, and files lists the
project-relative paths it is about when there are any. The exit code and the
standard output do not change. The codes are listed in the
diagnostics specification,
which also says how to read the lines. --diagnostics=github (annotations for
GitHub Actions) is not supported yet; a formatter can be built on the JSON
lines. A usage error (exit code 2) is always text.
From PHP, Scanner::scan() returns a ScanResult whose diagnostics are
Untangled\Diagnostic objects (level, code, message, files, toArray(),
toJson()); its older warnings list of strings is kept.
Development
composer install
composer test
composer test runs PHPUnit (a dev dependency in composer.json). The suite
has one test file per class under tests/, mirroring src/, plus
tests/ConformanceTest.php, which runs every shared case in
conformance/cases. Tests that need files on
disk write a throwaway project with tests/TempProject.php.
License
MIT, see LICENSE.